diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e995de8b..9f7de4e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,264 +1,31 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - markdown: - runs-on: ubuntu-latest - name: Markdown Lint - - steps: - - uses: actions/checkout@v7 - - - name: Lint Markdown files - uses: DavidAnson/markdownlint-cli2-action@v19 - with: - globs: | - *.md - .github/**/*.md - config: ".markdownlint.yaml" - - quality: - runs-on: ubuntu-latest - name: Lint, Format & Test - - steps: - - uses: actions/checkout@v7 - - - uses: ./.github/actions/setup-flutter-workspace - - - name: Check formatting - run: dart format --output=none --set-exit-if-changed . - - - name: Analyze - run: melos run analyze - - - name: Check auto-fixable issues - run: | - OUTPUT=$(dart fix --dry-run . 2>&1) - echo "$OUTPUT" - echo "$OUTPUT" | grep -q "Nothing to fix!" || { echo "::error::Auto-fixable issues found. Run 'dart fix --apply' and commit."; exit 1; } - - - name: Verify generated code (Drift) - run: | - cd core && dart run build_runner build --delete-conflicting-outputs - git diff --exit-code . || { echo "::error::Generated code is out of date. Run 'dart run build_runner build --delete-conflicting-outputs' in core/ and commit."; exit 1; } - - - name: Verify generated localizations - run: | - cd app && flutter gen-l10n - git diff --exit-code lib/l10n/ || { echo "::error::Generated l10n files are out of date. Run 'flutter gen-l10n' in app/ and commit."; exit 1; } - - - name: Check outdated dependencies - run: melos run outdated - continue-on-error: true - - - name: Run tests - run: melos run test:coverage - - - name: Upload coverage artifacts - uses: actions/upload-artifact@v7 - if: always() - with: - name: coverage-reports - path: | - core/coverage/lcov.info - listener/coverage/lcov.info - app/coverage/lcov.info - retention-days: 1 - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - if: always() - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: core/coverage/lcov.info,listener/coverage/lcov.info,app/coverage/lcov.info - flags: core,listener,app - fail_ci_if_error: false - - release-readiness: - runs-on: ubuntu-latest - name: Release Readiness - - steps: - - uses: actions/checkout@v7 - - - name: Validate pubspec structure - run: | - errors=0 - for pkg in app core listener; do - if [ ! -f "$pkg/pubspec.yaml" ]; then - echo "::error::Missing $pkg/pubspec.yaml" - errors=$((errors + 1)) - continue - fi - version=$(grep '^version:' "$pkg/pubspec.yaml" | head -1 | awk '{print $2}') - name=$(grep '^name:' "$pkg/pubspec.yaml" | head -1 | awk '{print $2}') - if [ -z "$version" ]; then - echo "::error::Missing version field in $pkg/pubspec.yaml" - errors=$((errors + 1)) - fi - if [ -z "$name" ]; then - echo "::error::Missing name field in $pkg/pubspec.yaml" - errors=$((errors + 1)) - fi - echo "OK $pkg: name=$name version=$version" - done - [ $errors -eq 0 ] || exit 1 - - - name: Validate release workflow references - run: | - errors=0 - for wf in release-win.yml release-mac.yml; do - if [ ! -f ".github/workflows/$wf" ]; then - echo "::error::Missing .github/workflows/$wf (referenced by release.yml)" - errors=$((errors + 1)) - else - echo "OK .github/workflows/$wf" - fi - done - for act in setup-flutter-workspace; do - if [ ! -f ".github/actions/$act/action.yml" ]; then - echo "::error::Missing .github/actions/$act/action.yml (referenced by the build jobs)" - errors=$((errors + 1)) - else - echo "OK .github/actions/$act/action.yml" - fi - done - [ $errors -eq 0 ] || exit 1 - - - name: Validate project structure - run: | - errors=0 - for dir in app/lib app/windows app/macos app/assets listener/windows listener/macos; do - if [ ! -d "$dir" ]; then - echo "::error::Missing required directory: $dir" - errors=$((errors + 1)) - else - echo "OK $dir/" - fi - done - for file in app/l10n.yaml app/pubspec.yaml core/pubspec.yaml listener/pubspec.yaml; do - if [ ! -f "$file" ]; then - echo "::error::Missing required file: $file" - errors=$((errors + 1)) - else - echo "OK $file" - fi - done - [ $errors -eq 0 ] || exit 1 - - - name: Simulate release version extraction - run: | - test_tags=("v2.1.0" "v2.0.0-beta.1" "v3.0.0" "v1.0.0-rc.1") - for tag in "${test_tags[@]}"; do - ref="refs/tags/$tag" - if [[ "$ref" =~ refs/tags/v(.+) ]]; then - VERSION="${BASH_REMATCH[1]}" - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "::error::Tag $tag produces invalid version: $VERSION" - exit 1 - fi - echo "OK $tag -> $VERSION" - else - echo "::error::Tag $tag would fail version extraction in release.yml" - exit 1 - fi - done - - build: - needs: quality - runs-on: windows-latest - name: Test & Build (Windows) - - steps: - - uses: actions/checkout@v7 - - - uses: ./.github/actions/setup-flutter-workspace - - - name: Run tests - shell: bash - run: dart pub global run melos:melos run test:coverage - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - if: always() - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: core/coverage/lcov.info,listener/coverage/lcov.info,app/coverage/lcov.info - flags: windows - fail_ci_if_error: false - - - name: Build Windows release - run: cd app; flutter build windows --release - - build-macos: - needs: quality - runs-on: macos-latest - name: Test & Build (macOS) - - steps: - - uses: actions/checkout@v7 - - - uses: ./.github/actions/setup-flutter-workspace - - - name: Run tests - shell: bash - run: dart pub global run melos:melos run test:coverage - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - if: always() - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: core/coverage/lcov.info,listener/coverage/lcov.info,app/coverage/lcov.info - flags: macos - fail_ci_if_error: false - - - name: Build macOS release - run: cd app && flutter build macos --release - - sonarcloud: - name: SonarCloud - needs: quality - runs-on: ubuntu-latest - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Download coverage reports - uses: actions/download-artifact@v7 - with: - name: coverage-reports - path: . - - - name: SonarCloud Scan - uses: SonarSource/sonarqube-scan-action@v5 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - args: > - -Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }} - -Dsonar.organization=${{ vars.SONAR_ORGANIZATION }} - -Dsonar.sources=core/lib,listener/lib,app/lib - -Dsonar.tests=core/test,listener/test,app/test - -Dsonar.dart.lcov.reportPaths=core/coverage/lcov.info,listener/coverage/lcov.info,app/coverage/lcov.info - -Dsonar.exclusions=**/generated/**,**/*.g.dart,**/*.freezed.dart,**/l10n/**,**/core.dart - -Dsonar.coverage.exclusions=**/main.dart,**/shell/**,**/services/auto_update_service.dart,**/windows_clipboard_listener.dart,**/l10n/**,**/*.g.dart,**/*.freezed.dart,**/core.dart,**/screens/settings_screen.dart - -Dsonar.qualitygate.wait=true - -Dsonar.qualitygate.timeout=300 +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Format, lints and tests + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all -- --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace + + deny: + name: Dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml new file mode 100644 index 00000000..19780489 --- /dev/null +++ b/.github/workflows/commits.yml @@ -0,0 +1,24 @@ +name: Commits + +on: + pull_request: + +jobs: + conventional: + name: Conventional commits + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Every commit follows the convention + run: | + pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: .{1,72}$' + fail=0 + while read -r sha; do + subject=$(git log -1 --format=%s "$sha") + if ! printf '%s' "$subject" | grep -qE "$pattern"; then + echo "no sigue la convencion: $subject"; fail=1 + fi + done < <(git rev-list origin/${{ github.base_ref }}..HEAD) + exit $fail diff --git a/.github/workflows/release-mac.yml b/.github/workflows/release-mac.yml deleted file mode 100644 index ffb0d555..00000000 --- a/.github/workflows/release-mac.yml +++ /dev/null @@ -1,202 +0,0 @@ -name: Release (macOS) - -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-macos: - runs-on: macos-15 - timeout-minutes: 45 - name: Build macOS (Universal) - - env: - MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.ref_name }} - - - name: Resolve version - id: get_version - env: - VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - BUILD_NAME="${VERSION%-*}" - BUILD_NUMBER=$(echo "$BUILD_NAME" | awk -F. '{printf "%d%03d%03d", $1, $2, $3}') - - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "BUILD_NAME=$BUILD_NAME" >> "$GITHUB_OUTPUT" - echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_OUTPUT" - echo "Version: $VERSION Build: $BUILD_NAME ($BUILD_NUMBER)" - - - uses: ./.github/actions/setup-flutter-workspace - with: - version: ${{ steps.get_version.outputs.VERSION }} - - - name: Import signing certificate - if: env.MACOS_CERTIFICATE_P12 != '' - run: | - KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" - KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - CERT_PATH="$RUNNER_TEMP/certificate.p12" - echo "$MACOS_CERTIFICATE_P12" | base64 --decode > "$CERT_PATH" - - security import "$CERT_PATH" \ - -k "$KEYCHAIN_PATH" \ - -P "$MACOS_CERTIFICATE_PASSWORD" \ - -T /usr/bin/codesign \ - -T /usr/bin/security - - security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db - security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - rm -f "$CERT_PATH" - echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" - echo "Certificate imported successfully" - - - name: Refresh CocoaPods lock - run: | - rm -f app/macos/Podfile.lock - cd app/macos && pod install --repo-update - - - name: Build macOS release (universal) - run: | - cd app - flutter build macos --release \ - --build-name="${{ steps.get_version.outputs.BUILD_NAME }}" \ - --build-number="${{ steps.get_version.outputs.BUILD_NUMBER }}" \ - --dart-define="APP_VERSION=${{ steps.get_version.outputs.VERSION }}" - - - name: Verify universal binary - run: | - APP="app/build/macos/Build/Products/Release/CopyPaste.app" - ARCHS=$(lipo -archs "$APP/Contents/MacOS/CopyPaste") - echo "Architectures: $ARCHS" - if [[ "$ARCHS" != *"x86_64"* ]] || [[ "$ARCHS" != *"arm64"* ]]; then - echo "::error::Expected universal binary (x86_64 + arm64), got: $ARCHS" - exit 1 - fi - echo "Universal binary verified (x86_64 + arm64)" - - - name: Sign application - if: env.MACOS_CERTIFICATE_P12 != '' - run: | - APP_PATH="app/build/macos/Build/Products/Release/CopyPaste.app" - - SIGN_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | awk '{print $2}') - echo "Signing with identity: $SIGN_IDENTITY" - - codesign --deep --force --options runtime \ - --entitlements "app/macos/Runner/Release.entitlements" \ - --sign "$SIGN_IDENTITY" \ - "$APP_PATH" - - echo "Verifying signature..." - codesign --verify --deep --strict "$APP_PATH" - echo "Signature verified" - - - name: Ad-hoc sign (unsigned build) - if: env.MACOS_CERTIFICATE_P12 == '' - run: | - APP_PATH="app/build/macos/Build/Products/Release/CopyPaste.app" - codesign --deep --force --sign - "$APP_PATH" - echo "Ad-hoc signed (unsigned build)" - - - name: Install create-dmg - run: brew install create-dmg - - - name: Create DMG - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - APP_PATH="app/build/macos/Build/Products/Release/CopyPaste.app" - DMG_NAME="CopyPaste_${VERSION}_universal.dmg" - - mkdir -p app/dist - - create-dmg \ - --volname "CopyPaste" \ - --volicon "app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png" \ - --window-pos 200 120 \ - --window-size 660 400 \ - --icon-size 80 \ - --icon "CopyPaste.app" 180 190 \ - --app-drop-link 480 190 \ - --hide-extension "CopyPaste.app" \ - --no-internet-enable \ - "app/dist/$DMG_NAME" \ - "$APP_PATH" \ - || true - - if [[ ! -f "app/dist/$DMG_NAME" ]]; then - echo "::error::DMG was not created" - exit 1 - fi - - echo "DMG created: $DMG_NAME ($(du -h "app/dist/$DMG_NAME" | cut -f1))" - - - name: Sign DMG - if: env.MACOS_CERTIFICATE_P12 != '' - run: | - DMG_PATH="app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_universal.dmg" - - SIGN_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | awk '{print $2}') - echo "Signing DMG with identity: $SIGN_IDENTITY" - - codesign --force --sign "$SIGN_IDENTITY" "$DMG_PATH" - codesign --verify "$DMG_PATH" - echo "DMG signed" - - - name: Notarize DMG - if: env.APPLE_ID != '' && env.MACOS_CERTIFICATE_P12 != '' - run: | - DMG_PATH="app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_universal.dmg" - - echo "Submitting for notarization..." - xcrun notarytool submit "$DMG_PATH" \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_APP_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" \ - --wait \ - --timeout 600 - - echo "Stapling notarization ticket..." - xcrun stapler staple "$DMG_PATH" - - echo "Verifying notarization..." - spctl --assess --type open --context context:primary-signature "$DMG_PATH" - echo "Notarization complete" - - - name: Cleanup keychain - if: always() && env.KEYCHAIN_PATH != '' - run: security delete-keychain "$KEYCHAIN_PATH" || true - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: release-macos - path: app/dist/*.dmg - retention-days: 5 diff --git a/.github/workflows/release-win.yml b/.github/workflows/release-win.yml deleted file mode 100644 index 55824d29..00000000 --- a/.github/workflows/release-win.yml +++ /dev/null @@ -1,158 +0,0 @@ -name: Release (Windows) - -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: - release-windows: - runs-on: windows-latest - timeout-minutes: 30 - name: Build Windows - - env: - PFX_BASE64: ${{ secrets.PFX_BASE64 }} - PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} - - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.ref_name }} - - - name: Resolve version - id: get_version - shell: bash - env: - VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - 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: ./.github/actions/setup-flutter-workspace - with: - version: ${{ steps.get_version.outputs.VERSION }} - - - name: Install Fastforge - run: dart pub global activate fastforge - - - name: Install Inno Setup - run: choco install innosetup --no-progress - - - name: Build standalone (exe installer) - shell: pwsh - run: | - Push-Location app - fastforge package ` - --platform windows ` - --targets exe ` - --build-dart-define "STORE_BUILD=false" ` - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" - Pop-Location - - - name: Decode signing certificate - if: env.PFX_BASE64 != '' - shell: pwsh - run: | - $bytes = [Convert]::FromBase64String($env:PFX_BASE64) - Set-Content -Path signingCert.pfx -Value $bytes -AsByteStream - - - name: Sign standalone installer - if: env.PFX_BASE64 != '' - shell: pwsh - run: | - $pfxPath = Join-Path $PWD 'signingCert.pfx' - $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` - -Recurse -Filter "signtool.exe" | - Where-Object { $_.FullName -like "*x64*" } | - Sort-Object FullName -Descending | - Select-Object -First 1 - if (-not $signtool) { Write-Error "Signtool not found"; exit 1 } - - $distDir = "app/dist" - Get-ChildItem -Path $distDir -Include *.exe -Recurse | ForEach-Object { - & $signtool.FullName sign /f $pfxPath /p $env:PFX_PASSWORD ` - /tr http://timestamp.digicert.com /td sha256 /fd sha256 $_.FullName - } - - - name: Rename standalone installer - shell: pwsh - run: | - $version = "${{ steps.get_version.outputs.VERSION }}" - $distDir = "app/dist" - $setup = Get-ChildItem -Path $distDir -Recurse -Filter "*-setup.exe" | Select-Object -First 1 - if (-not $setup) { - Write-Host "::error::No installer matching *-setup.exe under $distDir" - Get-ChildItem -Path $distDir -Recurse -File | ForEach-Object { Write-Host $_.FullName } - exit 1 - } - $newName = "CopyPaste_${version}_x64_Setup.exe" - Move-Item -Path $setup.FullName -Destination (Join-Path $setup.DirectoryName $newName) - Write-Host "Renamed to: $newName" - - - name: Build store MSIX - if: steps.get_version.outputs.IS_PRERELEASE != 'true' - shell: pwsh - run: | - Push-Location app - fastforge package ` - --platform windows ` - --targets msix ` - --build-dart-define "STORE_BUILD=true" ` - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" - Pop-Location - - - name: Move and rename store MSIX - if: steps.get_version.outputs.IS_PRERELEASE != 'true' - shell: pwsh - run: | - $version = "${{ steps.get_version.outputs.VERSION }}" - $distDir = "app/dist" - - # store: true generates .msixupload, not .msix - $extensions = @("*.msixupload", "*.msixbundle", "*.msix") - $searchDirs = @("app/dist", "app/build") - $found = $null - foreach ($ext in $extensions) { - $found = $searchDirs | ForEach-Object { - if (Test-Path $_) { Get-ChildItem -Path $_ -Recurse -Filter $ext } - } | Select-Object -First 1 - if ($found) { break } - } - - if (-not $found) { - Write-Host "::error::No MSIX package found under app/dist or app/build" - @("app/dist", "app/build") | ForEach-Object { - if (Test-Path $_) { Get-ChildItem -Path $_ -Recurse -File | ForEach-Object { Write-Host $_.FullName } } - } - exit 1 - } - - $newName = "CopyPaste_${version}_x64_store$($found.Extension)" - New-Item -ItemType Directory -Path $distDir -Force | Out-Null - Move-Item -Path $found.FullName -Destination (Join-Path $distDir $newName) - Write-Host "Moved $($found.Name) to: $newName" - - - name: Upload artifacts - uses: actions/upload-artifact@v7 - with: - name: release-windows - path: app/dist/ - retention-days: 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 437d4cdc..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,558 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*" - 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: write - -# Releases publish to repositories shared with other projects; never run two at once. -concurrency: - group: release - cancel-in-progress: false - -jobs: - extract-version: - runs-on: ubuntu-latest - name: Extract Version - timeout-minutes: 5 - outputs: - version: ${{ steps.get_version.outputs.VERSION }} - is_prerelease: ${{ steps.get_version.outputs.IS_PRERELEASE }} - is_tag: ${{ steps.get_version.outputs.IS_TAG }} - steps: - - name: Resolve version - id: get_version - env: - DISPATCH_VERSION: ${{ github.event.inputs.version }} - run: | - set -euo pipefail - IS_TAG="false" - if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then - VERSION="$DISPATCH_VERSION" - elif [[ "$GITHUB_REF" =~ refs/tags/v(.+) ]]; then - VERSION="${BASH_REMATCH[1]}" - IS_TAG="true" - else - VERSION="2.0.0" - fi - - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "::error::'$VERSION' is not a valid version (expected MAJOR.MINOR.PATCH[-prerelease])" - exit 1 - fi - - IS_PRERELEASE="false" - if [[ "$VERSION" == *-* ]]; then - IS_PRERELEASE="true" - fi - - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "IS_PRERELEASE=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" - echo "IS_TAG=$IS_TAG" >> "$GITHUB_OUTPUT" - echo "Resolved version: $VERSION (prerelease=$IS_PRERELEASE, tag=$IS_TAG)" - - # Fails now instead of after 45 minutes of builds, or worse, silently unsigned. - - name: Check publishing credentials - if: steps.get_version.outputs.IS_TAG == 'true' - env: - RELEASE_PRIVATE_KEY: ${{ secrets.RELEASE_PRIVATE_KEY }} - GIST_TOKEN: ${{ secrets.GIST_TOKEN }} - STORE_APP_ID: ${{ vars.STORE_APP_ID }} - PFX_BASE64: ${{ secrets.PFX_BASE64 }} - PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} - MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - IS_PRERELEASE: ${{ steps.get_version.outputs.IS_PRERELEASE }} - run: | - set -uo pipefail - status=0 - - require() { - if [[ -z "${!1:-}" ]]; then - echo "::error::$1 is not set; $2" - status=1 - else - echo "ok $1" - fi - } - prefer() { - if [[ -z "${!1:-}" ]]; then - echo "::warning::$1 is not set; $2" - else - echo "ok $1" - fi - } - - require RELEASE_PRIVATE_KEY "release-manifest.json cannot be signed" - require GIST_TOKEN "the Homebrew tap and Scoop bucket cannot be updated" - if [[ "$IS_PRERELEASE" != "true" ]]; then - require STORE_APP_ID "the Microsoft Store submission has no product id" - fi - - prefer PFX_BASE64 "the Windows installer will ship unsigned" - prefer PFX_PASSWORD "the Windows installer will ship unsigned" - prefer MACOS_CERTIFICATE_P12 "the macOS build will be ad-hoc signed and not notarized" - prefer MACOS_CERTIFICATE_PASSWORD "the macOS certificate cannot be imported" - prefer APPLE_ID "the DMG will not be notarized" - prefer APPLE_APP_PASSWORD "the DMG will not be notarized" - prefer APPLE_TEAM_ID "the DMG will not be notarized" - - exit $status - - build-windows: - needs: extract-version - uses: ./.github/workflows/release-win.yml - with: - version: ${{ needs.extract-version.outputs.version }} - secrets: inherit - - build-macos: - needs: extract-version - uses: ./.github/workflows/release-mac.yml - with: - version: ${{ needs.extract-version.outputs.version }} - secrets: inherit - - github-release: - runs-on: ubuntu-latest - needs: [extract-version, build-windows, build-macos] - if: needs.extract-version.outputs.is_tag == 'true' - timeout-minutes: 10 - name: Create GitHub Release - - permissions: - contents: write - id-token: write - attestations: write - - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.ref_name }} - fetch-depth: 0 - - - name: Extract tag message - id: tag_message - run: | - MSG=$(git tag -l --format='%(contents:body)' "${{ github.ref_name }}" | sed '/-----BEGIN SSH SIGNATURE-----/,$d' | sed -e :a -e '/^\n*$/{$d;N;ba}') - echo "TAG_BODY<> $GITHUB_OUTPUT - echo "$MSG" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Download all artifacts - uses: actions/download-artifact@v7 - with: - path: artifacts - - # attest-build-provenance only fails when *every* pattern comes up - # empty, so a single missing artifact would be signed away in silence. - - name: Verify every release artifact is present - env: - IS_PRERELEASE: ${{ needs.extract-version.outputs.is_prerelease }} - run: | - set -euo pipefail - shopt -s globstar nullglob - patterns=( - 'artifacts/release-windows/**/*_Setup.exe' - 'artifacts/release-macos/*.dmg' - ) - if [[ "$IS_PRERELEASE" != "true" ]]; then - patterns+=( 'artifacts/release-windows/**/*_store.msix*' ) - fi - status=0 - for pattern in "${patterns[@]}"; do - matches=( $pattern ) - if (( ${#matches[@]} == 0 )); then - echo "::error::No artifact matched '${pattern}'" - status=1 - else - printf '%s -> %s\n' "$pattern" "${matches[*]}" - fi - done - exit $status - - - uses: actions/attest-build-provenance@v4 - with: - subject-path: | - artifacts/release-windows/**/*_Setup.exe - artifacts/release-windows/**/*_store.msix* - artifacts/release-macos/*.dmg - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - body: ${{ steps.tag_message.outputs.TAG_BODY }} - generate_release_notes: true - prerelease: ${{ needs.extract-version.outputs.is_prerelease == 'true' }} - make_latest: ${{ needs.extract-version.outputs.is_prerelease != 'true' }} - files: | - artifacts/release-windows/**/*_Setup.exe - artifacts/release-windows/**/*_store.msix* - artifacts/release-macos/*.dmg - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-release-manifest: - runs-on: ubuntu-latest - needs: [extract-version, github-release] - if: needs.extract-version.outputs.is_tag == 'true' - timeout-minutes: 5 - name: Sign and publish release-manifest.json - - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.ref_name }} - fetch-depth: 0 - - - name: Setup Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Resolve dependencies - working-directory: app - run: flutter pub get - - - name: Inject Microsoft Store productId - env: - STORE_APP_ID: ${{ vars.STORE_APP_ID }} - run: | - if [ -z "$STORE_APP_ID" ]; then - echo "vars.STORE_APP_ID is not set" >&2 - exit 1 - fi - sed -i "s|ms-windows-store://pdp/?productid=PLACEHOLDER|ms-windows-store://pdp/?productid=${STORE_APP_ID}|" release-manifest.json - grep -q "productid=${STORE_APP_ID}" release-manifest.json - - - name: Override latest, URLs and standard release notes - run: | - TAG="${GITHUB_REF_NAME}" - VERSION="${TAG#v}" - REPO="${{ github.repository }}" - RELEASE_URL="https://github.com/${REPO}/releases/tag/${TAG}" - - TAG_BODY=$(git tag -l --format='%(contents)' "$TAG" | sed '/-----BEGIN SSH SIGNATURE-----/,$d') - read_trailer() { - printf '%s\n' "$TAG_BODY" \ - | grep -iE "^$1:" \ - | head -n 1 \ - | sed -E "s/^[^:]+:[[:space:]]*//" \ - | tr -d '\r' - } - - SEVERITY_OVERRIDE=$(read_trailer 'Severity') - MIN_SUPPORTED_OVERRIDE=$(read_trailer 'Min-Supported') - BLOCKED_OVERRIDE=$(read_trailer 'Blocked') - - SEVERITY_CURRENT=$(jq -r '.severity // "recommended"' release-manifest.json) - SEVERITY="${SEVERITY_OVERRIDE:-recommended}" - case "$SEVERITY" in - critical|recommended|patch) ;; - *) - echo "::error::Invalid Severity trailer '$SEVERITY' (expected: critical|recommended|patch)" - exit 1 - ;; - esac - echo "Severity: $SEVERITY (override=${SEVERITY_OVERRIDE:-}, previous=${SEVERITY_CURRENT})" - - if [[ "$SEVERITY" == "critical" && -z "$MIN_SUPPORTED_OVERRIDE" ]]; then - echo "::error::A critical release must carry an explicit Min-Supported trailer; the default ($VERSION) locks out every Linux install (<= 2.11.0)." - exit 1 - fi - - MIN_SUPPORTED="${MIN_SUPPORTED_OVERRIDE:-$VERSION}" - echo "minimumSupported: $MIN_SUPPORTED (override=${MIN_SUPPORTED_OVERRIDE:-})" - - if [[ -n "$BLOCKED_OVERRIDE" ]]; then - BLOCKED_JSON=$(printf '%s' "$BLOCKED_OVERRIDE" \ - | tr ',' '\n' \ - | sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \ - | grep -v '^$' \ - | jq -R . | jq -s .) - else - BLOCKED_JSON='[]' - fi - echo "blockedVersions: $BLOCKED_JSON (override=${BLOCKED_OVERRIDE:-})" - - SUMMARY_EN="CopyPaste ${TAG} (${SEVERITY} update). See release notes for details." - SUMMARY_ES="CopyPaste ${TAG} (actualización ${SEVERITY}). Consulta las notas de la versión." - - jq \ - --arg version "$VERSION" \ - --arg url "$RELEASE_URL" \ - --arg severity "$SEVERITY" \ - --arg min_supported "$MIN_SUPPORTED" \ - --argjson blocked "$BLOCKED_JSON" \ - --arg sum_en "$SUMMARY_EN" \ - --arg sum_es "$SUMMARY_ES" \ - '.latest = $version - | .severity = $severity - | .minimumSupported = $min_supported - | .blockedVersions = $blocked - | .releaseNotes.en.summary = $sum_en - | .releaseNotes.en.url = $url - | .releaseNotes.es.summary = $sum_es - | .releaseNotes.es.url = $url' \ - release-manifest.json > release-manifest.tmp.json - mv release-manifest.tmp.json release-manifest.json - - echo "--- Final manifest to publish ---" - cat release-manifest.json - - - name: Sign manifest - working-directory: app - env: - RELEASE_PRIVATE_KEY: ${{ secrets.RELEASE_PRIVATE_KEY }} - run: | - if [ -z "$RELEASE_PRIVATE_KEY" ]; then - echo "RELEASE_PRIVATE_KEY secret is not set" >&2 - exit 1 - fi - printf '%s' "$RELEASE_PRIVATE_KEY" | dart run tools/sign_manifest.dart \ - ../release-manifest.json \ - ../release-manifest.json.sig - - - name: Upload manifest assets to release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release upload "${{ github.ref_name }}" \ - release-manifest.json \ - release-manifest.json.sig \ - --clobber - - publish-to-store: - runs-on: windows-latest - needs: [extract-version, github-release] - if: needs.extract-version.outputs.is_tag == 'true' && needs.extract-version.outputs.is_prerelease != 'true' - timeout-minutes: 15 - name: Publish to Microsoft Store - - steps: - - name: Install Microsoft Store Developer CLI - uses: microsoft/microsoft-store-apppublisher@v1.3 - with: - version: v0.3.9 - - - name: Download Windows artifacts - uses: actions/download-artifact@v7 - with: - name: release-windows - path: artifacts/windows - - - name: Find MSIX package - id: find_msix - shell: bash - run: | - MSIX=$(find artifacts/windows -name "*.msixupload" | head -1) - [ -z "$MSIX" ] && MSIX=$(find artifacts/windows -name "*.msixbundle" | head -1) - [ -z "$MSIX" ] && MSIX=$(find artifacts/windows -name "*.msix" | head -1) - if [ -z "$MSIX" ]; then - echo "Error: No MSIX package found in release-windows artifact" - find artifacts/windows -type f - exit 1 - fi - echo "MSIX_PATH=$MSIX" >> $GITHUB_OUTPUT - echo "Found: $MSIX" - - - name: Configure Microsoft Store CLI - shell: bash - run: | - msstore reconfigure \ - --tenantId "${{ secrets.STORE_TENANT_ID }}" \ - --sellerId "${{ secrets.STORE_SELLER_ID }}" \ - --clientId "${{ secrets.STORE_CLIENT_ID }}" \ - --clientSecret "${{ secrets.STORE_CLIENT_SECRET }}" - - - name: Publish to Microsoft Store - shell: bash - run: | - msstore publish "${{ steps.find_msix.outputs.MSIX_PATH }}" \ - --appId "${{ vars.STORE_APP_ID }}" - - update-homebrew-cask: - runs-on: ubuntu-latest - needs: [extract-version, github-release] - if: needs.extract-version.outputs.is_tag == 'true' - timeout-minutes: 5 - name: Update Homebrew Tap - - steps: - - name: Update Homebrew Tap - env: - GH_TOKEN: ${{ secrets.GIST_TOKEN }} - run: | - set -euo pipefail - - TAG="${GITHUB_REF_NAME}" - VERSION="${TAG#v}" - - DMG_NAME="CopyPaste_${VERSION}_universal.dmg" - DMG_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${DMG_NAME}" - - echo "Downloading DMG to compute SHA256..." - curl -fSL --retry 5 --retry-delay 10 --retry-all-errors \ - -o "/tmp/${DMG_NAME}" "${DMG_URL}" - DMG_SHA256=$(sha256sum "/tmp/${DMG_NAME}" | awk '{print $1}') - rm -f "/tmp/${DMG_NAME}" - - echo "Version: ${VERSION}" - echo "DMG SHA256: ${DMG_SHA256}" - - if [[ "$VERSION" == *-* ]]; then - CASK_FILE="Casks/copypaste-beta.rb" - CASK_NAME="copypaste-beta" - CASK_DESC="Clipboard history manager for macOS (beta)" - else - CASK_FILE="Casks/copypaste.rb" - CASK_NAME="copypaste" - CASK_DESC="Clipboard history manager for macOS" - fi - - git clone "https://x-access-token:${GH_TOKEN}@github.com/rgdevment/homebrew-tap.git" /tmp/homebrew-tap - cd /tmp/homebrew-tap - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - mkdir -p Casks - - cat > "${CASK_FILE}" <<- CASK_EOF - cask "${CASK_NAME}" do - version "${VERSION}" - sha256 "${DMG_SHA256}" - - url "${DMG_URL}" - name "CopyPaste" - desc "${CASK_DESC}" - homepage "https://github.com/${{ github.repository }}" - - depends_on macos: :ventura - - app "CopyPaste.app" - - zap trash: [ - "~/Library/Application Support/com.rgdevment.copypaste", - ] - end - CASK_EOF - - git add "${CASK_FILE}" - if git diff --cached --quiet; then - echo "Homebrew Tap already at ${VERSION}, nothing to push" - exit 0 - fi - git commit -m "Update ${CASK_NAME} to ${VERSION}" - - # Shared tap: a concurrent release can land between fetch and push. - for attempt in 1 2 3 4 5; do - if (( attempt > 1 )); then - sleep $(( (attempt - 1) * 5 )) - git fetch origin main - git rebase origin/main - fi - if git push origin HEAD:main; then - echo "Homebrew Tap updated: cask ${CASK_NAME} → ${VERSION}" - exit 0 - fi - echo "Push rejected (attempt ${attempt})" - done - echo "::error::Could not push ${VERSION} to the Homebrew Tap" - exit 1 - - update-scoop-bucket: - runs-on: ubuntu-latest - needs: [extract-version, github-release] - if: needs.extract-version.outputs.is_tag == 'true' - timeout-minutes: 5 - name: Update Scoop Bucket - - steps: - - name: Update Scoop Bucket - env: - GH_TOKEN: ${{ secrets.GIST_TOKEN }} - run: | - set -euo pipefail - - TAG="${GITHUB_REF_NAME}" - VERSION="${TAG#v}" - BASE="https://github.com/${{ github.repository }}/releases/download/${TAG}" - SETUP="CopyPaste_${VERSION}_x64_Setup.exe" - - if [[ "$VERSION" == *-* ]]; then - NAME="copypaste-beta"; SUFFIX=" (beta)" - else - NAME="copypaste"; SUFFIX="" - fi - - curl -fSL --retry 5 --retry-delay 10 --retry-all-errors \ - -o "/tmp/${SETUP}" "${BASE}/${SETUP}" - SHA=$(sha256sum "/tmp/${SETUP}" | awk '{print $1}') - - # Scoop unpacks the installer without ever running its - # uninstaller, so the Run value the app writes would outlive it. - RUN_KEY='HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' - UNINSTALL="Remove-ItemProperty -Path '${RUN_KEY}' -Name 'CopyPaste' -ErrorAction SilentlyContinue" - - git clone "https://x-access-token:${GH_TOKEN}@github.com/rgdevment/scoop-bucket.git" /tmp/bucket - cd /tmp/bucket - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - mkdir -p bucket - - jq -n \ - --arg version "$VERSION" \ - --arg desc "Clipboard history manager${SUFFIX}" \ - --arg home "https://github.com/${{ github.repository }}" \ - --arg url "${BASE}/${SETUP}" \ - --arg hash "$SHA" \ - --arg shortcut "CopyPaste${SUFFIX}" \ - --arg uninstall "$UNINSTALL" \ - '{ - version: $version, - description: $desc, - homepage: $home, - license: "GPL-3.0-only", - architecture: {"64bit": {url: $url, hash: $hash}}, - innosetup: true, - shortcuts: [["CopyPaste.exe", $shortcut]], - pre_uninstall: [$uninstall] - }' > "bucket/${NAME}.json" - - git add "bucket/${NAME}.json" - if git diff --cached --quiet; then - echo "Scoop bucket already at ${VERSION}, nothing to push" - exit 0 - fi - git commit -m "${NAME} ${VERSION}" - - # Three repos share this bucket: a concurrent release can land - # between fetch and push. - for attempt in 1 2 3 4 5; do - if (( attempt > 1 )); then - sleep $(( (attempt - 1) * 5 )) - git fetch origin main - git rebase origin/main - fi - if git push origin HEAD:main; then - echo "Scoop bucket updated: ${NAME} → ${VERSION}" - exit 0 - fi - echo "Push rejected (attempt ${attempt})" - done - echo "::error::Could not push ${NAME} ${VERSION} to the Scoop bucket" - exit 1 diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml new file mode 100644 index 00000000..3200c1c2 --- /dev/null +++ b/.github/workflows/rules.yml @@ -0,0 +1,72 @@ +name: Rules + +on: + push: + branches: [main] + pull_request: + +jobs: + conventions: + name: Project conventions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Unsafe lives only in the -sys crates + run: | + expected="crates/cp-mac-sys crates/cp-win-sys" + found=$(grep -rl 'unsafe_code = "allow"' crates/*/Cargo.toml | xargs -n1 dirname | sort | tr '\n' ' ') + for dir in $found; do + case " $expected " in + *" $dir "*) ;; + *) echo "unsafe declarado fuera de los crates -sys: $dir"; exit 1 ;; + esac + done + + - name: The core produces no terminal output + run: | + if grep -rn 'println!\|eprintln!\|print!' crates/cp-core/src --include='*.rs'; then + echo "cp-core no imprime: usa tracing"; exit 1 + fi + + - name: The core touches neither platform nor interface + run: | + if grep -rn 'use tauri\|use windows\|use objc2' crates/cp-core/src --include='*.rs'; then + echo "cp-core no depende de plataforma ni de interfaz"; exit 1 + fi + + - name: No Spanish identifiers + run: | + if grep -rnE '\b(fecha|limite|prioridad|filtro|tarea|titulo|etiqueta|imagen|archivo) *:' \ + crates/*/src --include='*.rs' | grep -v '"'; then + echo "los identificadores van en ingles"; exit 1 + fi + + - name: No voseo in the Spanish anywhere + run: | + if grep -rniE '\b(vos|tenes|queres|podes|anda|mira|hace|che)\b' \ + crates docs README.md --include='*.rs' --include='*.md' 2>/dev/null; then + echo "espanol neutro, sin voseo"; exit 1 + fi + + - name: No peninsular Spanish in what a person reads + run: | + if grep -rniE '\b(fichero|ficheros|ordenador|pulsa|pulsar|pulsando)\b' \ + crates docs README.md --include='*.rs' --include='*.md' 2>/dev/null; then + echo "espanol neutro: archivo, computador, presiona"; exit 1 + fi + + - name: No prose blocks in the code + run: | + if awk '/^\s*\/\/[^\/]/{n++; if (n>3) {print FILENAME": bloque de prosa"; exit 1}} !/^\s*\/\//{n=0}' \ + $(find crates -name '*.rs'); then :; else exit 1; fi + + deterministic: + name: Tests are deterministic + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Twenty consecutive runs of the core + run: for i in $(seq 1 20); do cargo test -p cp-core --quiet || exit 1; done diff --git a/.gitignore b/.gitignore index 8ff75b7f..ecbd5bb6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,79 +1,3 @@ -# ── 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 +/target +**/*.rs.bk +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..37cecba9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,457 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cp-core" +version = "3.0.0" +dependencies = [ + "thiserror", + "unicode-normalization", +] + +[[package]] +name = "cp-mac" +version = "3.0.0" +dependencies = [ + "cp-core", + "cp-mac-sys", + "thiserror", +] + +[[package]] +name = "cp-mac-sys" +version = "3.0.0" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "cp-store" +version = "3.0.0" +dependencies = [ + "blake3", + "cp-core", + "rusqlite", + "thiserror", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..1be0c90c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[workspace] +resolver = "3" +members = ["crates/*"] + +[workspace.package] +version = "3.0.0" +edition = "2024" +rust-version = "1.98" +license = "GPL-3.0-only" +repository = "https://github.com/rgdevment/CopyPaste" +publish = false + +[workspace.dependencies] +cp-core = { path = "crates/cp-core" } +cp-store = { path = "crates/cp-store" } +cp-mac-sys = { path = "crates/cp-mac-sys" } +cp-mac = { path = "crates/cp-mac" } + +thiserror = "2" +tracing = "0.1" +crossbeam-channel = "0.5" +parking_lot = "0.12" +rusqlite = { version = "0.37", features = ["bundled"] } +blake3 = "1" +xxhash-rust = { version = "0.8", features = ["xxh3"] } +unicode-normalization = "0.1" +image = { version = "0.25", default-features = false, features = ["png"] } + +objc2 = "0.6" +objc2-foundation = "0.3" +objc2-app-kit = "0.3" +objc2-core-graphics = "0.3" +objc2-application-services = "0.3" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/README.md b/README.md index d2c5949d..0598469d 100644 --- a/README.md +++ b/README.md @@ -1,248 +1,251 @@ -
- CopyPaste — Free Open Source Clipboard Manager for Windows and macOS - -

CopyPaste — Free Open Source Clipboard Manager

-

A local-first clipboard history and copy paste tool for Windows and macOS.
No ads. No telemetry. No accounts. Just a fast, private clipboard utility built for productivity.

- -

- - Build Status - - - Quality Gate - - - Coverage - - - Latest Release - - Platform: Windows, macOS - - License GPL-3.0 - -

- -

Download CopyPaste

- -

- - Get CopyPaste clipboard manager from Microsoft Store - -   - - Install CopyPaste clipboard manager via Homebrew on macOS - -

- -

- Prefer a direct download? GitHub Releases has standalone installers — Windows (.exe) · macOS (.dmg) -

- -

- - Buy Me a Coffee - -

-
- ---- - -**CopyPaste** is a free, open source **clipboard manager** and **clipboard history** tool I built because the alternatives frustrated me. Most copy paste utilities are either bloated, ugly, or treat you as a product. I wanted a **copy tool** that felt native, respected my privacy, and just worked — so I built one and shared it. - -This isn't a company product. I'm a developer who needed a better **copy paste** tool for my desktop, built it for myself, and decided to open source it for anyone who feels the same. No ads, no telemetry, no subscriptions, no data collection — just a lightweight **clipboard utility** that lives on your machine and nowhere else. - -**Why people choose CopyPaste over other clipboard managers:** - -- **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. - Only redistributing it inside a product of your own needs [separate terms](COMMERCIAL.md). -- **Cross-platform** — same native copy-paste experience on Windows and macOS. -- **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 support has been discontinued.** See [Linux support (discontinued)](#linux-support-discontinued). - ---- - -## Table of Contents - -- [See It in Action](#see-copypaste-in-action) -- [Why I Built This](#why-i-built-this) -- [What It Is / What It Isn't](#what-it-is--what-it-isnt) -- [Who Is This For?](#who-is-this-for) -- [Privacy and Security](#privacy-and-security) -- [Key Features](#key-features) -- [Keyboard Shortcuts](#keyboard-shortcuts) -- [Getting Started](#getting-started) -- [FAQ](#faq) -- [Support and Bug Reporting](#support-and-bug-reporting) -- [Clean Install and Reset](#clean-install-and-reset) -- [Found a Bug? Have Feedback?](#found-a-bug-have-feedback) -- [Localization](#localization-help-translate-copypaste) -- [Want to Help?](#want-to-help) -- [Tech Stack](#tech-stack-for-developers) -- [Other Tools by the Same Author](#other-tools-by-the-same-author) -- [License and Spirit](#license-and-spirit) - -## See CopyPaste in Action - -
- CopyPaste clipboard manager demo — search clipboard history, paste with keyboard shortcuts, cross-platform on Windows and macOS -
-
Fast search, clean cards, and a native feel across Windows and macOS.
- -
- -
- CopyPaste clipboard history — main panel showing copied text, images, files and links with previews - CopyPaste copy tool — category filters and color labels for organizing clipboard items -

- CopyPaste settings — configure clipboard manager privacy, shortcuts and appearance - CopyPaste multiplatform clipboard manager — running natively on Windows and macOS side by side -
- ---- - -## Why I Built This - -I'm not a company. I'm a developer who copies and pastes things hundreds of times a day — and got frustrated. - -Most **clipboard managers** out there are either bloated, ugly, Windows-only, or silently collecting your data. In 2026, a **copy paste tool** should feel native, responsive, and beautiful on every platform. I couldn't find one that did, so I built my own. - -**CopyPaste started as a personal productivity tool.** I needed a lightweight **copy history** utility that: - -- Didn't hog system resources -- Looked and felt like part of my OS, not a widget dropped on top -- Worked on both Windows and macOS -- Didn't require an account, subscription, or internet connection -- Actually respected my privacy — not just claimed to - -After months of using it myself, I realized others might need it too. So I open sourced it — no ads, no tracking, no strings attached for the people who use it. - -Every line of code is public. You can read it, fork it, or learn from it. This is a **free, open source productivity tool** — a copy tool built from a real need. Redistributing it inside a product of your own is the one case that needs [separate terms](COMMERCIAL.md). - ---- - -## What It Is / What It Isn't - -**CopyPaste is:** - -- A **local-first clipboard manager** and **clipboard history** app for Windows and macOS -- A fast, keyboard-driven **copy-paste utility** for daily productivity and workflow efficiency -- A **copy tool** you can trust — **open source** (GPL v3), inspect every line, fork it, contribute to it - -**CopyPaste is not:** - -- A cloud clipboard or sync service -- A telemetry or analytics tool -- A "platform" with accounts, subscriptions, or ads -- A corporate product — it's a personal project shared with the community - ---- - -## Who Is This For? - -If you copy and paste throughout your day, this **clipboard manager** is for you: - -- **Developers** juggling code snippets, terminal commands, and log outputs — a real productivity boost -- **Students** collecting notes, quotes, and research sources into a searchable **copy history** -- **Writers and creators** reusing text fragments and assets across documents -- **Support and operations** teams handling repetitive copy-paste responses -- **Anyone** who wants a clean, private, free **clipboard history** tool on their computer - ---- - -## Privacy and Security - -**Everything stays local.** CopyPaste is built on a single, non-negotiable principle: your clipboard data never leaves your computer. This copy-paste tool was designed with privacy as the foundation, not an afterthought. - -- **Local-only storage** — no cloud, no servers, no data syncing -- **No tracking** — no telemetry, no analytics, no hidden collection of any kind -- **No automatic reporting** — errors are logged locally; nothing is sent without your explicit action -- **Sensitive content is ignored** — passwords and password-manager copies (1Password, Bitwarden, etc.) aren't saved -- **Log export is voluntary** — you choose when and what to share; logs never contain clipboard content - -**By design, CopyPaste will never have:** accounts, subscriptions, ads, cloud sync, or "AI analysis" of your clipboard. - -For responsible disclosure and security contact info, see [SECURITY.md](SECURITY.md). - -
-Where is my clipboard data stored? - -CopyPaste stores all data locally under your user profile: - -**Windows:** - -- **Database:** `%LOCALAPPDATA%\CopyPaste\clipboard.db` -- **Images:** `%LOCALAPPDATA%\CopyPaste\images` -- **Config:** `%LOCALAPPDATA%\CopyPaste\config` - -**macOS:** - -- **Database:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/clipboard.db` -- **Images:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/images` -- **Config:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/config` - -
- -If you care about privacy and control, this clipboard manager is made for you. Read the full [Privacy Policy](PRIVACY.md) for complete details. - -## Key Features - -**Latest Release** — See all features and improvements in the [Release Notes](https://github.com/rgdevment/CopyPaste/releases/latest). - -### Privacy and Security - -- **Private by Default:** All clipboard history stays on your computer. No cloud, no sync, no servers. -- **Respects Sensitive Data:** Passwords and API keys aren't stored. Password managers (1Password, Bitwarden, etc.) are ignored — their clipboard content never gets saved. - -### Design and Experience - -- **Adapts to Your System:** Follows your OS light or dark theme automatically — Mica on Windows, Sidebar material on macOS. -- **Fast and Lightweight:** Starts quickly and doesn't hog resources. Lightweight enough to forget it's running. -- **Multiplatform:** The same native look, feel, and functionality across Windows and macOS. - -### Smart Clipboard Management - -- **Handles Everything:** Text, images, files, folders, links, audio, and video — with content-aware previews. A copy tool that actually understands what you copy. -- **Smart Content Detection:** Automatically recognizes and categorizes content — emails, phone numbers (with country), colors (HEX/RGB/HSL with swatch), IP addresses, UUIDs, and JSON. Each type gets its own icon, badge, and filter. -- **Open with Default App:** Files, images, links, emails, and phone numbers open directly in your OS's default app — the copy-paste manager stays out of the way. -- **Drag to Other Apps (Windows):** Drag any image, file, folder, audio or video card straight into another app — a browser upload zone, a chat, an editor. Dragged files keep their real, unique name, so web uploaders no longer reject a second image as a duplicate `image.png`. macOS support is on the way. -- **Formatting Is Never Lost:** Copying text that is already in the history again, this time without styles, no longer discards the formatting stored for it. Rich text contains the plain text, not the other way around: _Paste as plain text_ already serves the unstyled version at paste time, without touching what is saved. Stored styles are replaced only when a new copy brings its own. - -### Workflow and Productivity - -- **Full Keyboard Navigation:** Navigate, search, and paste your copy history using only your keyboard — a clipboard utility built for speed. -- **Smart Search:** Diacritic-insensitive full-text search (handles é, ñ, ø, ß, æ and more) across content and labels. -- **Card Labels and Colors:** Personalize your copy-paste items with custom labels (up to 50 characters) and 7 color options to identify your snippets at a glance. -- **Advanced Filters:** Three filter modes — Content (text search), Category (color selection), and Type (item type) — with dropdown multi-selection. -- **Pin Important Items:** Keep your most-used copy-paste fragments always accessible at the top. -- **Backup and Restore:** Export and import your clipboard history, images, and settings as `.cpbackup` files. -- **Start with Windows:** Optionally launch at login — works natively on both the Microsoft Store (MSIX) and standalone installer versions, no admin rights required. -- **Guided Onboarding (Windows):** First-launch walkthrough on Windows — pick your preferences for thumbnails, broken-item retention and image quota before you start using the app. macOS opens straight to the main panel. -- **Live Settings (autosave):** The Settings panel is organized in 6 tabs (General · Shortcuts · Performance · Cleanup & Privacy · Backup & Support · About) and saves automatically as you tweak — no Save / Cancel buttons. - -### Storage Control - -- **Image Quota (MB):** Cap how much disk space copied images can use. When the cap is reached, oldest non-pinned images are evicted (LRU). Pinned items and external file references are never touched. Set to `0` (default) for unlimited. -- **Broken-Item Retention:** When a copied file or image disappears from disk (moved, deleted, external drive disconnected) the entry is kept for `keepBrokenItemsDays` (default 30) before being purged — so reconnecting an external drive restores the previews instead of losing them. -- **Native Thumbnails:** Image, video and audio previews are generated through the OS shell (QuickLook on macOS, `IShellItemImageFactory` on Windows). - ---- - -## Keyboard Shortcuts - +
+ CopyPaste — Free Open Source Clipboard Manager for Windows and macOS + +

CopyPaste — Free Open Source Clipboard Manager

+

A local-first clipboard history and copy paste tool for Windows and macOS.
No ads. No telemetry. No accounts. Just a fast, private clipboard utility built for productivity.

+ +

+ + Build Status + + + Quality Gate + + + Coverage + + + Latest Release + + Platform: Windows, macOS + + License GPL-3.0 + +

+ +

CopyPaste 3.0 is on the way — a rewrite of the core in Rust, starting with macOS.
+ What you see below is 2.x, which stays supported on the v2-stable branch.

+ +

Download CopyPaste

+ +

+ + Get CopyPaste clipboard manager from Microsoft Store + +   + + Install CopyPaste clipboard manager via Homebrew on macOS + +

+ +

+ Prefer a direct download? GitHub Releases has standalone installers — Windows (.exe) · macOS (.dmg) +

+ +

+ + Buy Me a Coffee + +

+
+ +--- + +**CopyPaste** is a free, open source **clipboard manager** and **clipboard history** tool I built because the alternatives frustrated me. Most copy paste utilities are either bloated, ugly, or treat you as a product. I wanted a **copy tool** that felt native, respected my privacy, and just worked — so I built one and shared it. + +This isn't a company product. I'm a developer who needed a better **copy paste** tool for my desktop, built it for myself, and decided to open source it for anyone who feels the same. No ads, no telemetry, no subscriptions, no data collection — just a lightweight **clipboard utility** that lives on your machine and nowhere else. + +**Why people choose CopyPaste over other clipboard managers:** + +- **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. + Only redistributing it inside a product of your own needs [separate terms](COMMERCIAL.md). +- **Cross-platform** — same native copy-paste experience on Windows and macOS. +- **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 support has been discontinued.** See [Linux support (discontinued)](#linux-support-discontinued). + +--- + +## Table of Contents + +- [See It in Action](#see-copypaste-in-action) +- [Why I Built This](#why-i-built-this) +- [What It Is / What It Isn't](#what-it-is--what-it-isnt) +- [Who Is This For?](#who-is-this-for) +- [Privacy and Security](#privacy-and-security) +- [Key Features](#key-features) +- [Keyboard Shortcuts](#keyboard-shortcuts) +- [Getting Started](#getting-started) +- [FAQ](#faq) +- [Support and Bug Reporting](#support-and-bug-reporting) +- [Clean Install and Reset](#clean-install-and-reset) +- [Found a Bug? Have Feedback?](#found-a-bug-have-feedback) +- [Localization](#localization-help-translate-copypaste) +- [Want to Help?](#want-to-help) +- [Tech Stack](#tech-stack-for-developers) +- [Other Tools by the Same Author](#other-tools-by-the-same-author) +- [License and Spirit](#license-and-spirit) + +## See CopyPaste in Action + +
+ CopyPaste clipboard manager demo — search clipboard history, paste with keyboard shortcuts, cross-platform on Windows and macOS +
+
Fast search, clean cards, and a native feel across Windows and macOS.
+ +
+ +
+ CopyPaste clipboard history — main panel showing copied text, images, files and links with previews + CopyPaste copy tool — category filters and color labels for organizing clipboard items +

+ CopyPaste settings — configure clipboard manager privacy, shortcuts and appearance + CopyPaste multiplatform clipboard manager — running natively on Windows and macOS side by side +
+ +--- + +## Why I Built This + +I'm not a company. I'm a developer who copies and pastes things hundreds of times a day — and got frustrated. + +Most **clipboard managers** out there are either bloated, ugly, Windows-only, or silently collecting your data. In 2026, a **copy paste tool** should feel native, responsive, and beautiful on every platform. I couldn't find one that did, so I built my own. + +**CopyPaste started as a personal productivity tool.** I needed a lightweight **copy history** utility that: + +- Didn't hog system resources +- Looked and felt like part of my OS, not a widget dropped on top +- Worked on both Windows and macOS +- Didn't require an account, subscription, or internet connection +- Actually respected my privacy — not just claimed to + +After months of using it myself, I realized others might need it too. So I open sourced it — no ads, no tracking, no strings attached for the people who use it. + +Every line of code is public. You can read it, fork it, or learn from it. This is a **free, open source productivity tool** — a copy tool built from a real need. Redistributing it inside a product of your own is the one case that needs [separate terms](COMMERCIAL.md). + +--- + +## What It Is / What It Isn't + +**CopyPaste is:** + +- A **local-first clipboard manager** and **clipboard history** app for Windows and macOS +- A fast, keyboard-driven **copy-paste utility** for daily productivity and workflow efficiency +- A **copy tool** you can trust — **open source** (GPL v3), inspect every line, fork it, contribute to it + +**CopyPaste is not:** + +- A cloud clipboard or sync service +- A telemetry or analytics tool +- A "platform" with accounts, subscriptions, or ads +- A corporate product — it's a personal project shared with the community + +--- + +## Who Is This For? + +If you copy and paste throughout your day, this **clipboard manager** is for you: + +- **Developers** juggling code snippets, terminal commands, and log outputs — a real productivity boost +- **Students** collecting notes, quotes, and research sources into a searchable **copy history** +- **Writers and creators** reusing text fragments and assets across documents +- **Support and operations** teams handling repetitive copy-paste responses +- **Anyone** who wants a clean, private, free **clipboard history** tool on their computer + +--- + +## Privacy and Security + +**Everything stays local.** CopyPaste is built on a single, non-negotiable principle: your clipboard data never leaves your computer. This copy-paste tool was designed with privacy as the foundation, not an afterthought. + +- **Local-only storage** — no cloud, no servers, no data syncing +- **No tracking** — no telemetry, no analytics, no hidden collection of any kind +- **No automatic reporting** — errors are logged locally; nothing is sent without your explicit action +- **Sensitive content is ignored** — passwords and password-manager copies (1Password, Bitwarden, etc.) aren't saved +- **Log export is voluntary** — you choose when and what to share; logs never contain clipboard content + +**By design, CopyPaste will never have:** accounts, subscriptions, ads, cloud sync, or "AI analysis" of your clipboard. + +For responsible disclosure and security contact info, see [SECURITY.md](SECURITY.md). + +
+Where is my clipboard data stored? + +CopyPaste stores all data locally under your user profile: + +**Windows:** + +- **Database:** `%LOCALAPPDATA%\CopyPaste\clipboard.db` +- **Images:** `%LOCALAPPDATA%\CopyPaste\images` +- **Config:** `%LOCALAPPDATA%\CopyPaste\config` + +**macOS:** + +- **Database:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/clipboard.db` +- **Images:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/images` +- **Config:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/config` + +
+ +If you care about privacy and control, this clipboard manager is made for you. Read the full [Privacy Policy](PRIVACY.md) for complete details. + +## Key Features + +**Latest Release** — See all features and improvements in the [Release Notes](https://github.com/rgdevment/CopyPaste/releases/latest). + +### Privacy and Security + +- **Private by Default:** All clipboard history stays on your computer. No cloud, no sync, no servers. +- **Respects Sensitive Data:** Passwords and API keys aren't stored. Password managers (1Password, Bitwarden, etc.) are ignored — their clipboard content never gets saved. + +### Design and Experience + +- **Adapts to Your System:** Follows your OS light or dark theme automatically — Mica on Windows, Sidebar material on macOS. +- **Fast and Lightweight:** Starts quickly and doesn't hog resources. Lightweight enough to forget it's running. +- **Multiplatform:** The same native look, feel, and functionality across Windows and macOS. + +### Smart Clipboard Management + +- **Handles Everything:** Text, images, files, folders, links, audio, and video — with content-aware previews. A copy tool that actually understands what you copy. +- **Smart Content Detection:** Automatically recognizes and categorizes content — emails, phone numbers (with country), colors (HEX/RGB/HSL with swatch), IP addresses, UUIDs, and JSON. Each type gets its own icon, badge, and filter. +- **Open with Default App:** Files, images, links, emails, and phone numbers open directly in your OS's default app — the copy-paste manager stays out of the way. +- **Drag to Other Apps (Windows):** Drag any image, file, folder, audio or video card straight into another app — a browser upload zone, a chat, an editor. Dragged files keep their real, unique name, so web uploaders no longer reject a second image as a duplicate `image.png`. macOS support is on the way. +- **Formatting Is Never Lost:** Copying text that is already in the history again, this time without styles, no longer discards the formatting stored for it. Rich text contains the plain text, not the other way around: _Paste as plain text_ already serves the unstyled version at paste time, without touching what is saved. Stored styles are replaced only when a new copy brings its own. + +### Workflow and Productivity + +- **Full Keyboard Navigation:** Navigate, search, and paste your copy history using only your keyboard — a clipboard utility built for speed. +- **Smart Search:** Diacritic-insensitive full-text search (handles é, ñ, ø, ß, æ and more) across content and labels. +- **Card Labels and Colors:** Personalize your copy-paste items with custom labels (up to 50 characters) and 7 color options to identify your snippets at a glance. +- **Advanced Filters:** Three filter modes — Content (text search), Category (color selection), and Type (item type) — with dropdown multi-selection. +- **Pin Important Items:** Keep your most-used copy-paste fragments always accessible at the top. +- **Backup and Restore:** Export and import your clipboard history, images, and settings as `.cpbackup` files. +- **Start with Windows:** Optionally launch at login — works natively on both the Microsoft Store (MSIX) and standalone installer versions, no admin rights required. +- **Guided Onboarding (Windows):** First-launch walkthrough on Windows — pick your preferences for thumbnails, broken-item retention and image quota before you start using the app. macOS opens straight to the main panel. +- **Live Settings (autosave):** The Settings panel is organized in 6 tabs (General · Shortcuts · Performance · Cleanup & Privacy · Backup & Support · About) and saves automatically as you tweak — no Save / Cancel buttons. + +### Storage Control + +- **Image Quota (MB):** Cap how much disk space copied images can use. When the cap is reached, oldest non-pinned images are evicted (LRU). Pinned items and external file references are never touched. Set to `0` (default) for unlimited. +- **Broken-Item Retention:** When a copied file or image disappears from disk (moved, deleted, external drive disconnected) the entry is kept for `keepBrokenItemsDays` (default 30) before being purged — so reconnecting an external drive restores the previews instead of losing them. +- **Native Thumbnails:** Image, video and audio previews are generated through the OS shell (QuickLook on macOS, `IShellItemImageFactory` on Windows). + +--- + +## Keyboard Shortcuts + CopyPaste keeps `Ctrl+V` under the active application's control and uses dedicated shortcuts for its global actions and history panel. | Scope | Shortcut | Action | | :---- | :------- | :----- | -| Active application | Ctrl+V (Windows) / Cmd+V (macOS) | Paste the current system clipboard normally. CopyPaste does not intercept it. | -| CopyPaste global | Ctrl+Alt+C (Windows) / Control+Shift+V (macOS) | Open/close CopyPaste (customizable). | -| CopyPaste global, optional | Ctrl+Alt+V (Windows; configurable on macOS) | Paste the current system clipboard as plain text without opening the panel. | +| Active application | Ctrl+V (Windows) / Cmd+V (macOS) | Paste the current system clipboard normally. CopyPaste does not intercept it. | +| CopyPaste global | Ctrl+Alt+C (Windows) / Control+Shift+V (macOS) | Open/close CopyPaste (customizable). | +| CopyPaste global, optional | Ctrl+Alt+V (Windows; configurable on macOS) | Paste the current system clipboard as plain text without opening the panel. | | CopyPaste panel open | Enter | Paste the hovered item, keyboard selection, or first visible history item normally, in that order. | | CopyPaste panel open | Shift+Enter | Paste the hovered item, keyboard selection, or first visible history item as plain text (text/link only), in that order. | | CopyPaste panel open | ↓ or Tab | Navigate from search to clipboard items. | @@ -257,477 +260,477 @@ dedicated shortcuts for its global actions and history panel. | CopyPaste panel open | Alt+C | Focus the search box. | | CopyPaste panel open | Alt+G / Alt+T | Open the filter menu. | | CopyPaste panel open | Esc | Clear the current filter or close the panel. | - -### Card Customization - -Each clipboard card can be personalized with: - -- **Custom Label:** Add a descriptive name (up to 50 characters) to identify your items quickly -- **Color Indicator:** Choose from 6 colors (Red, Green, Purple, Yellow, Blue, Orange) or None to visually categorize your items - -To edit a card: - -- **Right-click** on any card → Select "Edit" -- **Press E** with a card selected -- **Click the ... menu** on hover → Select "Edit" _(Default theme only)_ - -### Advanced Filters - -CopyPaste includes three filter modes to help you find items in your clipboard history quickly: - -| Mode | Description | How to Use | -| :----------- | :-------------------- | :------------------------------------------------------------------------------------------- | -| **Content** | Text search (default) | Type in the search box to filter by content or label | -| **Category** | Filter by color | Select colors from the dropdown to show only items with selected colors | -| **Type** | Filter by item type | Select from the dropdown to filter by content type | - -**Switching Filter Modes:** - -- Click the filter icon next to the search box and select a mode from the flyout -- Use keyboard shortcuts: Alt+C (Content), Alt+G (Category), Alt+T (Type) - -**How Filters Work:** - -- Each mode applies only its relevant filter — text search in Content mode, colors in Category mode, types in Type mode -- Switching modes automatically uses the appropriate filter without mixing criteria -- In Category and Type modes, select multiple options from the dropdown for precise filtering -- Press Esc to clear the current filter -- When filtering, pinned items show a pin icon in the footer to help identify them - -**Clearing Filters:** Press Esc to clear the current filter (search text, colors, or types depending on the active mode). - -**Configurable Reset Behavior:** In Settings, you can configure whether filters reset when the window opens: - -- Reset to Content mode on open -- Clear text search on open -- Clear category (color) filter on open -- Clear type filter on open - -### Card Expansion - -Clipboard items (cards) can be expanded to show more text content: - -**With Mouse:** - -- **Single click** on a card → Expand to see full text (click again to collapse) -- **Double click** on a card → Paste the item immediately to your previous app -- Only one card can be expanded at a time -- All cards collapse when the window is hidden -- In **Default** theme, hovering a card reveals quick action buttons -- In **Compact** theme, cards have no hover effect (use right-click instead) - -Double-click always collapses the card before pasting, so your last click state is always clean. - -**With Keyboard:** - -- **Right arrow →** → Expand/collapse the selected card -- Cards automatically collapse when you navigate to a different item with ↑/↓ -- Only one card can be expanded at a time - -### Keyboard-Only Workflow - -1. **Press Ctrl+Alt+C** on Windows or **Control+Shift+V** on macOS (customizable in Settings) → Window opens with focus on search box -2. **Type to filter** (optional) → Results update in real-time (searches content and labels) -3. **Press Esc** (optional) → Clear search to see all items again -4. **Press ↓** → Navigate to first clipboard item -5. **Use ↑/↓** → Select the desired item -6. **Press →** (optional) → Expand card to see full text -7. **Press E** (optional) → Edit card to add label/color -8. **Press Enter** → Item is pasted to your previous application - -This copy-paste workflow matches the efficiency of double-clicking with your mouse but keeps your hands on the keyboard. - -### Filter Configuration - -In the **Settings** window, you can customize filter behavior: - -- **Return to Content mode on open:** When enabled, always starts in Content mode (text search) when opening CopyPaste -- **Clear search on open:** Automatically clears the search text when opening the window -- **Clear category filter on open:** Resets color selections when opening (only applies if not returning to Content mode) -- **Clear type filter on open:** Resets type selections when opening (only applies if not returning to Content mode) - -If "Return to Content mode on open" is enabled, the other clear options are automatically disabled since returning to Content mode achieves the same result. - ---- - -## Getting Started - -| OS | Recommended | Alternatives | -| :---------- | :-------------------------------- | :------------------------------------------------- | -| **Windows** | Microsoft Store | Scoop · standalone `.exe` | -| **macOS** | Homebrew | Standalone `.dmg` | - -After installing, open CopyPaste with **Ctrl+Alt+C** on Windows or **Control+Shift+V** on macOS. Both are customizable in Settings → Shortcuts. - -### Windows - -**Microsoft Store** (recommended) — one click, automatic updates, no security warnings. - -> [Install from the Microsoft Store](https://apps.microsoft.com/detail/9NBJRZF3K856) - -**Scoop** — for command-line installs, tracked with `scoop update`: - -```sh -scoop bucket add rgdevment https://github.com/rgdevment/scoop-bucket -scoop install copypaste -``` - -**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. - ---- - -### 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 support (discontinued) - -> **Linux was maintained through v2.11.0 and is discontinued from there on.** Keeping the X11 shell -> (global hotkey, XTest paste-back, AppIndicator tray) and the AppImage / `.deb` / `.rpm` pipeline -> alive was beyond the resources of a single-maintainer project, so the platform was retired rather -> than left to rot half-working. - -- **v2.11.0 is the last release with Linux builds, and it stays available.** Its artifacts remain on - [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/tag/v2.11.0) and keep working; - installed copies point there instead of at a version they cannot install. They receive no fixes, - no security updates and no new features. -- **No new Linux packages are published.** The openSUSE Build Service repositories, the Homebrew - `copypaste-linux` formula and the self-updating AppImage are frozen at v2.11.0 so reinstalling - still works, but nothing new lands there. -- **Your data is untouched.** History, images and settings stay in - `~/.local/share/com.rgdevment.copypaste/CopyPaste/` — back that folder up before uninstalling if - you want to keep it. -- **Bug reports for Linux are not accepted.** The last state with full Linux support is archived on - the [`v2-linux-archive`](https://github.com/rgdevment/CopyPaste/tree/v2-linux-archive) branch — - code, packaging and CI included — for anyone who wants to fork and continue it. The GPL v3 - licence covers exactly that. - -### Compatibility - -| Platform | Versions | Architecture | -| :---------- | :------------------------------------------- | :-------------------------------- | -| **Windows** | Windows 10 (1809+), Windows 11 | x64 | -| **macOS** | Ventura (13.0+) | Universal (Apple Silicon + Intel) | - -### Standalone Downloads - -Direct packages live on [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest): - -| Platform | File | Notes | -| :---------- | :------------------------- | :-------------------------------------------------------------------------- | -| **Windows** | `*_Setup.exe` | Self-signed installer — see security note below | -| **macOS** | `*.dmg` | Universal binary (Apple Silicon + Intel) | - -
-Windows standalone: security warnings - -Since CopyPaste is an independent open source project, the installer uses a self-signed certificate. Windows and your browser may show security warnings — **this is normal and expected.** - -- **Browser:** Chrome/Edge may block the download — click Keep or Keep anyway. -- **SmartScreen:** Click More info → Run anyway (only happens once). -- **Why?** Code signing certificates cost $200–800/year. The code is 100% open source — you can inspect every line. SHA256 checksums are provided for each release. - -
- ---- - -## FAQ - -**Is CopyPaste free?** -Yes. Completely free and open source. No premium tiers, no subscriptions, no paywalls — ever. That covers using it anywhere, including across a company, and packaging it for a distribution or package manager. Only redistributing it inside a product of your own needs [separate terms](COMMERCIAL.md). - -**Does it upload my clipboard data?** -No. Everything stays on your machine. There is no cloud, no server, no sync. CopyPaste is a local-first clipboard manager by design — your copy paste data never leaves your computer. - -**Does it store passwords?** -No. Passwords and clipboard content from password managers are automatically ignored. - -**Do I need internet to use it?** -No. CopyPaste works fully offline. The standalone version makes a lightweight check for updates (no user data sent), but works perfectly without a connection. - -**Does it sync clipboard history between devices?** -No. There's intentionally no cloud sync. Your copy history stays on the device where you copied it. This is a local-first copy tool, not a cloud service. - -**Where is my clipboard history stored?** -Windows: `%LOCALAPPDATA%\CopyPaste\` — macOS: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/`. Each folder contains the database, images, config, and logs. - -**What platforms does this copy-paste tool support?** -Windows 10/11 and macOS (Ventura+). Linux support was discontinued — see [Linux support (discontinued)](#linux-support-discontinued). - -**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. - -**Does the macOS version work on Intel Macs?** -Yes. The DMG contains a universal binary that runs natively on both Apple Silicon (M1/M2/M3/M4) and Intel Macs. - -**How is CopyPaste different from other clipboard managers?** -CopyPaste is a personal project, not a company product. There are no ads, no telemetry, no accounts, and no data collection. Unlike most copy paste tools, it's built to feel native on each platform (Mica on Windows, Sidebar material on macOS), it's fully keyboard-driven, and it respects your privacy completely. It's an open source clipboard utility focused on productivity — you can verify every line of code yourself. - ---- - -## Support and Bug Reporting - -### Exporting Logs - -If CopyPaste is misbehaving, you can export a diagnostic log bundle directly from the app. - -**Steps:** - -1. Open CopyPaste → **Settings** (gear icon) -2. Go to the **About** tab -3. Under **Support**, click **"Export Logs"** -4. Save the .zip file to a location of your choice -5. Attach the zip to your [GitHub issue](https://github.com/rgdevment/CopyPaste/issues/new) - -The zip includes: - -- Recent application log files (.log) -- The `crash.log` file if one exists (created when the app fails to start or crashes during initialization) -- A `device_info.txt` with your OS version and app version — no personal data - -**Privacy guarantee:** Logs and the crash file contain only application events, errors and stack traces. **Your clipboard content is never written to any of them.** Before zipping, an automatic redaction pass replaces your user name, home folder path and any email addresses with ``, `` and `` placeholders. The exported file stays on your machine until you explicitly share it. Nothing is sent automatically. - -### Opening the Logs Folder - -If you prefer to inspect log files directly: - -1. Settings → About → Support → **"Open Logs Folder"** -2. Your file manager opens at the logs directory - -Logs are plain text — you can review them before deciding what to share. - -### Reporting on GitHub - -1. [Open a new issue](https://github.com/rgdevment/CopyPaste/issues/new) -2. Describe what happened and steps to reproduce -3. Attach the exported log zip (optional but very helpful) -4. Include your OS version and CopyPaste version (shown in Settings → About) - -You decide exactly what you share. The reporting process is fully manual and private. - ---- - -## Clean Install and Reset - -Sometimes you need a fresh start — for troubleshooting, transferring to a new machine, or just cleaning up. - -**Where to find it:** Settings → About → **Reset & Clean Install** - -### Soft Reset - -Resets all settings to defaults and marks the app as a new installation. **Your clipboard history is preserved.** - -Use this when: - -- Settings became corrupted or something isn't behaving correctly -- You want to start fresh with default configuration without losing history - -### Hard Reset - -Deletes everything — clipboard history, images, settings, and logs — then restarts the app. **This action cannot be undone.** - -Use this when: - -- You want a completely clean slate -- You're transferring to someone else or decommissioning the app - -### Microsoft Store Users - -Both reset options work identically on the Microsoft Store version. MSIX packaging uses filesystem virtualization, so the app's data folder is the real package data path — CopyPaste can find and wipe it without needing elevated permissions. - -The Windows Settings "Reset app" button does the same thing as Hard Reset. Both are safe to use. - ---- - -## Found a Bug? Have Feedback? - -**Your feedback shapes what gets built next.** Here's how to reach me: - -| What you need | How | -| :------------------------------------- | :----------------------------------------------------------------------------------------------------------------- | -| **Report a bug** | [Open an Issue](https://github.com/rgdevment/CopyPaste/issues/new) — tell me what happened and how to reproduce it | -| **Suggest a feature** | [Open an Issue](https://github.com/rgdevment/CopyPaste/issues/new) — tell me what you'd like to see | -| **Ask a question** | [Start a Discussion](https://github.com/rgdevment/CopyPaste/discussions) — ask anything or just say hi | -| **Show support** | Star the repo — helps other people find this clipboard manager | -| **Contribute code** | [Check CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome | - -**When reporting bugs, include:** - -- OS and version (e.g., Windows 11 24H2, macOS Sequoia 15.3) -- What you were doing -- Any error messages -- CopyPaste version (check Settings → About) -- Exported log zip if available (Settings → About → Support → Export Logs) — it now also bundles `crash.log` if the app failed to start, with personal info redacted automatically - ---- - -## What's Coming and What's Changed - -I keep a clear record of what's been added, fixed, and planned: - -**[View Release Notes & Changelog](https://github.com/rgdevment/CopyPaste/releases)** — complete history of all changes. - ---- - -## Localization: Help Translate CopyPaste - -CopyPaste should speak your language. Currently it supports English and Spanish, but the goal is to reach people everywhere. - -### Currently Supported Languages - -| Language | Tag | Status | -| :------------------ | :---: | :------: | -| Spanish (Chile) | es-CL | Complete | -| English (US) | en-US | Complete | - -### How It Works - -- **Automatic Detection:** The app detects your system language and applies the appropriate translation. -- **Regional Fallback:** If your exact region isn't available (e.g., es-MX), it falls back to the base language (e.g., es-CL). -- **Manual Override:** You can force a specific language in the Settings panel. - -### Help Add a New Language - -CopyPaste uses Flutter's standard ARB-based localization. Adding a new language requires creating one file. - -#### Steps to Add a New Translation - -1. **Create a branch** from main in the repository. - -2. **Copy the base language file:** - - ```text - app/lib/l10n/app_en.arb - ``` - - This is the reference file with all translation keys. - -3. **Name your file using the language code:** - - app_de.arb (German) - - app_fr.arb (French) - - app_pt.arb (Portuguese - Brazil) - - app_ja.arb (Japanese) - -4. **Translate the values** (keep the keys in English — only change values): - - ```json - { - "@@locale": "de", - "searchPlaceholder": "Suche im Zwischenablage…", - "emptyStateSubtitle": "Kopiere etwas, um zu starten", - "pinned": "Angeheftet", - "recent": "Zuletzt" - } - ``` - -5. **Run flutter gen-l10n** (or flutter pub get) to regenerate the localization classes. - -6. **Test your translation** by changing your system language or using the manual override in Settings. - -7. **Submit a Pull Request** with your ARB file. - -#### Translation Guidelines - -- Keep translations concise — UI space is limited -- Use formal or neutral tone -- Preserve ARB placeholders like {name} or {count} -- Include "@@locale": "xx" at the top of the file -- Don't translate brand names (CopyPaste, Windows, etc.) -- Don't change ARB keys (only values) - ---- - -## Want to Help? - -Contributions are always appreciated — whether that's a bug report, a translation, or a pull request: - -- **Write Code** — Fix bugs or add features. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup. -- **Translate** — Add your language. [See guide](#localization-help-translate-copypaste). -- **Report Bugs** — If something breaks, [open an issue](https://github.com/rgdevment/CopyPaste/issues/new). -- **Share Ideas** — Tell me what you wish this clipboard manager could do. - ---- - -## Tech Stack (For Developers) - -If you're curious about what's under the hood of this open source clipboard manager: - -| Technology | Why | -| :---------------------------------------------------- | :------------------------------------------------------------------------------------ | -| **Flutter** | Cross-platform UI toolkit — native on Windows and macOS. | -| **Dart** | Clean, performant language for core logic, services, and domain models. | -| **Platform Channels + FFI** | Native integration with each OS for clipboard hooks and system APIs. | -| **Windows Mica / macOS Sidebar** | Native translucent effects that match each platform's design language. | -| **C++ Plugin (Win) / Swift (Mac)** | Low-level clipboard listener to capture every content type before the OS discards it. | -| **Native C++ Launcher (Win)** | Lightweight splash process that appears instantly while Flutter warms up. | -| **SQLite (Drift) + FTS5** | Local database with full-text search across content and labels. | -| **Auto-update (Standalone)** | Ed25519-signed release manifest hosted on GitHub Releases; in-app badge notifies users of new versions and enforces blocks on versions with critical issues. | -| **Theme System** | Built-in Default and Compact themes, plus custom theme support via external packages. | - ---- - -## Themes - -CopyPaste follows your system theme automatically — no configuration needed. - -- **Light** — Clean and bright, matching a light OS theme. -- **Dark** — Easy on the eyes, matching a dark OS theme. -- You can override the automatic selection in **Settings → General → Theme**. - ---- - -## Other Tools by the Same Author - -I build free, open source tools focused on privacy and productivity. If you like CopyPaste, you might also find this useful: - -

- - LinkUnbound - -

- -### [LinkUnbound](https://github.com/rgdevment/LinkUnbound) - -A free, open source browser picker for Windows and Mac. Every link you click gets intercepted — domain rules open the assigned browser instantly, or a small picker appears near your cursor to let you choose. Resolves Microsoft SafeLinks and redirect wrappers before matching rules. - -No ads. No telemetry. No accounts. Everything local. - ---- - -## License and Spirit - -**CopyPaste** — A modern, open source clipboard manager and copy-paste tool for Windows and macOS. -Copyright (C) 2026 Mario Hidalgo G. (rgdevment) - -This program comes with ABSOLUTELY NO WARRANTY. -This is free software, and you are welcome to redistribute it under certain conditions. -Distributed under the **GNU General Public License v3.0**. See [LICENSE](LICENSE) for more information. - -CopyPaste is dual licensed. The GPL-3.0 covers everyone using, deploying, -auditing, packaging or forking it — which is almost everybody, and it costs -nothing. Redistributing it inside a product of your own needs separate terms: -see [COMMERCIAL.md](COMMERCIAL.md). - -Packaging it for a distribution or package manager is ordinary GPL -redistribution and needs no permission from anyone. - -Contributions require a one-time [CLA](CLA.md); you keep the copyright on your -work. - ---- - -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. - -
-

Built with care and too much coffee.

-
+ +### Card Customization + +Each clipboard card can be personalized with: + +- **Custom Label:** Add a descriptive name (up to 50 characters) to identify your items quickly +- **Color Indicator:** Choose from 6 colors (Red, Green, Purple, Yellow, Blue, Orange) or None to visually categorize your items + +To edit a card: + +- **Right-click** on any card → Select "Edit" +- **Press E** with a card selected +- **Click the ... menu** on hover → Select "Edit" _(Default theme only)_ + +### Advanced Filters + +CopyPaste includes three filter modes to help you find items in your clipboard history quickly: + +| Mode | Description | How to Use | +| :----------- | :-------------------- | :------------------------------------------------------------------------------------------- | +| **Content** | Text search (default) | Type in the search box to filter by content or label | +| **Category** | Filter by color | Select colors from the dropdown to show only items with selected colors | +| **Type** | Filter by item type | Select from the dropdown to filter by content type | + +**Switching Filter Modes:** + +- Click the filter icon next to the search box and select a mode from the flyout +- Use keyboard shortcuts: Alt+C (Content), Alt+G (Category), Alt+T (Type) + +**How Filters Work:** + +- Each mode applies only its relevant filter — text search in Content mode, colors in Category mode, types in Type mode +- Switching modes automatically uses the appropriate filter without mixing criteria +- In Category and Type modes, select multiple options from the dropdown for precise filtering +- Press Esc to clear the current filter +- When filtering, pinned items show a pin icon in the footer to help identify them + +**Clearing Filters:** Press Esc to clear the current filter (search text, colors, or types depending on the active mode). + +**Configurable Reset Behavior:** In Settings, you can configure whether filters reset when the window opens: + +- Reset to Content mode on open +- Clear text search on open +- Clear category (color) filter on open +- Clear type filter on open + +### Card Expansion + +Clipboard items (cards) can be expanded to show more text content: + +**With Mouse:** + +- **Single click** on a card → Expand to see full text (click again to collapse) +- **Double click** on a card → Paste the item immediately to your previous app +- Only one card can be expanded at a time +- All cards collapse when the window is hidden +- In **Default** theme, hovering a card reveals quick action buttons +- In **Compact** theme, cards have no hover effect (use right-click instead) + +Double-click always collapses the card before pasting, so your last click state is always clean. + +**With Keyboard:** + +- **Right arrow →** → Expand/collapse the selected card +- Cards automatically collapse when you navigate to a different item with ↑/↓ +- Only one card can be expanded at a time + +### Keyboard-Only Workflow + +1. **Press Ctrl+Alt+C** on Windows or **Control+Shift+V** on macOS (customizable in Settings) → Window opens with focus on search box +2. **Type to filter** (optional) → Results update in real-time (searches content and labels) +3. **Press Esc** (optional) → Clear search to see all items again +4. **Press ↓** → Navigate to first clipboard item +5. **Use ↑/↓** → Select the desired item +6. **Press →** (optional) → Expand card to see full text +7. **Press E** (optional) → Edit card to add label/color +8. **Press Enter** → Item is pasted to your previous application + +This copy-paste workflow matches the efficiency of double-clicking with your mouse but keeps your hands on the keyboard. + +### Filter Configuration + +In the **Settings** window, you can customize filter behavior: + +- **Return to Content mode on open:** When enabled, always starts in Content mode (text search) when opening CopyPaste +- **Clear search on open:** Automatically clears the search text when opening the window +- **Clear category filter on open:** Resets color selections when opening (only applies if not returning to Content mode) +- **Clear type filter on open:** Resets type selections when opening (only applies if not returning to Content mode) + +If "Return to Content mode on open" is enabled, the other clear options are automatically disabled since returning to Content mode achieves the same result. + +--- + +## Getting Started + +| OS | Recommended | Alternatives | +| :---------- | :-------------------------------- | :------------------------------------------------- | +| **Windows** | Microsoft Store | Scoop · standalone `.exe` | +| **macOS** | Homebrew | Standalone `.dmg` | + +After installing, open CopyPaste with **Ctrl+Alt+C** on Windows or **Control+Shift+V** on macOS. Both are customizable in Settings → Shortcuts. + +### Windows + +**Microsoft Store** (recommended) — one click, automatic updates, no security warnings. + +> [Install from the Microsoft Store](https://apps.microsoft.com/detail/9NBJRZF3K856) + +**Scoop** — for command-line installs, tracked with `scoop update`: + +```sh +scoop bucket add rgdevment https://github.com/rgdevment/scoop-bucket +scoop install copypaste +``` + +**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. + +--- + +### 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 support (discontinued) + +> **Linux was maintained through v2.11.0 and is discontinued from there on.** Keeping the X11 shell +> (global hotkey, XTest paste-back, AppIndicator tray) and the AppImage / `.deb` / `.rpm` pipeline +> alive was beyond the resources of a single-maintainer project, so the platform was retired rather +> than left to rot half-working. + +- **v2.11.0 is the last release with Linux builds, and it stays available.** Its artifacts remain on + [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/tag/v2.11.0) and keep working; + installed copies point there instead of at a version they cannot install. They receive no fixes, + no security updates and no new features. +- **No new Linux packages are published.** The openSUSE Build Service repositories, the Homebrew + `copypaste-linux` formula and the self-updating AppImage are frozen at v2.11.0 so reinstalling + still works, but nothing new lands there. +- **Your data is untouched.** History, images and settings stay in + `~/.local/share/com.rgdevment.copypaste/CopyPaste/` — back that folder up before uninstalling if + you want to keep it. +- **Bug reports for Linux are not accepted.** The last state with full Linux support is archived on + the [`v2-linux-archive`](https://github.com/rgdevment/CopyPaste/tree/v2-linux-archive) branch — + code, packaging and CI included — for anyone who wants to fork and continue it. The GPL v3 + licence covers exactly that. + +### Compatibility + +| Platform | Versions | Architecture | +| :---------- | :------------------------------------------- | :-------------------------------- | +| **Windows** | Windows 10 (1809+), Windows 11 | x64 | +| **macOS** | Ventura (13.0+) | Universal (Apple Silicon + Intel) | + +### Standalone Downloads + +Direct packages live on [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest): + +| Platform | File | Notes | +| :---------- | :------------------------- | :-------------------------------------------------------------------------- | +| **Windows** | `*_Setup.exe` | Self-signed installer — see security note below | +| **macOS** | `*.dmg` | Universal binary (Apple Silicon + Intel) | + +
+Windows standalone: security warnings + +Since CopyPaste is an independent open source project, the installer uses a self-signed certificate. Windows and your browser may show security warnings — **this is normal and expected.** + +- **Browser:** Chrome/Edge may block the download — click Keep or Keep anyway. +- **SmartScreen:** Click More info → Run anyway (only happens once). +- **Why?** Code signing certificates cost $200–800/year. The code is 100% open source — you can inspect every line. SHA256 checksums are provided for each release. + +
+ +--- + +## FAQ + +**Is CopyPaste free?** +Yes. Completely free and open source. No premium tiers, no subscriptions, no paywalls — ever. That covers using it anywhere, including across a company, and packaging it for a distribution or package manager. Only redistributing it inside a product of your own needs [separate terms](COMMERCIAL.md). + +**Does it upload my clipboard data?** +No. Everything stays on your machine. There is no cloud, no server, no sync. CopyPaste is a local-first clipboard manager by design — your copy paste data never leaves your computer. + +**Does it store passwords?** +No. Passwords and clipboard content from password managers are automatically ignored. + +**Do I need internet to use it?** +No. CopyPaste works fully offline. The standalone version makes a lightweight check for updates (no user data sent), but works perfectly without a connection. + +**Does it sync clipboard history between devices?** +No. There's intentionally no cloud sync. Your copy history stays on the device where you copied it. This is a local-first copy tool, not a cloud service. + +**Where is my clipboard history stored?** +Windows: `%LOCALAPPDATA%\CopyPaste\` — macOS: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/`. Each folder contains the database, images, config, and logs. + +**What platforms does this copy-paste tool support?** +Windows 10/11 and macOS (Ventura+). Linux support was discontinued — see [Linux support (discontinued)](#linux-support-discontinued). + +**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. + +**Does the macOS version work on Intel Macs?** +Yes. The DMG contains a universal binary that runs natively on both Apple Silicon (M1/M2/M3/M4) and Intel Macs. + +**How is CopyPaste different from other clipboard managers?** +CopyPaste is a personal project, not a company product. There are no ads, no telemetry, no accounts, and no data collection. Unlike most copy paste tools, it's built to feel native on each platform (Mica on Windows, Sidebar material on macOS), it's fully keyboard-driven, and it respects your privacy completely. It's an open source clipboard utility focused on productivity — you can verify every line of code yourself. + +--- + +## Support and Bug Reporting + +### Exporting Logs + +If CopyPaste is misbehaving, you can export a diagnostic log bundle directly from the app. + +**Steps:** + +1. Open CopyPaste → **Settings** (gear icon) +2. Go to the **About** tab +3. Under **Support**, click **"Export Logs"** +4. Save the .zip file to a location of your choice +5. Attach the zip to your [GitHub issue](https://github.com/rgdevment/CopyPaste/issues/new) + +The zip includes: + +- Recent application log files (.log) +- The `crash.log` file if one exists (created when the app fails to start or crashes during initialization) +- A `device_info.txt` with your OS version and app version — no personal data + +**Privacy guarantee:** Logs and the crash file contain only application events, errors and stack traces. **Your clipboard content is never written to any of them.** Before zipping, an automatic redaction pass replaces your user name, home folder path and any email addresses with ``, `` and `` placeholders. The exported file stays on your machine until you explicitly share it. Nothing is sent automatically. + +### Opening the Logs Folder + +If you prefer to inspect log files directly: + +1. Settings → About → Support → **"Open Logs Folder"** +2. Your file manager opens at the logs directory + +Logs are plain text — you can review them before deciding what to share. + +### Reporting on GitHub + +1. [Open a new issue](https://github.com/rgdevment/CopyPaste/issues/new) +2. Describe what happened and steps to reproduce +3. Attach the exported log zip (optional but very helpful) +4. Include your OS version and CopyPaste version (shown in Settings → About) + +You decide exactly what you share. The reporting process is fully manual and private. + +--- + +## Clean Install and Reset + +Sometimes you need a fresh start — for troubleshooting, transferring to a new machine, or just cleaning up. + +**Where to find it:** Settings → About → **Reset & Clean Install** + +### Soft Reset + +Resets all settings to defaults and marks the app as a new installation. **Your clipboard history is preserved.** + +Use this when: + +- Settings became corrupted or something isn't behaving correctly +- You want to start fresh with default configuration without losing history + +### Hard Reset + +Deletes everything — clipboard history, images, settings, and logs — then restarts the app. **This action cannot be undone.** + +Use this when: + +- You want a completely clean slate +- You're transferring to someone else or decommissioning the app + +### Microsoft Store Users + +Both reset options work identically on the Microsoft Store version. MSIX packaging uses filesystem virtualization, so the app's data folder is the real package data path — CopyPaste can find and wipe it without needing elevated permissions. + +The Windows Settings "Reset app" button does the same thing as Hard Reset. Both are safe to use. + +--- + +## Found a Bug? Have Feedback? + +**Your feedback shapes what gets built next.** Here's how to reach me: + +| What you need | How | +| :------------------------------------- | :----------------------------------------------------------------------------------------------------------------- | +| **Report a bug** | [Open an Issue](https://github.com/rgdevment/CopyPaste/issues/new) — tell me what happened and how to reproduce it | +| **Suggest a feature** | [Open an Issue](https://github.com/rgdevment/CopyPaste/issues/new) — tell me what you'd like to see | +| **Ask a question** | [Start a Discussion](https://github.com/rgdevment/CopyPaste/discussions) — ask anything or just say hi | +| **Show support** | Star the repo — helps other people find this clipboard manager | +| **Contribute code** | [Check CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome | + +**When reporting bugs, include:** + +- OS and version (e.g., Windows 11 24H2, macOS Sequoia 15.3) +- What you were doing +- Any error messages +- CopyPaste version (check Settings → About) +- Exported log zip if available (Settings → About → Support → Export Logs) — it now also bundles `crash.log` if the app failed to start, with personal info redacted automatically + +--- + +## What's Coming and What's Changed + +I keep a clear record of what's been added, fixed, and planned: + +**[View Release Notes & Changelog](https://github.com/rgdevment/CopyPaste/releases)** — complete history of all changes. + +--- + +## Localization: Help Translate CopyPaste + +CopyPaste should speak your language. Currently it supports English and Spanish, but the goal is to reach people everywhere. + +### Currently Supported Languages + +| Language | Tag | Status | +| :------------------ | :---: | :------: | +| Spanish (Chile) | es-CL | Complete | +| English (US) | en-US | Complete | + +### How It Works + +- **Automatic Detection:** The app detects your system language and applies the appropriate translation. +- **Regional Fallback:** If your exact region isn't available (e.g., es-MX), it falls back to the base language (e.g., es-CL). +- **Manual Override:** You can force a specific language in the Settings panel. + +### Help Add a New Language + +CopyPaste uses Flutter's standard ARB-based localization. Adding a new language requires creating one file. + +#### Steps to Add a New Translation + +1. **Create a branch** from main in the repository. + +2. **Copy the base language file:** + + ```text + app/lib/l10n/app_en.arb + ``` + + This is the reference file with all translation keys. + +3. **Name your file using the language code:** + - app_de.arb (German) + - app_fr.arb (French) + - app_pt.arb (Portuguese - Brazil) + - app_ja.arb (Japanese) + +4. **Translate the values** (keep the keys in English — only change values): + + ```json + { + "@@locale": "de", + "searchPlaceholder": "Suche im Zwischenablage…", + "emptyStateSubtitle": "Kopiere etwas, um zu starten", + "pinned": "Angeheftet", + "recent": "Zuletzt" + } + ``` + +5. **Run flutter gen-l10n** (or flutter pub get) to regenerate the localization classes. + +6. **Test your translation** by changing your system language or using the manual override in Settings. + +7. **Submit a Pull Request** with your ARB file. + +#### Translation Guidelines + +- Keep translations concise — UI space is limited +- Use formal or neutral tone +- Preserve ARB placeholders like {name} or {count} +- Include "@@locale": "xx" at the top of the file +- Don't translate brand names (CopyPaste, Windows, etc.) +- Don't change ARB keys (only values) + +--- + +## Want to Help? + +Contributions are always appreciated — whether that's a bug report, a translation, or a pull request: + +- **Write Code** — Fix bugs or add features. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup. +- **Translate** — Add your language. [See guide](#localization-help-translate-copypaste). +- **Report Bugs** — If something breaks, [open an issue](https://github.com/rgdevment/CopyPaste/issues/new). +- **Share Ideas** — Tell me what you wish this clipboard manager could do. + +--- + +## Tech Stack (For Developers) + +If you're curious about what's under the hood of this open source clipboard manager: + +| Technology | Why | +| :---------------------------------------------------- | :------------------------------------------------------------------------------------ | +| **Flutter** | Cross-platform UI toolkit — native on Windows and macOS. | +| **Dart** | Clean, performant language for core logic, services, and domain models. | +| **Platform Channels + FFI** | Native integration with each OS for clipboard hooks and system APIs. | +| **Windows Mica / macOS Sidebar** | Native translucent effects that match each platform's design language. | +| **C++ Plugin (Win) / Swift (Mac)** | Low-level clipboard listener to capture every content type before the OS discards it. | +| **Native C++ Launcher (Win)** | Lightweight splash process that appears instantly while Flutter warms up. | +| **SQLite (Drift) + FTS5** | Local database with full-text search across content and labels. | +| **Auto-update (Standalone)** | Ed25519-signed release manifest hosted on GitHub Releases; in-app badge notifies users of new versions and enforces blocks on versions with critical issues. | +| **Theme System** | Built-in Default and Compact themes, plus custom theme support via external packages. | + +--- + +## Themes + +CopyPaste follows your system theme automatically — no configuration needed. + +- **Light** — Clean and bright, matching a light OS theme. +- **Dark** — Easy on the eyes, matching a dark OS theme. +- You can override the automatic selection in **Settings → General → Theme**. + +--- + +## Other Tools by the Same Author + +I build free, open source tools focused on privacy and productivity. If you like CopyPaste, you might also find this useful: + +

+ + LinkUnbound + +

+ +### [LinkUnbound](https://github.com/rgdevment/LinkUnbound) + +A free, open source browser picker for Windows and Mac. Every link you click gets intercepted — domain rules open the assigned browser instantly, or a small picker appears near your cursor to let you choose. Resolves Microsoft SafeLinks and redirect wrappers before matching rules. + +No ads. No telemetry. No accounts. Everything local. + +--- + +## License and Spirit + +**CopyPaste** — A modern, open source clipboard manager and copy-paste tool for Windows and macOS. +Copyright (C) 2026 Mario Hidalgo G. (rgdevment) + +This program comes with ABSOLUTELY NO WARRANTY. +This is free software, and you are welcome to redistribute it under certain conditions. +Distributed under the **GNU General Public License v3.0**. See [LICENSE](LICENSE) for more information. + +CopyPaste is dual licensed. The GPL-3.0 covers everyone using, deploying, +auditing, packaging or forking it — which is almost everybody, and it costs +nothing. Redistributing it inside a product of your own needs separate terms: +see [COMMERCIAL.md](COMMERCIAL.md). + +Packaging it for a distribution or package manager is ordinary GPL +redistribution and needs no permission from anyone. + +Contributions require a one-time [CLA](CLA.md); you keep the copyright on your +work. + +--- + +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. + +
+

Built with care and too much coffee.

+
diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index 08eeefd2..00000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,33 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -analyzer: - exclude: - - "**/*.g.dart" - - "**/*.freezed.dart" - language: - strict-casts: true - strict-inference: true - strict-raw-types: true - -linter: - rules: - # Style - prefer_single_quotes: true - prefer_const_constructors: true - prefer_const_declarations: true - prefer_final_locals: true - prefer_final_fields: true - sort_constructors_first: true - use_super_parameters: true - unnecessary_late: true - require_trailing_commas: true - - # Safety - avoid_print: true - avoid_relative_lib_imports: true - cancel_subscriptions: true - close_sinks: true - unawaited_futures: true - - # Documentation - public_member_api_docs: false diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 3820a95c..00000000 --- a/app/.gitignore +++ /dev/null @@ -1,45 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release diff --git a/app/.metadata b/app/.metadata deleted file mode 100644 index 33cef2bf..00000000 --- a/app/.metadata +++ /dev/null @@ -1,33 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "48c32af0345e9ad5747f78ddce828c7f795f7159" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - - platform: macos - create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - - platform: windows - create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml deleted file mode 100644 index 5e2133eb..00000000 --- a/app/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../analysis_options.yaml diff --git a/app/assets/icons/icon_linkunbound.png b/app/assets/icons/icon_linkunbound.png deleted file mode 100644 index 60277ea1..00000000 Binary files a/app/assets/icons/icon_linkunbound.png and /dev/null differ diff --git a/app/assets/icons/icon_mac.png b/app/assets/icons/icon_mac.png deleted file mode 100644 index f38e2a7d..00000000 Binary files a/app/assets/icons/icon_mac.png and /dev/null differ diff --git a/app/assets/icons/icon_mac_tray.png b/app/assets/icons/icon_mac_tray.png deleted file mode 100644 index 460488eb..00000000 Binary files a/app/assets/icons/icon_mac_tray.png and /dev/null differ diff --git a/app/assets/icons/icon_mac_tray@2x.png b/app/assets/icons/icon_mac_tray@2x.png deleted file mode 100644 index 1d906697..00000000 Binary files a/app/assets/icons/icon_mac_tray@2x.png and /dev/null differ diff --git a/app/assets/icons/icon_notification.png b/app/assets/icons/icon_notification.png deleted file mode 100644 index 2981fa46..00000000 Binary files a/app/assets/icons/icon_notification.png and /dev/null differ diff --git a/app/assets/icons/icon_tray.ico b/app/assets/icons/icon_tray.ico deleted file mode 100644 index 11ef74b2..00000000 Binary files a/app/assets/icons/icon_tray.ico and /dev/null differ diff --git a/app/assets/icons/icon_tray_32.png b/app/assets/icons/icon_tray_32.png deleted file mode 100644 index 86af0c4d..00000000 Binary files a/app/assets/icons/icon_tray_32.png and /dev/null differ diff --git a/app/assets/icons/icon_tray_64.png b/app/assets/icons/icon_tray_64.png deleted file mode 100644 index 563677d8..00000000 Binary files a/app/assets/icons/icon_tray_64.png and /dev/null differ diff --git a/app/assets/keys/release_pubkey.txt b/app/assets/keys/release_pubkey.txt deleted file mode 100644 index e1f0b01e..00000000 --- a/app/assets/keys/release_pubkey.txt +++ /dev/null @@ -1 +0,0 @@ -MnfQjTGjNcJN6Z/UT4e0eHXUJY63V6+Qx4byymoZ++4= diff --git a/app/distribute_options.yaml b/app/distribute_options.yaml deleted file mode 100644 index df57bc9a..00000000 --- a/app/distribute_options.yaml +++ /dev/null @@ -1,22 +0,0 @@ -output: dist/ - -releases: - - name: windows-standalone - jobs: - - name: build-exe - package: - platform: windows - target: exe - build_args: - dart-define: - STORE_BUILD: "false" - - - name: windows-store - jobs: - - name: build-msix - package: - platform: windows - target: msix - build_args: - dart-define: - STORE_BUILD: "true" diff --git a/app/l10n.yaml b/app/l10n.yaml deleted file mode 100644 index a4d323c6..00000000 --- a/app/l10n.yaml +++ /dev/null @@ -1,5 +0,0 @@ -arb-dir: lib/l10n -template-arb-file: app_en.arb -output-localization-file: app_localizations.dart -output-class: AppLocalizations -nullable-getter: false diff --git a/app/lib/helpers/url_helper.dart b/app/lib/helpers/url_helper.dart deleted file mode 100644 index 9e6970f8..00000000 --- a/app/lib/helpers/url_helper.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart' show visibleForTesting; - -class UrlHelper { - UrlHelper._(); - - @visibleForTesting - static String? platformOverride; - - static Future open(String url) async { - final platform = platformOverride ?? _currentPlatform(); - if (platform == 'windows') { - await Process.start('cmd', ['/c', 'start', '', url], runInShell: true); - } else if (platform == 'macos') { - await Process.start('open', [url]); - } - } - - static String _currentPlatform() { - if (Platform.isWindows) return 'windows'; - if (Platform.isMacOS) return 'macos'; - return 'other'; - } -} diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb deleted file mode 100644 index 26002a9f..00000000 --- a/app/lib/l10n/app_en.arb +++ /dev/null @@ -1,675 +0,0 @@ -{ - "@@locale": "en", - - "searchPlaceholder": "Search clipboard\u2026", - "@searchPlaceholder": { "description": "Search box placeholder" }, - - "emptyState": "No items in this section", - "@emptyState": { "description": "Empty list message" }, - - "emptyStateSubtitle": "Copy something to get started", - "@emptyStateSubtitle": { "description": "Empty state subtitle" }, - - "hintBannerText": "CopyPaste is active and running in the background. Look for it in the system tray or just use your shortcut. Feel free to customize your experience in", - "@hintBannerText": { "description": "First-run hint banner text" }, - - "hintBannerAction": "Settings", - "@hintBannerAction": { "description": "First-run hint banner action" }, - - "settingsTitle": "Settings", - "@settingsTitle": { "description": "Settings screen title" }, - - "sectionShortcuts": "KEYBOARD SHORTCUTS", - "@sectionShortcuts": { "description": "Shortcuts section header" }, - - "sectionStorage": "STORAGE", - "@sectionStorage": { "description": "Storage section header" }, - - "settingRunOnStartup": "Run on startup", - "@settingRunOnStartup": { "description": "Run on startup toggle label" }, - - "settingLanguage": "Interface language", - "@settingLanguage": { "description": "Language picker label" }, - - "hotkeyWillApply": "Hotkey will apply immediately", - "@hotkeyWillApply": { "description": "Hint when hotkey changes" }, - - "sectionSupport": "SUPPORT", - "@sectionSupport": { "description": "Support section header in About tab" }, - - "supportExportLogs": "Export logs", - "@supportExportLogs": { "description": "Export logs action label" }, - - "supportExportLogsSubtitle": "Save a zip with app logs for a bug report. Your clipboard content is never included.", - "@supportExportLogsSubtitle": { "description": "Export logs subtitle" }, - - "supportOpenLogsFolder": "Open logs folder", - "@supportOpenLogsFolder": { "description": "Open logs folder label" }, - - "supportOpenLogsFolderSubtitle": "Browse the raw log files in your file manager.", - "@supportOpenLogsFolderSubtitle": { "description": "Open logs folder subtitle" }, - - "supportGitHub": "Report a bug on GitHub", - "@supportGitHub": { "description": "GitHub issue link label" }, - - "supportExportSuccess": "Logs saved to Downloads.", - "@supportExportSuccess": { "description": "Snackbar after successful log export" }, - "supportShowInFiles": "Show", - "@supportShowInFiles": { "description": "Snackbar action to reveal the exported file in Finder/Explorer" }, - - "supportExportEmpty": "No log files found.", - "@supportExportEmpty": { "description": "Snackbar when no logs exist" }, - - "supportExportError": "Failed to export logs.", - "@supportExportError": { "description": "Snackbar on export error" }, - - "sectionReset": "RESET & CLEAN INSTALL", - "@sectionReset": { "description": "Reset section header in About tab" }, - - "resetSoftLabel": "Soft Reset", - "@resetSoftLabel": { "description": "Soft reset action label" }, - - "resetSoftSubtitle": "Resets all settings to defaults and marks app as fresh install. Clipboard history is preserved.", - "@resetSoftSubtitle": { "description": "Soft reset subtitle" }, - - "resetHardLabel": "Hard Reset", - "@resetHardLabel": { "description": "Hard reset action label" }, - - "resetHardSubtitle": "Deletes all clipboard history, images, and settings. This cannot be undone.", - "@resetHardSubtitle": { "description": "Hard reset subtitle" }, - - "resetSoftConfirmTitle": "Soft reset?", - "@resetSoftConfirmTitle": { "description": "Soft reset confirm dialog title" }, - - "resetSoftConfirmMessage": "All settings will return to defaults and the app will restart as if freshly installed. Your clipboard history will not be deleted.", - "@resetSoftConfirmMessage": { "description": "Soft reset confirm dialog message" }, - - "resetHardConfirmTitle": "Hard reset?", - "@resetHardConfirmTitle": { "description": "Hard reset confirm dialog title" }, - - "resetHardConfirmMessage": "This will permanently delete all clipboard history, images, and settings, then restart the app. This cannot be undone.", - "@resetHardConfirmMessage": { "description": "Hard reset confirm dialog message" }, - - "resetConfirmButton": "Reset & Restart", - "@resetConfirmButton": { "description": "Reset confirm button label" }, - - "clearHistoryConfirmTitle": "Clear history?", - "@clearHistoryConfirmTitle": { "description": "Clear history dialog title" }, - - "clearHistoryConfirmMessage": "This will permanently delete all non-pinned clipboard items. This action cannot be undone.", - "@clearHistoryConfirmMessage": { "description": "Clear history dialog message" }, - - "clearHistoryConfirmButton": "Clear", - "@clearHistoryConfirmButton": { "description": "Clear history confirm button" }, - - "backupLastDate": "Last backup: {date}", - "@backupLastDate": { - "description": "Last backup date", - "placeholders": { - "date": { "type": "String" } - } - }, - - "backupNone": "No backup created yet.", - "@backupNone": { "description": "No backup yet message" }, - - "backupCreateLabel": "Create backup", - "@backupCreateLabel": { "description": "Create backup label" }, - - "backupRestoreLabel": "Restore backup", - "@backupRestoreLabel": { "description": "Restore backup label" }, - - "backupError": "Failed to create backup. Check permissions.", - "@backupError": { "description": "Backup error message" }, - - "restoreDialogTitle": "Restore backup", - "@restoreDialogTitle": { "description": "Restore dialog title" }, - - "restoreDialogWarning": "This will replace all current data with the backup contents. Continue?", - "@restoreDialogWarning": { "description": "Restore confirmation warning" }, - - "restoreFileNotFound": "File not found.", - "@restoreFileNotFound": { "description": "File not found error" }, - - "restoreSuccess": "Restored {count} items.", - "@restoreSuccess": { - "description": "Restore success message", - "placeholders": { - "count": { "type": "int" } - } - }, - - "restoreError": "Restore failed. Your previous data has been preserved.", - "@restoreError": { "description": "Restore error message" }, - - "buttonSave": "Save", - "@buttonSave": { "description": "Save button" }, - "buttonClose": "Close", - "@buttonClose": { "description": "Generic Close button" }, - "buttonCancel": "Cancel", - "@buttonCancel": { "description": "Cancel button" }, - - "buttonReset": "Restore defaults", - "@buttonReset": { "description": "Reset button" }, - - "savingIndicator": "Saving\u2026", - "@savingIndicator": { "description": "Footer indicator while autosave is in flight" }, - - "savedIndicator": "Saved", - "@savedIndicator": { "description": "Footer indicator after autosave completes" }, - - "menuPaste": "Paste", - "@menuPaste": { "description": "Context menu paste" }, - - "menuPastePlain": "Paste plain", - "@menuPastePlain": { "description": "Context menu paste plain" }, - - "menuCopy": "Copy", - "@menuCopy": { "description": "Context menu copy to clipboard without pasting" }, - - "copiedToClipboard": "Copied to clipboard", - "@copiedToClipboard": { "description": "Snackbar shown after the copy action" }, - - "menuPin": "Pin", - "@menuPin": { "description": "Context menu pin" }, - - "menuUnpin": "Unpin", - "@menuUnpin": { "description": "Context menu unpin" }, - - "menuEdit": "Edit card", - "@menuEdit": { "description": "Context menu edit" }, - - "menuDelete": "Delete", - "@menuDelete": { "description": "Context menu delete" }, - - "editColorLabel": "Color", - "@editColorLabel": { "description": "Color picker label in edit dialog" }, - - "colorRed": "Red", - "colorGreen": "Green", - "colorPurple": "Purple", - "colorYellow": "Yellow", - "colorBlue": "Blue", - "colorOrange": "Orange", - - "typeText": "Text", - "typeImage": "Image", - "typeFile": "File", - "typeFolder": "Folder", - "typeLink": "Link", - "typeAudio": "Audio", - "typeVideo": "Video", - "typeEmail": "Email", - "typePhone": "Phone", - "typeColor": "Color", - "typeIp": "IP", - "typeUuid": "UUID", - "typeJson": "JSON", - "filterAll": "All", - "filterPinned": "Pinned", - - "trayTooltip": "CopyPaste", - "@trayTooltip": { "description": "System tray tooltip" }, - - "trayExit": "Exit", - "@trayExit": { "description": "Tray menu exit item" }, - - "subtitleShortcutScopes": "Ctrl+V stays with the active app. History shortcuts work while the CopyPaste panel is open.", - "shortcutOpenClose": "CopyPaste global: Open / close CopyPaste", - "shortcutPastePlainDirect": "CopyPaste global: Paste the current clipboard as plain text", - "shortcutSystemPaste": "Active app: Paste the current clipboard normally (CopyPaste does not intercept it)", - "shortcutEscape": "Clear search or close window", - "shortcutTab1": "Switch to Recent tab", - "shortcutTab2": "Switch to Pinned tab", - "shortcutArrows": "Navigate between items", - "shortcutEnter": "CopyPaste open: Paste the hovered, selected, or first history item normally", - "shortcutPasteSelectedPlain": "CopyPaste open: Paste the hovered, selected, or first history item as plain text", - "shortcutDelete": "Delete selected item", - "shortcutPin": "Pin / Unpin selected item", - "shortcutEdit": "Edit card (label and color)", - - "tabGeneral": "General", - "@tabGeneral": { "description": "General nav tab" }, - "tabBackupRestore": "Backup & Support", - "@tabBackupRestore": { "description": "Backup nav tab" }, - "tabAppearance": "Appearance", - "@tabAppearance": { "description": "Appearance nav tab" }, - "tabShortcuts": "Shortcuts", - "@tabShortcuts": { "description": "Shortcuts nav tab" }, - "tabAbout": "About", - "@tabAbout": { "description": "About nav tab" }, - - "sectionLanguage": "LANGUAGE", - "@sectionLanguage": { "description": "Language section title" }, - "sectionStartup": "STARTUP", - "@sectionStartup": { "description": "Startup section title" }, - "sectionKeyboardShortcut": "KEYBOARD SHORTCUT", - "@sectionKeyboardShortcut": { "description": "Keyboard shortcut section title" }, - "sectionCategories": "CATEGORIES", - "@sectionCategories": { "description": "Categories section title" }, - "sectionPerformance": "PERFORMANCE", - "@sectionPerformance": { "description": "Performance section title" }, - "sectionPaste": "PASTE", - "@sectionPaste": { "description": "Paste section title" }, - "sectionBackupRestore": "BACKUP & RESTORE", - "@sectionBackupRestore": { "description": "Backup and restore section title" }, - "sectionAppearance": "APPEARANCE", - "@sectionAppearance": { "description": "Appearance section title" }, - "settingTheme": "Theme", - "@settingTheme": { "description": "Theme selector label" }, - "themeLight": "Light", - "@themeLight": { "description": "Light theme option" }, - "themeDark": "Dark", - "@themeDark": { "description": "Dark theme option" }, - "themeAuto": "Auto", - "@themeAuto": { "description": "Auto theme option" }, - "sectionBehavior": "BEHAVIOR", - "@sectionBehavior": { "description": "Behavior section title" }, - "sectionAbout": "COPYPASTE", - "@sectionAbout": { "description": "About section title" }, - "sectionLinks": "LINKS", - "@sectionLinks": { "description": "Links section title" }, - - "settingItemsPerPage": "Items per page", - "@settingItemsPerPage": { "description": "Items per page label" }, - "settingMemoryLimit": "Memory limit", - "@settingMemoryLimit": { "description": "Memory limit label" }, - "settingScrollThreshold": "Scroll threshold (px)", - "@settingScrollThreshold": { "description": "Scroll threshold label" }, - "settingPasteSpeed": "Paste speed", - "@settingPasteSpeed": { "description": "Paste speed label" }, - "settingPanelWidth": "Panel width (px)", - "@settingPanelWidth": { "description": "Panel width label" }, - "settingPanelHeight": "Panel height (px)", - "@settingPanelHeight": { "description": "Panel height label" }, - "settingLinesCollapsed": "Lines collapsed", - "@settingLinesCollapsed": { "description": "Lines collapsed label" }, - "settingLinesExpanded": "Lines expanded", - "@settingLinesExpanded": { "description": "Lines expanded label" }, - "settingHideOnDeactivate": "Hide on deactivate", - "@settingHideOnDeactivate": { "description": "Hide on deactivate label" }, - "settingRememberWindowPosition": "Remember window position", - "@settingRememberWindowPosition": { "description": "Remember window position toggle label" }, - "settingScrollToTopOnOpen": "Scroll to top on open", - "@settingScrollToTopOnOpen": { "description": "Scroll to top on open label" }, - "settingClearSearchOnOpen": "Clear search on open", - "@settingClearSearchOnOpen": { "description": "Clear search on open label" }, - "settingRetentionDaysLabel": "Retention days (0 = unlimited)", - "@settingRetentionDaysLabel": { "description": "Retention days label" }, - "settingClearHistoryLabel": "Clear clipboard history", - "@settingClearHistoryLabel": { "description": "Clear clipboard history label" }, - "settingHotkeyShortcutLabel": "Shortcut to open/close CopyPaste", - "@settingHotkeyShortcutLabel": { "description": "Hotkey shortcut label" }, - "subtitleGlobalHotkeyWarning": "System-wide shortcut. It may replace the same combination in another application.", - "settingPlainPasteHotkeyLabel": "Optional global plain-text paste", - "subtitlePlainPasteHotkey": "Pastes the current clipboard as plain text without opening CopyPaste. Enabling a global shortcut can override the same shortcut in other apps.", - "shortcutDisabled": "Disabled", - "currentShortcut": "Current: {shortcut}", - "@currentShortcut": { - "placeholders": { "shortcut": { "type": "String" } } - }, - "hotkeyRequiresModifier": "Add at least one modifier key before saving this shortcut.", - "hotkeyConflictWarning": "This combination is already assigned to the other CopyPaste shortcut.", - "restoreRecommendedHotkeys": "Restore recommended shortcuts", - "plainPasteHotkeyRegistrationFailed": "The direct plain-text paste shortcut could not be registered. It may already be in use by the system or another app.", - "pasteDestinationUnavailable": "Paste was cancelled because the original destination could not be restored. Open CopyPaste with its keyboard shortcut and try again.", - "plainPasteItemUnavailable": "The hovered, selected, or first item cannot be pasted as plain text.", - "plainClipboardUnavailable": "There is no text on the clipboard. Copy some text first, then use plain-text paste again.", - "clipboardWriteFailed": "The item could not be placed on the clipboard because another app is holding it. Try again in a moment.", - "pasteTargetElevated": "The destination app runs as administrator, so Windows blocks the simulated paste. Run CopyPaste as administrator too, or press Ctrl+V yourself.", - "hotkeyRegistrationFailed": "The shortcut {shortcut} could not be registered. It may already be in use by the system or another app.", - "@hotkeyRegistrationFailed": { - "placeholders": { "shortcut": { "type": "String" } } - }, - "hotkeyFallbackActive": "The shortcut {requested} was unavailable. CopyPaste is temporarily using {effective}.", - "@hotkeyFallbackActive": { - "placeholders": { - "requested": { "type": "String" }, - "effective": { "type": "String" } - } - }, - - "subtitleStartupDesc": "Launches in background when you sign in", - "@subtitleStartupDesc": { "description": "Startup subtitle" }, - "subtitleHideOnDeactivate": "Close window when clicking outside", - "@subtitleHideOnDeactivate": { "description": "Hide on deactivate subtitle" }, - "subtitleRememberWindowPosition": "Reopen the window where you left it last time", - "@subtitleRememberWindowPosition": { "description": "Remember window position subtitle" }, - "subtitleScrollToTopOnOpen": "Resets scroll and selects latest item", - "@subtitleScrollToTopOnOpen": { "description": "Scroll to top on open subtitle" }, - "subtitleClearSearchOnOpen": "Clears the search text each time", - "@subtitleClearSearchOnOpen": { "description": "Clear search on open subtitle" }, - "subtitlePasteSpeed": "Adjust restoration and paste timings", - "@subtitlePasteSpeed": { "description": "Paste speed subtitle" }, - "subtitleCategories": "Customize the names of color categories.", - "@subtitleCategories": { "description": "Categories subtitle" }, - - "linkGitHub": "Support & Source code \u2014 GitHub", - "@linkGitHub": { "description": "GitHub link label" }, - "linkCoffee": "Buy me a coffee", - "@linkCoffee": { "description": "Buy me a coffee link label" }, - - "editDialogTitle": "Label & Color", - "@editDialogTitle": { "description": "Edit card dialog title" }, - "editDialogHint": "Add a label...", - "@editDialogHint": { "description": "Label input hint in edit dialog" }, - - "historyCleared": "History cleared", - "@historyCleared": { "description": "Snackbar after clearing history" }, - - "backupSavedFile": "Backup saved: {filename}", - "@backupSavedFile": { - "description": "Backup saved snackbar", - "placeholders": { - "filename": { "type": "String" } - } - }, - - "buttonRestore": "Restore", - "@buttonRestore": { "description": "Restore action button" }, - - "restoreCompleted": "Restore completed", - "@restoreCompleted": { "description": "Restore completed snackbar" }, - - "restoreRestartRequired": "Restore completed. The app will restart to apply changes.", - "@restoreRestartRequired": { "description": "Restore requires restart message" }, - - "shortcutExpand": "Expand / collapse card", - "@shortcutExpand": { "description": "Expand collapse shortcut" }, - - "shortcutFocusSearch": "Focus search box", - "@shortcutFocusSearch": { "description": "Focus search shortcut" }, - - "trayShowHide": "Show/Hide", - "@trayShowHide": { "description": "Tray menu show/hide item" }, - - "fileNotFound": "Not found", - "@fileNotFound": { "description": "Badge when file is missing" }, - - "audioFile": "Audio file", - "@audioFile": { "description": "Fallback name for audio items" }, - - "videoFile": "Video file", - "@videoFile": { "description": "Fallback name for video items" }, - - "imageFile": "Image file", - "@imageFile": { "description": "Fallback name / accessibility label for image items" }, - - "timeNow": "now", - "@timeNow": { "description": "Timestamp for less than 1 minute ago" }, - - "clearAllFilters": "Clear all filters", - "@clearAllFilters": { "description": "Filter menu clear action" }, - - "colorSectionLabel": "COLOR", - "@colorSectionLabel": { "description": "Filter menu color section header" }, - - "colorNone": "None", - "@colorNone": { "description": "No color option" }, - - "subtitlePastePreset": "Automatic paste speed. Instant is optimized for Windows; use Safe if a destination app misses a paste.", - "@subtitlePastePreset": { "description": "Paste preset subtitle" }, - "subtitlePastePresetStandard": "Automatic paste speed. Normal/Safe recommended for most computers.", - "@subtitlePastePresetStandard": { "description": "Non-Windows paste preset subtitle" }, - - "pastePresetInstant": "Instant", - "@pastePresetInstant": { "description": "Windows instant paste preset label" }, - "pastePresetFast": "Fast", - "@pastePresetFast": { "description": "Fast paste preset label" }, - "pastePresetNormal": "Normal", - "@pastePresetNormal": { "description": "Normal paste preset label" }, - "pastePresetSafe": "Safe", - "@pastePresetSafe": { "description": "Safe paste preset label" }, - "pastePresetSlow": "Slow", - "@pastePresetSlow": { "description": "Slow paste preset label" }, - "pastePresetCustom": "Custom", - "@pastePresetCustom": { "description": "Custom paste preset placeholder" }, - "pastePresetWarning": "\u26a1 Instant (Windows): lowest latency with native focus verification.\n\u26a0\ufe0f Fast: may cause unexpected behavior in heavy apps.\n\u26a0\ufe0f Slow: may feel sluggish on modern computers.", - "@pastePresetWarning": { "description": "Paste preset warning text" }, - "pastePresetWarningStandard": "\u26a0\ufe0f Fast: may cause unexpected behavior in heavy apps.\n\u26a0\ufe0f Slow: may feel sluggish on modern computers.", - "@pastePresetWarningStandard": { "description": "Non-Windows paste preset warning text" }, - - "settingResetFiltersOnOpen": "Switch to All on open", - "@settingResetFiltersOnOpen": { "description": "Reset filters on open label" }, - "subtitleResetFiltersOnOpen": "Clears category and type filters and returns to the All tab", - "@subtitleResetFiltersOnOpen": { "description": "Reset filters on open subtitle" }, - - "subtitleBackup": "Create a backup of your clipboard history, images, and settings. Restore at any time on this or another device.", - "@subtitleBackup": { "description": "Backup section subtitle" }, - - "aboutDescription": "A modern clipboard manager built to feel native on Windows and macOS.\nLocal-first \u2014 your history, always at hand. No accounts, no telemetry, no subscriptions.", - "@aboutDescription": { "description": "About section description" }, - - "sectionPrivacy": "PRIVACY", - "@sectionPrivacy": { "description": "Privacy section title in About tab" }, - "privacyStatement": "Everything local. Nothing leaves your PC \u2014 no telemetry, no sync, no accounts.", - "@privacyStatement": { "description": "Short privacy philosophy statement shown in About tab" }, - "privacyPolicy": "Privacy Policy", - "@privacyPolicy": { "description": "Link label to open the full privacy policy" }, - - "aboutTagLocal": "Local-only", - "@aboutTagLocal": { "description": "Badge label: everything is stored locally" }, - "aboutTagOpenSource": "Open source", - "@aboutTagOpenSource": { "description": "Badge label: the app is open source" }, - "aboutTagFree": "Free", - "@aboutTagFree": { "description": "Badge label: the app is free" }, - - "sectionOtherTools": "OTHER TOOLS", - "@sectionOtherTools": { "description": "Other tools section title in About tab" }, - "otherToolLinkUnbound": "LinkUnbound", - "@otherToolLinkUnbound": { "description": "LinkUnbound app name" }, - "otherToolLinkUnboundDesc": "Open-source browser selector for Windows and Mac. Same philosophy: no ads, no telemetry, everything local.", - "@otherToolLinkUnboundDesc": { "description": "LinkUnbound app description" }, - - "aboutLicense": "GPL v3 License \u2014 Free and open source.", - "@aboutLicense": { "description": "License footer text" }, - - "permissionsTitle": "Accessibility Permission Required", - "@permissionsTitle": { "description": "Title for the macOS accessibility permissions dialog" }, - - "permissionsMessage": "CopyPaste needs Accessibility permission to paste content into other apps.\n\nGo to System Settings → Privacy & Security → Accessibility and enable CopyPaste.", - "@permissionsMessage": { "description": "Body text explaining why accessibility permission is needed" }, - - "permissionsOpenSettings": "Open Settings", - "@permissionsOpenSettings": { "description": "Button to open macOS System Settings" }, - - "permissionsDismiss": "Later", - "@permissionsDismiss": { "description": "Dismiss button for permissions dialog" }, - - "permissionsGranted": "Permission granted", - "@permissionsGranted": { "description": "Snackbar message when permission is confirmed" }, - - "permissionsResetTitle": "Accessibility Permission Lost", - "@permissionsResetTitle": { "description": "Title shown when permission was previously granted but is no longer recognised (Gatekeeper identity change)" }, - - "permissionsResetMessage": "macOS no longer recognises CopyPaste's permission because the app was re-authorised through Gatekeeper.\n\nTo fix this:\n1. Open Accessibility settings below\n2. Remove CopyPaste from the list (−)\n3. Re-add it or toggle it back on", - "@permissionsResetMessage": { "description": "Instructions for fixing stale TCC entries after Gatekeeper re-authorisation" }, - - "permissionsRestartMessage": "Make sure CopyPaste is enabled in Privacy & Security > Accessibility.\n\nThe app will continue automatically when the permission is detected.", - "@permissionsRestartMessage": { "description": "Shown after polling times out without detecting the permission grant" }, - - "permissionsCheckAgain": "Check Again", - "@permissionsCheckAgain": { "description": "Button to manually re-check accessibility permission" }, - - "permissionsRestartApp": "Restart App", - "@permissionsRestartApp": { "description": "Button to restart the app when permission detection is stuck" }, - - "permissionsWaiting": "Waiting for permission…", - "@permissionsWaiting": { "description": "Label shown while polling for the accessibility permission grant" }, - - "updateBadge": "v{version} is available, please update", - "@updateBadge": { "description": "Short text shown in the footer when an update is available", "placeholders": { "version": { "type": "String" } } }, - - "updateAvailableWindows": "Version {version} is available.\n\nDownload the latest installer from GitHub.", - "@updateAvailableWindows": { "description": "Update dialog message for Windows standalone builds", "placeholders": { "version": { "type": "String" } } }, - - "updateAvailableMac": "Version {version} is available.\n\nUpdate via Homebrew:\nbrew upgrade copypaste\n\nOr download the latest release from GitHub.", - "@updateAvailableMac": { "description": "Update dialog message for macOS", "placeholders": { "version": { "type": "String" } } }, - - "updateAvailableStore": "Version {version} is available.\n\nMicrosoft Store delivers updates automatically. New versions may take a few days to appear after release.", - "@updateAvailableStore": { "description": "Update dialog message for MS Store builds", "placeholders": { "version": { "type": "String" } } }, - - "updateTooltipStore": "Update {version} coming via Microsoft Store", - "@updateTooltipStore": { "description": "Short tooltip for MS Store badge", "placeholders": { "version": { "type": "String" } } }, - - "updateTooltipGeneric": "Update {version} available — click for details", - "@updateTooltipGeneric": { "description": "Short tooltip for non-Store badge", "placeholders": { "version": { "type": "String" } } }, - - "updateDialogTitle": "Update Available", - "@updateDialogTitle": { "description": "Title of the update available dialog" }, - - "updateViewRelease": "View release", - "@updateViewRelease": { "description": "Button to open the GitHub release page" }, - - "updateDismiss": "Later", - "@updateDismiss": { "description": "Button to dismiss the update notification" }, - - "updateBadgeImportant": "v{version} available — important update", - "@updateBadgeImportant": { "description": "Footer badge text for minor/major updates", "placeholders": { "version": { "type": "String" } } }, - - "updateActionDownload": "Download installer", - "@updateActionDownload": { "description": "Action button to open the installer download page" }, - - "updateActionOpenStore": "Open Microsoft Store", - "@updateActionOpenStore": { "description": "Action button to open the MS Store update page" }, - - "updateActionCopyCommand": "Copy {tool} command", - "@updateActionCopyCommand": { - "description": "Action button to copy the package manager upgrade command", - "placeholders": { "tool": { "type": "String" } } - }, - - "updateActionCopied": "Copied to clipboard", - "@updateActionCopied": { "description": "Snack/tooltip shown after copying the upgrade command" }, - - "blockedTitle": "Update required", - "@blockedTitle": { "description": "Title of the blocked-version full-screen gate" }, - - "blockedDescription": "Version {current} of CopyPaste is no longer supported. Please install version {required} or newer to continue using the app.", - "@blockedDescription": { "description": "Body of the blocked-version full-screen gate", "placeholders": { "current": { "type": "String" }, "required": { "type": "String" } } }, - - "blockedReasonGeneric": "This version was retired by the maintainers for safety or compatibility reasons.", - "@blockedReasonGeneric": { "description": "Generic reason shown in the blocked screen when the manifest does not provide one" }, - - "blockedQuit": "Quit CopyPaste", - "@blockedQuit": { "description": "Secondary action on the blocked-version screen" }, - - "blockedFallbackHint": "Visit https://github.com/rgdevment/CopyPaste/releases to download the latest installer.", - "@blockedFallbackHint": { "description": "Hint shown when no channel-specific action is available" }, - - "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", - "placeholders": { - "hotkey": { "type": "String" } - } - }, - - "taskbarOpenHint": "Tip: press {hotkey} to open and paste automatically — no focus lost.", - "@taskbarOpenHint": { - "description": "Hint shown when user opens CopyPaste from the taskbar in taskbar mode", - "placeholders": { - "hotkey": { "type": "String" } - } - }, - - "balloonStartupBody": "Running in the background. Press {hotkey} or click the tray icon.", - "@balloonStartupBody": { - "description": "Windows balloon shown at startup when window starts hidden", - "placeholders": { - "hotkey": { "type": "String" } - } - }, - - "balloonWakeupTitle": "CopyPaste is already open", - "@balloonWakeupTitle": { "description": "Windows balloon title when a second instance is launched" }, - - "balloonWakeupBody": "Press {hotkey} or click the tray icon to bring it up.", - "@balloonWakeupBody": { - "description": "Windows balloon body when a second instance is launched", - "placeholders": { - "hotkey": { "type": "String" } - } - }, - - "onboardingTitle": "Welcome to CopyPaste", - "@onboardingTitle": { "description": "Onboarding screen title" }, - - "onboardingSubtitle": "Everything you copy, saved.", - "@onboardingSubtitle": { "description": "Onboarding screen subtitle" }, - - "onboardingPrivacyBadge": "No cloud · No tracking · 100% local", - "@onboardingPrivacyBadge": { "description": "Onboarding privacy badge chip" }, - - "onboardingDescription": "Runs silently in the background. Press {hotkey} anytime to open your clipboard history.", - "@onboardingDescription": { - "description": "Onboarding main description", - "placeholders": { "hotkey": { "type": "String" } } - }, - - "onboardingTrayHint": "Look for the CP icon next to your clock.", - "@onboardingTrayHint": { "description": "Onboarding tray location hint" }, - - "onboardingSettingsButton": "Settings", - "@onboardingSettingsButton": { "description": "Onboarding settings button" }, - - "onboardingDismissButton": "Get started", - "@onboardingDismissButton": { "description": "Onboarding dismiss button" }, - - "tabCapture": "Performance", - "@tabCapture": { "description": "Performance tab label (paste, perf, multimedia)" }, - - "tabMultimedia": "Multimedia", - "@tabMultimedia": { "description": "Multimedia tab label (legacy, unused since tabs were merged)" }, - - "tabCleanupPrivacy": "Cleanup & Privacy", - "@tabCleanupPrivacy": { "description": "Cleanup & privacy tab label" }, - - "sectionMultimedia": "MULTIMEDIA & THUMBNAILS", - "@sectionMultimedia": { "description": "Multimedia section header" }, - - "subtitleMultimedia": "Control how images, videos and audio files are previewed.", - "@subtitleMultimedia": { "description": "Multimedia section subtitle" }, - - "settingGenerateImageThumbnails": "Generate image thumbnails", - "@settingGenerateImageThumbnails": { "description": "Image thumbs toggle" }, - - "subtitleGenerateImageThumbnails": "Show preview tiles for copied or referenced images.", - "@subtitleGenerateImageThumbnails": { "description": "Image thumbs subtitle" }, - - "settingGenerateVideoThumbnails": "Generate video thumbnails", - "@settingGenerateVideoThumbnails": { "description": "Video thumbs toggle" }, - - "subtitleGenerateVideoThumbnails": "Use the OS shell cache to show a preview frame for video files.", - "@subtitleGenerateVideoThumbnails": { "description": "Video thumbs subtitle" }, - - "settingGenerateAudioThumbnails": "Generate audio thumbnails", - "@settingGenerateAudioThumbnails": { "description": "Audio thumbs toggle" }, - - "subtitleGenerateAudioThumbnails": "Show cover art when available for audio files.", - "@subtitleGenerateAudioThumbnails": { "description": "Audio thumbs subtitle" }, - - "settingMaxImageSize": "Max image size for processing (MB)", - "@settingMaxImageSize": { "description": "Max image size label" }, - - "subtitleMaxImageSize": "Larger images keep their original bitmap fallback and are not re-encoded.", - "@subtitleMaxImageSize": { "description": "Max image size subtitle" }, - - "sectionCleanupPrivacy": "CLEANUP & PRIVACY", - "@sectionCleanupPrivacy": { "description": "Cleanup & privacy section header" }, - - "settingKeepBrokenItemsLabel": "Keep unavailable items (days)", - "@settingKeepBrokenItemsLabel": { "description": "Days to keep broken external refs" }, - - "subtitleKeepBrokenItems": "Items that point to a missing file or unmounted volume are pruned after this many days. 0 prunes immediately.", - "@subtitleKeepBrokenItems": { "description": "Broken-items subtitle" }, - - "settingImagesQuotaLabel": "Storage cap for images", - "@settingImagesQuotaLabel": { "description": "Quota label" }, - - "subtitleImagesQuota": "When the images folder exceeds this size, oldest unpinned items are deleted to free space.", - "@subtitleImagesQuota": { "description": "Quota subtitle" }, - - "imagesQuotaOff": "Unlimited", - "@imagesQuotaOff": { "description": "Quota disabled label" } -} diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb deleted file mode 100644 index ed5fa789..00000000 --- a/app/lib/l10n/app_es.arb +++ /dev/null @@ -1,315 +0,0 @@ -{ - "@@locale": "es", - - "searchPlaceholder": "Buscar en portapapeles\u2026", - "emptyState": "No hay elementos en esta sección", - - "emptyStateSubtitle": "Copia algo para comenzar", - - "hintBannerText": "CopyPaste se ejecuta en segundo plano — encuéntralo en la bandeja del sistema o usa tu atajo de teclado. Personaliza tu experiencia en", - "hintBannerAction": "Ajustes", - - "settingsTitle": "Configuración", - "sectionShortcuts": "ATAJOS DE TECLADO", - - "sectionStorage": "ALMACENAMIENTO", - - "settingRunOnStartup": "Iniciar con el sistema", - "settingLanguage": "Idioma de la interfaz", - "hotkeyWillApply": "El atajo se aplicará de inmediato", - - "clearHistoryConfirmTitle": "¿Limpiar historial?", - "clearHistoryConfirmMessage": "Esto eliminará permanentemente todos los elementos no anclados. Esta acción no se puede deshacer.", - "clearHistoryConfirmButton": "Limpiar", - - "backupLastDate": "Último respaldo: {date}", - "backupNone": "Aún no se ha creado un respaldo.", - "backupCreateLabel": "Crear respaldo", - "backupRestoreLabel": "Restaurar respaldo", - "backupError": "Error al crear el respaldo. Verifica los permisos.", - - "restoreDialogTitle": "Restaurar respaldo", - "restoreDialogWarning": "Esto reemplazará todos los datos actuales con el contenido del respaldo. ¿Continuar?", - "restoreFileNotFound": "Archivo no encontrado.", - "restoreSuccess": "Se restauraron {count} elementos.", - "restoreError": "Error al restaurar. Tus datos anteriores se han preservado.", - - "sectionSupport": "SOPORTE", - "supportExportLogs": "Exportar registros", - "supportExportLogsSubtitle": "Guarda un zip con registros de la app para adjuntar a un reporte. El contenido del portapapeles nunca se incluye.", - "supportOpenLogsFolder": "Abrir carpeta de registros", - "supportOpenLogsFolderSubtitle": "Explora los archivos de registro en tu gestor de archivos.", - "supportGitHub": "Reportar un error en GitHub", - "supportExportSuccess": "Registros guardados en Descargas.", - "supportShowInFiles": "Mostrar", - "supportExportEmpty": "No se encontraron archivos de registro.", - "supportExportError": "Error al exportar los registros.", - - "sectionReset": "RESTABLECER E INSTALACIÓN LIMPIA", - "resetSoftLabel": "Restablecimiento suave", - "resetSoftSubtitle": "Restablece la configuración a los valores predeterminados y marca la app como nueva instalación. El historial del portapapeles se conserva.", - "resetHardLabel": "Restablecimiento completo", - "resetHardSubtitle": "Elimina todo el historial, imágenes y configuración. Esta acción no se puede deshacer.", - "resetSoftConfirmTitle": "¿Restablecimiento suave?", - "resetSoftConfirmMessage": "Toda la configuración volverá a los valores predeterminados y la app se reiniciará como si fuera una instalación nueva. El historial del portapapeles no se eliminará.", - "resetHardConfirmTitle": "¿Restablecimiento completo?", - "resetHardConfirmMessage": "Se eliminará permanentemente todo el historial, imágenes y configuración, y luego la app se reiniciará. Esta acción no se puede deshacer.", - "resetConfirmButton": "Restablecer y Reiniciar", - - "buttonSave": "Guardar", - "buttonClose": "Cerrar", - "buttonCancel": "Cancelar", - "buttonReset": "Restaurar predeterminados", - "savingIndicator": "Guardando\u2026", - "savedIndicator": "Guardado", - - "menuPaste": "Pegar", - "menuPastePlain": "Pegar sin formato", - "menuCopy": "Copiar", - "copiedToClipboard": "Copiado al portapapeles", - "menuPin": "Anclar", - "menuUnpin": "Desanclar", - "menuEdit": "Editar tarjeta", - "menuDelete": "Eliminar", - - "editColorLabel": "Color", - - "colorRed": "Rojo", - "colorGreen": "Verde", - "colorPurple": "Morado", - "colorYellow": "Amarillo", - "colorBlue": "Azul", - "colorOrange": "Naranja", - - "typeText": "Texto", - "typeImage": "Imagen", - "typeFile": "Archivo", - "typeFolder": "Carpeta", - "typeLink": "Enlace", - "typeAudio": "Audio", - "typeVideo": "Video", - "typeEmail": "Email", - "typePhone": "Teléfono", - "typeColor": "Color", - "typeIp": "IP", - "typeUuid": "UUID", - "typeJson": "JSON", - "filterAll": "Todo", - "filterPinned": "Anclados", - - "trayTooltip": "CopyPaste", - "trayExit": "Salir", - - "subtitleShortcutScopes": "Ctrl+V pertenece a la aplicaci\u00f3n activa. Los atajos del historial funcionan mientras el panel de CopyPaste est\u00e1 abierto.", - "shortcutOpenClose": "Global de CopyPaste: Abrir / cerrar CopyPaste", - "shortcutPastePlainDirect": "Global de CopyPaste: Pegar el portapapeles actual como texto plano", - "shortcutSystemPaste": "Aplicaci\u00f3n activa: Pegar normalmente el portapapeles actual (CopyPaste no lo intercepta)", - "shortcutEscape": "Limpiar búsqueda o cerrar ventana", - "shortcutTab1": "Cambiar a pestaña Recientes", - "shortcutTab2": "Cambiar a pestaña Anclados", - "shortcutArrows": "Navegar entre elementos", - "shortcutEnter": "Con CopyPaste abierto: Pegar normalmente el elemento bajo el cursor, el seleccionado o el primero", - "shortcutPasteSelectedPlain": "Con CopyPaste abierto: Pegar como texto plano el elemento bajo el cursor, el seleccionado o el primero", - "shortcutDelete": "Eliminar elemento seleccionado", - "shortcutPin": "Anclar / Desanclar elemento", - "shortcutEdit": "Editar tarjeta (etiqueta y color)", - - "tabGeneral": "General", - "tabBackupRestore": "Backup y soporte", - "tabAppearance": "Apariencia", - "tabShortcuts": "Atajos", - "tabAbout": "Acerca de", - - "sectionLanguage": "IDIOMA", - "sectionStartup": "INICIO", - "sectionKeyboardShortcut": "ATAJO DE TECLADO", - "sectionCategories": "CATEGOR\u00cdAS", - "sectionPerformance": "RENDIMIENTO", - "sectionPaste": "PEGADO", - "sectionBackupRestore": "RESPALDO Y RESTAURACI\u00d3N", - "sectionAppearance": "APARIENCIA", - "settingTheme": "Tema", - "themeLight": "Claro", - "themeDark": "Oscuro", - "themeAuto": "Auto", - "sectionBehavior": "COMPORTAMIENTO", - "sectionAbout": "COPYPASTE", - "sectionLinks": "ENLACES", - - "settingItemsPerPage": "Elementos por p\u00e1gina", - "settingMemoryLimit": "L\u00edmite de memoria", - "settingScrollThreshold": "Umbral de desplazamiento (px)", - "settingPasteSpeed": "Velocidad de pegado", - "settingPanelWidth": "Ancho del panel (px)", - "settingPanelHeight": "Alto del panel (px)", - "settingLinesCollapsed": "L\u00edneas contra\u00eddas", - "settingLinesExpanded": "L\u00edneas expandidas", - "settingHideOnDeactivate": "Ocultar al hacer clic fuera", - "settingRememberWindowPosition": "Recordar posición de la ventana", - "settingScrollToTopOnOpen": "Ir al inicio al abrir", - "settingClearSearchOnOpen": "Limpiar b\u00fasqueda al abrir", - "settingRetentionDaysLabel": "D\u00edas de retenci\u00f3n (0 = sin l\u00edmite)", - "settingClearHistoryLabel": "Limpiar historial del portapapeles", - "settingHotkeyShortcutLabel": "Atajo para abrir/cerrar CopyPaste", - "subtitleGlobalHotkeyWarning": "Atajo global del sistema. Puede reemplazar la misma combinación en otra aplicación.", - "settingPlainPasteHotkeyLabel": "Pegado global opcional como texto plano", - "subtitlePlainPasteHotkey": "Pega el portapapeles actual como texto plano sin abrir CopyPaste. Activar un atajo global puede reemplazar el mismo atajo en otras aplicaciones.", - "shortcutDisabled": "Desactivado", - "currentShortcut": "Actual: {shortcut}", - "@currentShortcut": { - "placeholders": { "shortcut": { "type": "String" } } - }, - "hotkeyRequiresModifier": "Agrega al menos una tecla modificadora antes de guardar este atajo.", - "hotkeyConflictWarning": "Esta combinación ya está asignada al otro atajo de CopyPaste.", - "restoreRecommendedHotkeys": "Restaurar atajos recomendados", - "plainPasteHotkeyRegistrationFailed": "No se pudo registrar el atajo de pegado directo como texto plano. Es posible que el sistema u otra aplicación ya lo esté usando.", - "pasteDestinationUnavailable": "Se canceló el pegado porque no se pudo restaurar el destino original. Abre CopyPaste con su atajo de teclado e inténtalo nuevamente.", - "plainPasteItemUnavailable": "El elemento bajo el cursor, el seleccionado o el primero no se puede pegar como texto plano.", - "plainClipboardUnavailable": "No hay texto en el portapapeles. Copia primero un texto y vuelve a usar el pegado como texto plano.", - "clipboardWriteFailed": "No se pudo copiar el elemento al portapapeles porque otra aplicación lo tiene retenido. Vuelve a intentarlo en un momento.", - "pasteTargetElevated": "La aplicación de destino se ejecuta como administrador, así que Windows bloquea el pegado simulado. Ejecuta CopyPaste también como administrador o pulsa Ctrl+V tú mismo.", - "hotkeyRegistrationFailed": "No se pudo registrar el atajo {shortcut}. Es posible que el sistema u otra aplicación ya lo esté usando.", - "@hotkeyRegistrationFailed": { - "placeholders": { "shortcut": { "type": "String" } } - }, - "hotkeyFallbackActive": "El atajo {requested} no estaba disponible. CopyPaste está usando temporalmente {effective}.", - "@hotkeyFallbackActive": { - "placeholders": { - "requested": { "type": "String" }, - "effective": { "type": "String" } - } - }, - - "subtitleStartupDesc": "Se inicia en segundo plano al iniciar sesi\u00f3n", - "subtitleHideOnDeactivate": "Cerrar la ventana al hacer clic fuera", - "subtitleRememberWindowPosition": "Reabrir la ventana donde la dejaste la última vez", - "subtitleScrollToTopOnOpen": "Restablece el desplazamiento y selecciona el \u00faltimo elemento", - "subtitleClearSearchOnOpen": "Borra el texto de b\u00fasqueda cada vez", - "subtitlePasteSpeed": "Ajustar tiempos de restauraci\u00f3n y pegado", - "subtitleCategories": "Personaliza los nombres de las categor\u00edas de color.", - - "linkGitHub": "Soporte y C\u00f3digo fuente \u2014 GitHub", - "linkCoffee": "Inv\u00edtame un caf\u00e9", - - "editDialogTitle": "Etiqueta y Color", - "editDialogHint": "Agregar una etiqueta...", - - "historyCleared": "Historial limpiado", - - "backupSavedFile": "Respaldo guardado: {filename}", - - "buttonRestore": "Restaurar", - - "restoreCompleted": "Restauraci\u00f3n completada", - - "restoreRestartRequired": "Restauraci\u00f3n completada. La app se reiniciar\u00e1 para aplicar los cambios.", - - "shortcutExpand": "Expandir / contraer tarjeta", - - "shortcutFocusSearch": "Enfocar el buscador", - - "trayShowHide": "Mostrar/Ocultar", - - "fileNotFound": "No encontrado", - "audioFile": "Archivo de audio", - "videoFile": "Archivo de video", - "imageFile": "Archivo de imagen", - "timeNow": "ahora", - "clearAllFilters": "Limpiar todos los filtros", - "colorSectionLabel": "COLOR", - "colorNone": "Ninguno", - "subtitlePastePreset": "Velocidad de pegado autom\u00e1tico. Instant\u00e1neo est\u00e1 optimizado para Windows; usa Seguro si alguna aplicaci\u00f3n no recibe el pegado.", - "subtitlePastePresetStandard": "Velocidad de pegado autom\u00e1tico. Normal/Seguro recomendado para la mayor\u00eda.", - "pastePresetInstant": "Instant\u00e1neo", - "pastePresetFast": "R\u00e1pido", - "pastePresetNormal": "Normal", - "pastePresetSafe": "Seguro", - "pastePresetSlow": "Lento", - "pastePresetCustom": "Personalizado", - "pastePresetWarning": "\u26a1 Instant\u00e1neo (Windows): latencia m\u00ednima con verificaci\u00f3n nativa del foco.\n\u26a0\ufe0f R\u00e1pido: puede causar comportamientos extra\u00f1os en apps pesadas.\n\u26a0\ufe0f Lento: puede sentirse pesado en equipos modernos.", - "pastePresetWarningStandard": "\u26a0\ufe0f R\u00e1pido: puede causar comportamientos extra\u00f1os en apps pesadas.\n\u26a0\ufe0f Lento: puede sentirse pesado en equipos modernos.", - "settingResetFiltersOnOpen": "Volver a Todos al abrir", - "subtitleResetFiltersOnOpen": "Limpia los filtros de categor\u00eda y tipo, y vuelve a la pesta\u00f1a Todos", - "subtitleBackup": "Crea un respaldo de tu historial, im\u00e1genes y configuraci\u00f3n. Restaura en cualquier momento en este u otro dispositivo.", - "aboutDescription": "Un gestor de portapapeles moderno, nativo en Windows y macOS.\nTodo local \u2014 tu historial, siempre a mano. Sin cuentas, sin telemetr\u00eda, sin suscripciones.", - "sectionPrivacy": "PRIVACIDAD", - "privacyStatement": "Todo local. Nada sale de tu PC \u2014 sin telemetr\u00eda, sin sincronizaci\u00f3n, sin cuentas.", - "privacyPolicy": "Pol\u00edtica de privacidad", - "aboutTagLocal": "Todo local", - "aboutTagOpenSource": "C\u00f3digo abierto", - "aboutTagFree": "Gratis", - - "sectionOtherTools": "OTRAS HERRAMIENTAS", - "otherToolLinkUnbound": "LinkUnbound", - "otherToolLinkUnboundDesc": "Selector de navegadores de c\u00f3digo abierto para Windows y Mac. Misma filosof\u00eda: sin anuncios, sin telemetr\u00eda, todo local.", - - "aboutLicense": "Licencia GPL v3 \u2014 Libre y de c\u00f3digo abierto.", - "permissionsTitle": "Permiso de Accesibilidad requerido", - "permissionsMessage": "CopyPaste necesita permiso de Accesibilidad para pegar contenido en otras apps.\n\nVe a Configuraci\u00f3n del Sistema \u2192 Privacidad y Seguridad \u2192 Accesibilidad y activa CopyPaste.", - "permissionsOpenSettings": "Abrir Configuraci\u00f3n", - "permissionsDismiss": "Despu\u00e9s", - "permissionsGranted": "Permiso concedido", - "permissionsResetTitle": "Permiso de Accesibilidad perdido", - "permissionsResetMessage": "macOS ya no reconoce el permiso de CopyPaste porque la app fue re-autorizada a trav\u00e9s de Gatekeeper.\n\nPara solucionarlo:\n1. Abre la configuraci\u00f3n de Accesibilidad\n2. Elimina CopyPaste de la lista (\u2212)\n3. Vuelve a a\u00f1adirlo o act\u00edvalo de nuevo", - "permissionsRestartMessage": "Aseg\u00farate de que CopyPaste est\u00e9 activado en Privacidad y seguridad > Accesibilidad.\n\nLa app continuar\u00e1 autom\u00e1ticamente cuando detecte el permiso.", - "permissionsCheckAgain": "Verificar", - "permissionsRestartApp": "Reiniciar app", - "permissionsWaiting": "Esperando permiso\u2026", - - "updateBadge": "v{version} disponible, por favor actualiza", - "updateAvailableWindows": "La versi\u00f3n {version} est\u00e1 disponible.\n\nDescarga el instalador m\u00e1s reciente desde GitHub.", - "updateAvailableMac": "La versi\u00f3n {version} est\u00e1 disponible.\n\nActualiza con Homebrew:\nbrew upgrade copypaste\n\nO descarga la \u00faltima versi\u00f3n desde GitHub.", - "updateAvailableStore": "La versi\u00f3n {version} est\u00e1 disponible.\n\nLa Microsoft Store entrega las actualizaciones autom\u00e1ticamente. Las nuevas versiones pueden tardar unos d\u00edas en aparecer tras su publicaci\u00f3n.", - "updateTooltipStore": "Actualizaci\u00f3n {version} en camino por Microsoft Store", - "updateTooltipGeneric": "Actualizaci\u00f3n {version} disponible \u2014 haz clic para detalles", - "updateDialogTitle": "Actualizaci\u00f3n disponible", - "updateViewRelease": "Ver versi\u00f3n", - "updateDismiss": "Despu\u00e9s", - - "updateBadgeImportant": "v{version} disponible — actualizaci\u00f3n importante", - "updateActionDownload": "Descargar instalador", - "updateActionOpenStore": "Abrir Microsoft Store", - "updateActionCopyCommand": "Copiar comando {tool}", - "updateActionCopied": "Copiado al portapapeles", - - "blockedTitle": "Actualizaci\u00f3n requerida", - "blockedDescription": "La versi\u00f3n {current} de CopyPaste ya no est\u00e1 soportada. Instala la versi\u00f3n {required} o m\u00e1s reciente para continuar.", - "blockedReasonGeneric": "Esta versi\u00f3n fue retirada por motivos de seguridad o compatibilidad.", - "blockedQuit": "Salir de CopyPaste", - "blockedFallbackHint": "Visita https://github.com/rgdevment/CopyPaste/releases para descargar el instalador m\u00e1s reciente.", - - "wakeupHint": "CopyPaste se ejecuta en segundo plano \u2014 presiona {hotkey} o haz clic en el \u00edcono de la bandeja para abrirlo cuando quieras.", - - "taskbarOpenHint": "Tip: presiona {hotkey} para abrir y pegar autom\u00e1ticamente, sin perder el foco.", - - "balloonStartupBody": "Ejecut\u00e1ndose en segundo plano. Presiona {hotkey} o haz clic en el \u00edcono de la bandeja.", - "balloonWakeupTitle": "CopyPaste ya est\u00e1 abierto", - "balloonWakeupBody": "Presiona {hotkey} o haz clic en el \u00edcono de la bandeja para abrirlo.", - - "onboardingTitle": "Bienvenido a CopyPaste", - "onboardingSubtitle": "Todo lo que copias, guardado.", - "onboardingPrivacyBadge": "Sin nube \u00b7 Sin rastreo \u00b7 100% local", - "onboardingDescription": "Corre en segundo plano sin que lo notes. Presiona {hotkey} cuando quieras para abrir tu historial.", - "onboardingTrayHint": "Encu\u00e9ntralo junto al reloj, abajo a la derecha.", - "onboardingSettingsButton": "Configuraci\u00f3n", - "onboardingDismissButton": "Empezar", - "tabCapture": "Rendimiento", - "tabMultimedia": "Multimedia", - "tabCleanupPrivacy": "Limpieza y privacidad", - "sectionMultimedia": "MULTIMEDIA Y MINIATURAS", - "subtitleMultimedia": "Controla c\u00f3mo se previsualizan im\u00e1genes, v\u00eddeos y archivos de audio.", - "settingGenerateImageThumbnails": "Generar miniaturas de im\u00e1genes", - "subtitleGenerateImageThumbnails": "Muestra una vista previa de las im\u00e1genes copiadas o referenciadas.", - "settingGenerateVideoThumbnails": "Generar miniaturas de v\u00eddeos", - "subtitleGenerateVideoThumbnails": "Usa la cach\u00e9 del sistema para mostrar un fotograma de los v\u00eddeos.", - "settingGenerateAudioThumbnails": "Generar miniaturas de audio", - "subtitleGenerateAudioThumbnails": "Muestra la car\u00e1tula cuando est\u00e9 disponible.", - "settingMaxImageSize": "Tama\u00f1o m\u00e1ximo a procesar (MB)", - "subtitleMaxImageSize": "Las im\u00e1genes m\u00e1s grandes mantienen su mapa de bits original sin reprocesarse.", - "sectionCleanupPrivacy": "LIMPIEZA Y PRIVACIDAD", - "settingKeepBrokenItemsLabel": "Conservar elementos no disponibles (d\u00edas)", - "subtitleKeepBrokenItems": "Los elementos que apuntan a archivos perdidos o vol\u00famenes desconectados se eliminan tras estos d\u00edas. 0 los elimina al instante.", - "settingImagesQuotaLabel": "L\u00edmite de almacenamiento para im\u00e1genes", - "subtitleImagesQuota": "Cuando la carpeta de im\u00e1genes supera este tama\u00f1o, se eliminan los elementos m\u00e1s antiguos no fijados para liberar espacio.", - "imagesQuotaOff": "Sin l\u00edmite" -} diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart deleted file mode 100644 index 611ddda4..00000000 --- a/app/lib/l10n/app_localizations.dart +++ /dev/null @@ -1,1682 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:intl/intl.dart' as intl; - -import 'app_localizations_en.dart'; -import 'app_localizations_es.dart'; - -// ignore_for_file: type=lint - -/// Callers can lookup localized strings with an instance of AppLocalizations -/// returned by `AppLocalizations.of(context)`. -/// -/// Applications need to include `AppLocalizations.delegate()` in their app's -/// `localizationDelegates` list, and the locales they support in the app's -/// `supportedLocales` list. For example: -/// -/// ```dart -/// import 'l10n/app_localizations.dart'; -/// -/// return MaterialApp( -/// localizationsDelegates: AppLocalizations.localizationsDelegates, -/// supportedLocales: AppLocalizations.supportedLocales, -/// home: MyApplicationHome(), -/// ); -/// ``` -/// -/// ## Update pubspec.yaml -/// -/// Please make sure to update your pubspec.yaml to include the following -/// packages: -/// -/// ```yaml -/// dependencies: -/// # Internationalization support. -/// flutter_localizations: -/// sdk: flutter -/// intl: any # Use the pinned version from flutter_localizations -/// -/// # Rest of dependencies -/// ``` -/// -/// ## iOS Applications -/// -/// iOS applications define key application metadata, including supported -/// locales, in an Info.plist file that is built into the application bundle. -/// To configure the locales supported by your app, you’ll need to edit this -/// file. -/// -/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. -/// Then, in the Project Navigator, open the Info.plist file under the Runner -/// project’s Runner folder. -/// -/// Next, select the Information Property List item, select Add Item from the -/// Editor menu, then select Localizations from the pop-up menu. -/// -/// Select and expand the newly-created Localizations item then, for each -/// locale your application supports, add a new item and select the locale -/// you wish to add from the pop-up menu in the Value field. This list should -/// be consistent with the languages listed in the AppLocalizations.supportedLocales -/// property. -abstract class AppLocalizations { - AppLocalizations(String locale) - : localeName = intl.Intl.canonicalizedLocale(locale.toString()); - - final String localeName; - - static AppLocalizations of(BuildContext context) { - return Localizations.of(context, AppLocalizations)!; - } - - static const LocalizationsDelegate delegate = - _AppLocalizationsDelegate(); - - /// A list of this localizations delegate along with the default localizations - /// delegates. - /// - /// Returns a list of localizations delegates containing this delegate along with - /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, - /// and GlobalWidgetsLocalizations.delegate. - /// - /// Additional delegates can be added by appending to this list in - /// MaterialApp. This list does not have to be used at all if a custom list - /// of delegates is preferred or required. - static const List> localizationsDelegates = - >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; - - /// A list of this localizations delegate's supported locales. - static const List supportedLocales = [ - Locale('en'), - Locale('es'), - ]; - - /// Search box placeholder - /// - /// In en, this message translates to: - /// **'Search clipboard…'** - String get searchPlaceholder; - - /// Empty list message - /// - /// In en, this message translates to: - /// **'No items in this section'** - String get emptyState; - - /// Empty state subtitle - /// - /// In en, this message translates to: - /// **'Copy something to get started'** - String get emptyStateSubtitle; - - /// First-run hint banner text - /// - /// In en, this message translates to: - /// **'CopyPaste is active and running in the background. Look for it in the system tray or just use your shortcut. Feel free to customize your experience in'** - String get hintBannerText; - - /// First-run hint banner action - /// - /// In en, this message translates to: - /// **'Settings'** - String get hintBannerAction; - - /// Settings screen title - /// - /// In en, this message translates to: - /// **'Settings'** - String get settingsTitle; - - /// Shortcuts section header - /// - /// In en, this message translates to: - /// **'KEYBOARD SHORTCUTS'** - String get sectionShortcuts; - - /// Storage section header - /// - /// In en, this message translates to: - /// **'STORAGE'** - String get sectionStorage; - - /// Run on startup toggle label - /// - /// In en, this message translates to: - /// **'Run on startup'** - String get settingRunOnStartup; - - /// Language picker label - /// - /// In en, this message translates to: - /// **'Interface language'** - String get settingLanguage; - - /// Hint when hotkey changes - /// - /// In en, this message translates to: - /// **'Hotkey will apply immediately'** - String get hotkeyWillApply; - - /// Support section header in About tab - /// - /// In en, this message translates to: - /// **'SUPPORT'** - String get sectionSupport; - - /// Export logs action label - /// - /// In en, this message translates to: - /// **'Export logs'** - String get supportExportLogs; - - /// Export logs subtitle - /// - /// In en, this message translates to: - /// **'Save a zip with app logs for a bug report. Your clipboard content is never included.'** - String get supportExportLogsSubtitle; - - /// Open logs folder label - /// - /// In en, this message translates to: - /// **'Open logs folder'** - String get supportOpenLogsFolder; - - /// Open logs folder subtitle - /// - /// In en, this message translates to: - /// **'Browse the raw log files in your file manager.'** - String get supportOpenLogsFolderSubtitle; - - /// GitHub issue link label - /// - /// In en, this message translates to: - /// **'Report a bug on GitHub'** - String get supportGitHub; - - /// Snackbar after successful log export - /// - /// In en, this message translates to: - /// **'Logs saved to Downloads.'** - String get supportExportSuccess; - - /// Snackbar action to reveal the exported file in Finder/Explorer - /// - /// In en, this message translates to: - /// **'Show'** - String get supportShowInFiles; - - /// Snackbar when no logs exist - /// - /// In en, this message translates to: - /// **'No log files found.'** - String get supportExportEmpty; - - /// Snackbar on export error - /// - /// In en, this message translates to: - /// **'Failed to export logs.'** - String get supportExportError; - - /// Reset section header in About tab - /// - /// In en, this message translates to: - /// **'RESET & CLEAN INSTALL'** - String get sectionReset; - - /// Soft reset action label - /// - /// In en, this message translates to: - /// **'Soft Reset'** - String get resetSoftLabel; - - /// Soft reset subtitle - /// - /// In en, this message translates to: - /// **'Resets all settings to defaults and marks app as fresh install. Clipboard history is preserved.'** - String get resetSoftSubtitle; - - /// Hard reset action label - /// - /// In en, this message translates to: - /// **'Hard Reset'** - String get resetHardLabel; - - /// Hard reset subtitle - /// - /// In en, this message translates to: - /// **'Deletes all clipboard history, images, and settings. This cannot be undone.'** - String get resetHardSubtitle; - - /// Soft reset confirm dialog title - /// - /// In en, this message translates to: - /// **'Soft reset?'** - String get resetSoftConfirmTitle; - - /// Soft reset confirm dialog message - /// - /// In en, this message translates to: - /// **'All settings will return to defaults and the app will restart as if freshly installed. Your clipboard history will not be deleted.'** - String get resetSoftConfirmMessage; - - /// Hard reset confirm dialog title - /// - /// In en, this message translates to: - /// **'Hard reset?'** - String get resetHardConfirmTitle; - - /// Hard reset confirm dialog message - /// - /// In en, this message translates to: - /// **'This will permanently delete all clipboard history, images, and settings, then restart the app. This cannot be undone.'** - String get resetHardConfirmMessage; - - /// Reset confirm button label - /// - /// In en, this message translates to: - /// **'Reset & Restart'** - String get resetConfirmButton; - - /// Clear history dialog title - /// - /// In en, this message translates to: - /// **'Clear history?'** - String get clearHistoryConfirmTitle; - - /// Clear history dialog message - /// - /// In en, this message translates to: - /// **'This will permanently delete all non-pinned clipboard items. This action cannot be undone.'** - String get clearHistoryConfirmMessage; - - /// Clear history confirm button - /// - /// In en, this message translates to: - /// **'Clear'** - String get clearHistoryConfirmButton; - - /// Last backup date - /// - /// In en, this message translates to: - /// **'Last backup: {date}'** - String backupLastDate(String date); - - /// No backup yet message - /// - /// In en, this message translates to: - /// **'No backup created yet.'** - String get backupNone; - - /// Create backup label - /// - /// In en, this message translates to: - /// **'Create backup'** - String get backupCreateLabel; - - /// Restore backup label - /// - /// In en, this message translates to: - /// **'Restore backup'** - String get backupRestoreLabel; - - /// Backup error message - /// - /// In en, this message translates to: - /// **'Failed to create backup. Check permissions.'** - String get backupError; - - /// Restore dialog title - /// - /// In en, this message translates to: - /// **'Restore backup'** - String get restoreDialogTitle; - - /// Restore confirmation warning - /// - /// In en, this message translates to: - /// **'This will replace all current data with the backup contents. Continue?'** - String get restoreDialogWarning; - - /// File not found error - /// - /// In en, this message translates to: - /// **'File not found.'** - String get restoreFileNotFound; - - /// Restore success message - /// - /// In en, this message translates to: - /// **'Restored {count} items.'** - String restoreSuccess(int count); - - /// Restore error message - /// - /// In en, this message translates to: - /// **'Restore failed. Your previous data has been preserved.'** - String get restoreError; - - /// Save button - /// - /// In en, this message translates to: - /// **'Save'** - String get buttonSave; - - /// Generic Close button - /// - /// In en, this message translates to: - /// **'Close'** - String get buttonClose; - - /// Cancel button - /// - /// In en, this message translates to: - /// **'Cancel'** - String get buttonCancel; - - /// Reset button - /// - /// In en, this message translates to: - /// **'Restore defaults'** - String get buttonReset; - - /// Footer indicator while autosave is in flight - /// - /// In en, this message translates to: - /// **'Saving…'** - String get savingIndicator; - - /// Footer indicator after autosave completes - /// - /// In en, this message translates to: - /// **'Saved'** - String get savedIndicator; - - /// Context menu paste - /// - /// In en, this message translates to: - /// **'Paste'** - String get menuPaste; - - /// Context menu paste plain - /// - /// In en, this message translates to: - /// **'Paste plain'** - String get menuPastePlain; - - /// Context menu copy to clipboard without pasting - /// - /// In en, this message translates to: - /// **'Copy'** - String get menuCopy; - - /// Snackbar shown after the copy action - /// - /// In en, this message translates to: - /// **'Copied to clipboard'** - String get copiedToClipboard; - - /// Context menu pin - /// - /// In en, this message translates to: - /// **'Pin'** - String get menuPin; - - /// Context menu unpin - /// - /// In en, this message translates to: - /// **'Unpin'** - String get menuUnpin; - - /// Context menu edit - /// - /// In en, this message translates to: - /// **'Edit card'** - String get menuEdit; - - /// Context menu delete - /// - /// In en, this message translates to: - /// **'Delete'** - String get menuDelete; - - /// Color picker label in edit dialog - /// - /// In en, this message translates to: - /// **'Color'** - String get editColorLabel; - - /// No description provided for @colorRed. - /// - /// In en, this message translates to: - /// **'Red'** - String get colorRed; - - /// No description provided for @colorGreen. - /// - /// In en, this message translates to: - /// **'Green'** - String get colorGreen; - - /// No description provided for @colorPurple. - /// - /// In en, this message translates to: - /// **'Purple'** - String get colorPurple; - - /// No description provided for @colorYellow. - /// - /// In en, this message translates to: - /// **'Yellow'** - String get colorYellow; - - /// No description provided for @colorBlue. - /// - /// In en, this message translates to: - /// **'Blue'** - String get colorBlue; - - /// No description provided for @colorOrange. - /// - /// In en, this message translates to: - /// **'Orange'** - String get colorOrange; - - /// No description provided for @typeText. - /// - /// In en, this message translates to: - /// **'Text'** - String get typeText; - - /// No description provided for @typeImage. - /// - /// In en, this message translates to: - /// **'Image'** - String get typeImage; - - /// No description provided for @typeFile. - /// - /// In en, this message translates to: - /// **'File'** - String get typeFile; - - /// No description provided for @typeFolder. - /// - /// In en, this message translates to: - /// **'Folder'** - String get typeFolder; - - /// No description provided for @typeLink. - /// - /// In en, this message translates to: - /// **'Link'** - String get typeLink; - - /// No description provided for @typeAudio. - /// - /// In en, this message translates to: - /// **'Audio'** - String get typeAudio; - - /// No description provided for @typeVideo. - /// - /// In en, this message translates to: - /// **'Video'** - String get typeVideo; - - /// No description provided for @typeEmail. - /// - /// In en, this message translates to: - /// **'Email'** - String get typeEmail; - - /// No description provided for @typePhone. - /// - /// In en, this message translates to: - /// **'Phone'** - String get typePhone; - - /// No description provided for @typeColor. - /// - /// In en, this message translates to: - /// **'Color'** - String get typeColor; - - /// No description provided for @typeIp. - /// - /// In en, this message translates to: - /// **'IP'** - String get typeIp; - - /// No description provided for @typeUuid. - /// - /// In en, this message translates to: - /// **'UUID'** - String get typeUuid; - - /// No description provided for @typeJson. - /// - /// In en, this message translates to: - /// **'JSON'** - String get typeJson; - - /// No description provided for @filterAll. - /// - /// In en, this message translates to: - /// **'All'** - String get filterAll; - - /// No description provided for @filterPinned. - /// - /// In en, this message translates to: - /// **'Pinned'** - String get filterPinned; - - /// System tray tooltip - /// - /// In en, this message translates to: - /// **'CopyPaste'** - String get trayTooltip; - - /// Tray menu exit item - /// - /// In en, this message translates to: - /// **'Exit'** - String get trayExit; - - /// No description provided for @subtitleShortcutScopes. - /// - /// In en, this message translates to: - /// **'Ctrl+V stays with the active app. History shortcuts work while the CopyPaste panel is open.'** - String get subtitleShortcutScopes; - - /// No description provided for @shortcutOpenClose. - /// - /// In en, this message translates to: - /// **'CopyPaste global: Open / close CopyPaste'** - String get shortcutOpenClose; - - /// No description provided for @shortcutPastePlainDirect. - /// - /// In en, this message translates to: - /// **'CopyPaste global: Paste the current clipboard as plain text'** - String get shortcutPastePlainDirect; - - /// No description provided for @shortcutSystemPaste. - /// - /// In en, this message translates to: - /// **'Active app: Paste the current clipboard normally (CopyPaste does not intercept it)'** - String get shortcutSystemPaste; - - /// No description provided for @shortcutEscape. - /// - /// In en, this message translates to: - /// **'Clear search or close window'** - String get shortcutEscape; - - /// No description provided for @shortcutTab1. - /// - /// In en, this message translates to: - /// **'Switch to Recent tab'** - String get shortcutTab1; - - /// No description provided for @shortcutTab2. - /// - /// In en, this message translates to: - /// **'Switch to Pinned tab'** - String get shortcutTab2; - - /// No description provided for @shortcutArrows. - /// - /// In en, this message translates to: - /// **'Navigate between items'** - String get shortcutArrows; - - /// No description provided for @shortcutEnter. - /// - /// In en, this message translates to: - /// **'CopyPaste open: Paste the hovered, selected, or first history item normally'** - String get shortcutEnter; - - /// No description provided for @shortcutPasteSelectedPlain. - /// - /// In en, this message translates to: - /// **'CopyPaste open: Paste the hovered, selected, or first history item as plain text'** - String get shortcutPasteSelectedPlain; - - /// No description provided for @shortcutDelete. - /// - /// In en, this message translates to: - /// **'Delete selected item'** - String get shortcutDelete; - - /// No description provided for @shortcutPin. - /// - /// In en, this message translates to: - /// **'Pin / Unpin selected item'** - String get shortcutPin; - - /// No description provided for @shortcutEdit. - /// - /// In en, this message translates to: - /// **'Edit card (label and color)'** - String get shortcutEdit; - - /// General nav tab - /// - /// In en, this message translates to: - /// **'General'** - String get tabGeneral; - - /// Backup nav tab - /// - /// In en, this message translates to: - /// **'Backup & Support'** - String get tabBackupRestore; - - /// Appearance nav tab - /// - /// In en, this message translates to: - /// **'Appearance'** - String get tabAppearance; - - /// Shortcuts nav tab - /// - /// In en, this message translates to: - /// **'Shortcuts'** - String get tabShortcuts; - - /// About nav tab - /// - /// In en, this message translates to: - /// **'About'** - String get tabAbout; - - /// Language section title - /// - /// In en, this message translates to: - /// **'LANGUAGE'** - String get sectionLanguage; - - /// Startup section title - /// - /// In en, this message translates to: - /// **'STARTUP'** - String get sectionStartup; - - /// Keyboard shortcut section title - /// - /// In en, this message translates to: - /// **'KEYBOARD SHORTCUT'** - String get sectionKeyboardShortcut; - - /// Categories section title - /// - /// In en, this message translates to: - /// **'CATEGORIES'** - String get sectionCategories; - - /// Performance section title - /// - /// In en, this message translates to: - /// **'PERFORMANCE'** - String get sectionPerformance; - - /// Paste section title - /// - /// In en, this message translates to: - /// **'PASTE'** - String get sectionPaste; - - /// Backup and restore section title - /// - /// In en, this message translates to: - /// **'BACKUP & RESTORE'** - String get sectionBackupRestore; - - /// Appearance section title - /// - /// In en, this message translates to: - /// **'APPEARANCE'** - String get sectionAppearance; - - /// Theme selector label - /// - /// In en, this message translates to: - /// **'Theme'** - String get settingTheme; - - /// Light theme option - /// - /// In en, this message translates to: - /// **'Light'** - String get themeLight; - - /// Dark theme option - /// - /// In en, this message translates to: - /// **'Dark'** - String get themeDark; - - /// Auto theme option - /// - /// In en, this message translates to: - /// **'Auto'** - String get themeAuto; - - /// Behavior section title - /// - /// In en, this message translates to: - /// **'BEHAVIOR'** - String get sectionBehavior; - - /// About section title - /// - /// In en, this message translates to: - /// **'COPYPASTE'** - String get sectionAbout; - - /// Links section title - /// - /// In en, this message translates to: - /// **'LINKS'** - String get sectionLinks; - - /// Items per page label - /// - /// In en, this message translates to: - /// **'Items per page'** - String get settingItemsPerPage; - - /// Memory limit label - /// - /// In en, this message translates to: - /// **'Memory limit'** - String get settingMemoryLimit; - - /// Scroll threshold label - /// - /// In en, this message translates to: - /// **'Scroll threshold (px)'** - String get settingScrollThreshold; - - /// Paste speed label - /// - /// In en, this message translates to: - /// **'Paste speed'** - String get settingPasteSpeed; - - /// Panel width label - /// - /// In en, this message translates to: - /// **'Panel width (px)'** - String get settingPanelWidth; - - /// Panel height label - /// - /// In en, this message translates to: - /// **'Panel height (px)'** - String get settingPanelHeight; - - /// Lines collapsed label - /// - /// In en, this message translates to: - /// **'Lines collapsed'** - String get settingLinesCollapsed; - - /// Lines expanded label - /// - /// In en, this message translates to: - /// **'Lines expanded'** - String get settingLinesExpanded; - - /// Hide on deactivate label - /// - /// In en, this message translates to: - /// **'Hide on deactivate'** - String get settingHideOnDeactivate; - - /// Remember window position toggle label - /// - /// In en, this message translates to: - /// **'Remember window position'** - String get settingRememberWindowPosition; - - /// Scroll to top on open label - /// - /// In en, this message translates to: - /// **'Scroll to top on open'** - String get settingScrollToTopOnOpen; - - /// Clear search on open label - /// - /// In en, this message translates to: - /// **'Clear search on open'** - String get settingClearSearchOnOpen; - - /// Retention days label - /// - /// In en, this message translates to: - /// **'Retention days (0 = unlimited)'** - String get settingRetentionDaysLabel; - - /// Clear clipboard history label - /// - /// In en, this message translates to: - /// **'Clear clipboard history'** - String get settingClearHistoryLabel; - - /// Hotkey shortcut label - /// - /// In en, this message translates to: - /// **'Shortcut to open/close CopyPaste'** - String get settingHotkeyShortcutLabel; - - /// No description provided for @subtitleGlobalHotkeyWarning. - /// - /// In en, this message translates to: - /// **'System-wide shortcut. It may replace the same combination in another application.'** - String get subtitleGlobalHotkeyWarning; - - /// No description provided for @settingPlainPasteHotkeyLabel. - /// - /// In en, this message translates to: - /// **'Optional global plain-text paste'** - String get settingPlainPasteHotkeyLabel; - - /// No description provided for @subtitlePlainPasteHotkey. - /// - /// In en, this message translates to: - /// **'Pastes the current clipboard as plain text without opening CopyPaste. Enabling a global shortcut can override the same shortcut in other apps.'** - String get subtitlePlainPasteHotkey; - - /// No description provided for @shortcutDisabled. - /// - /// In en, this message translates to: - /// **'Disabled'** - String get shortcutDisabled; - - /// No description provided for @currentShortcut. - /// - /// In en, this message translates to: - /// **'Current: {shortcut}'** - String currentShortcut(String shortcut); - - /// No description provided for @hotkeyRequiresModifier. - /// - /// In en, this message translates to: - /// **'Add at least one modifier key before saving this shortcut.'** - String get hotkeyRequiresModifier; - - /// No description provided for @hotkeyConflictWarning. - /// - /// In en, this message translates to: - /// **'This combination is already assigned to the other CopyPaste shortcut.'** - String get hotkeyConflictWarning; - - /// No description provided for @restoreRecommendedHotkeys. - /// - /// In en, this message translates to: - /// **'Restore recommended shortcuts'** - String get restoreRecommendedHotkeys; - - /// No description provided for @plainPasteHotkeyRegistrationFailed. - /// - /// In en, this message translates to: - /// **'The direct plain-text paste shortcut could not be registered. It may already be in use by the system or another app.'** - String get plainPasteHotkeyRegistrationFailed; - - /// No description provided for @pasteDestinationUnavailable. - /// - /// In en, this message translates to: - /// **'Paste was cancelled because the original destination could not be restored. Open CopyPaste with its keyboard shortcut and try again.'** - String get pasteDestinationUnavailable; - - /// No description provided for @plainPasteItemUnavailable. - /// - /// In en, this message translates to: - /// **'The hovered, selected, or first item cannot be pasted as plain text.'** - String get plainPasteItemUnavailable; - - /// No description provided for @plainClipboardUnavailable. - /// - /// In en, this message translates to: - /// **'There is no text on the clipboard. Copy some text first, then use plain-text paste again.'** - String get plainClipboardUnavailable; - - /// No description provided for @clipboardWriteFailed. - /// - /// In en, this message translates to: - /// **'The item could not be placed on the clipboard because another app is holding it. Try again in a moment.'** - String get clipboardWriteFailed; - - /// No description provided for @pasteTargetElevated. - /// - /// In en, this message translates to: - /// **'The destination app runs as administrator, so Windows blocks the simulated paste. Run CopyPaste as administrator too, or press Ctrl+V yourself.'** - String get pasteTargetElevated; - - /// No description provided for @hotkeyRegistrationFailed. - /// - /// In en, this message translates to: - /// **'The shortcut {shortcut} could not be registered. It may already be in use by the system or another app.'** - String hotkeyRegistrationFailed(String shortcut); - - /// No description provided for @hotkeyFallbackActive. - /// - /// In en, this message translates to: - /// **'The shortcut {requested} was unavailable. CopyPaste is temporarily using {effective}.'** - String hotkeyFallbackActive(String requested, String effective); - - /// Startup subtitle - /// - /// In en, this message translates to: - /// **'Launches in background when you sign in'** - String get subtitleStartupDesc; - - /// Hide on deactivate subtitle - /// - /// In en, this message translates to: - /// **'Close window when clicking outside'** - String get subtitleHideOnDeactivate; - - /// Remember window position subtitle - /// - /// In en, this message translates to: - /// **'Reopen the window where you left it last time'** - String get subtitleRememberWindowPosition; - - /// Scroll to top on open subtitle - /// - /// In en, this message translates to: - /// **'Resets scroll and selects latest item'** - String get subtitleScrollToTopOnOpen; - - /// Clear search on open subtitle - /// - /// In en, this message translates to: - /// **'Clears the search text each time'** - String get subtitleClearSearchOnOpen; - - /// Paste speed subtitle - /// - /// In en, this message translates to: - /// **'Adjust restoration and paste timings'** - String get subtitlePasteSpeed; - - /// Categories subtitle - /// - /// In en, this message translates to: - /// **'Customize the names of color categories.'** - String get subtitleCategories; - - /// GitHub link label - /// - /// In en, this message translates to: - /// **'Support & Source code — GitHub'** - String get linkGitHub; - - /// Buy me a coffee link label - /// - /// In en, this message translates to: - /// **'Buy me a coffee'** - String get linkCoffee; - - /// Edit card dialog title - /// - /// In en, this message translates to: - /// **'Label & Color'** - String get editDialogTitle; - - /// Label input hint in edit dialog - /// - /// In en, this message translates to: - /// **'Add a label...'** - String get editDialogHint; - - /// Snackbar after clearing history - /// - /// In en, this message translates to: - /// **'History cleared'** - String get historyCleared; - - /// Backup saved snackbar - /// - /// In en, this message translates to: - /// **'Backup saved: {filename}'** - String backupSavedFile(String filename); - - /// Restore action button - /// - /// In en, this message translates to: - /// **'Restore'** - String get buttonRestore; - - /// Restore completed snackbar - /// - /// In en, this message translates to: - /// **'Restore completed'** - String get restoreCompleted; - - /// Restore requires restart message - /// - /// In en, this message translates to: - /// **'Restore completed. The app will restart to apply changes.'** - String get restoreRestartRequired; - - /// Expand collapse shortcut - /// - /// In en, this message translates to: - /// **'Expand / collapse card'** - String get shortcutExpand; - - /// Focus search shortcut - /// - /// In en, this message translates to: - /// **'Focus search box'** - String get shortcutFocusSearch; - - /// Tray menu show/hide item - /// - /// In en, this message translates to: - /// **'Show/Hide'** - String get trayShowHide; - - /// Badge when file is missing - /// - /// In en, this message translates to: - /// **'Not found'** - String get fileNotFound; - - /// Fallback name for audio items - /// - /// In en, this message translates to: - /// **'Audio file'** - String get audioFile; - - /// Fallback name for video items - /// - /// In en, this message translates to: - /// **'Video file'** - String get videoFile; - - /// Fallback name / accessibility label for image items - /// - /// In en, this message translates to: - /// **'Image file'** - String get imageFile; - - /// Timestamp for less than 1 minute ago - /// - /// In en, this message translates to: - /// **'now'** - String get timeNow; - - /// Filter menu clear action - /// - /// In en, this message translates to: - /// **'Clear all filters'** - String get clearAllFilters; - - /// Filter menu color section header - /// - /// In en, this message translates to: - /// **'COLOR'** - String get colorSectionLabel; - - /// No color option - /// - /// In en, this message translates to: - /// **'None'** - String get colorNone; - - /// Paste preset subtitle - /// - /// In en, this message translates to: - /// **'Automatic paste speed. Instant is optimized for Windows; use Safe if a destination app misses a paste.'** - String get subtitlePastePreset; - - /// Non-Windows paste preset subtitle - /// - /// In en, this message translates to: - /// **'Automatic paste speed. Normal/Safe recommended for most computers.'** - String get subtitlePastePresetStandard; - - /// Windows instant paste preset label - /// - /// In en, this message translates to: - /// **'Instant'** - String get pastePresetInstant; - - /// Fast paste preset label - /// - /// In en, this message translates to: - /// **'Fast'** - String get pastePresetFast; - - /// Normal paste preset label - /// - /// In en, this message translates to: - /// **'Normal'** - String get pastePresetNormal; - - /// Safe paste preset label - /// - /// In en, this message translates to: - /// **'Safe'** - String get pastePresetSafe; - - /// Slow paste preset label - /// - /// In en, this message translates to: - /// **'Slow'** - String get pastePresetSlow; - - /// Custom paste preset placeholder - /// - /// In en, this message translates to: - /// **'Custom'** - String get pastePresetCustom; - - /// Paste preset warning text - /// - /// In en, this message translates to: - /// **'⚡ Instant (Windows): lowest latency with native focus verification.\n⚠️ Fast: may cause unexpected behavior in heavy apps.\n⚠️ Slow: may feel sluggish on modern computers.'** - String get pastePresetWarning; - - /// Non-Windows paste preset warning text - /// - /// In en, this message translates to: - /// **'⚠️ Fast: may cause unexpected behavior in heavy apps.\n⚠️ Slow: may feel sluggish on modern computers.'** - String get pastePresetWarningStandard; - - /// Reset filters on open label - /// - /// In en, this message translates to: - /// **'Switch to All on open'** - String get settingResetFiltersOnOpen; - - /// Reset filters on open subtitle - /// - /// In en, this message translates to: - /// **'Clears category and type filters and returns to the All tab'** - String get subtitleResetFiltersOnOpen; - - /// Backup section subtitle - /// - /// In en, this message translates to: - /// **'Create a backup of your clipboard history, images, and settings. Restore at any time on this or another device.'** - String get subtitleBackup; - - /// About section description - /// - /// In en, this message translates to: - /// **'A modern clipboard manager built to feel native on Windows and macOS.\nLocal-first — your history, always at hand. No accounts, no telemetry, no subscriptions.'** - String get aboutDescription; - - /// Privacy section title in About tab - /// - /// In en, this message translates to: - /// **'PRIVACY'** - String get sectionPrivacy; - - /// Short privacy philosophy statement shown in About tab - /// - /// In en, this message translates to: - /// **'Everything local. Nothing leaves your PC — no telemetry, no sync, no accounts.'** - String get privacyStatement; - - /// Link label to open the full privacy policy - /// - /// In en, this message translates to: - /// **'Privacy Policy'** - String get privacyPolicy; - - /// Badge label: everything is stored locally - /// - /// In en, this message translates to: - /// **'Local-only'** - String get aboutTagLocal; - - /// Badge label: the app is open source - /// - /// In en, this message translates to: - /// **'Open source'** - String get aboutTagOpenSource; - - /// Badge label: the app is free - /// - /// In en, this message translates to: - /// **'Free'** - String get aboutTagFree; - - /// Other tools section title in About tab - /// - /// In en, this message translates to: - /// **'OTHER TOOLS'** - String get sectionOtherTools; - - /// LinkUnbound app name - /// - /// In en, this message translates to: - /// **'LinkUnbound'** - String get otherToolLinkUnbound; - - /// LinkUnbound app description - /// - /// In en, this message translates to: - /// **'Open-source browser selector for Windows and Mac. Same philosophy: no ads, no telemetry, everything local.'** - String get otherToolLinkUnboundDesc; - - /// License footer text - /// - /// In en, this message translates to: - /// **'GPL v3 License — Free and open source.'** - String get aboutLicense; - - /// Title for the macOS accessibility permissions dialog - /// - /// In en, this message translates to: - /// **'Accessibility Permission Required'** - String get permissionsTitle; - - /// Body text explaining why accessibility permission is needed - /// - /// In en, this message translates to: - /// **'CopyPaste needs Accessibility permission to paste content into other apps.\n\nGo to System Settings → Privacy & Security → Accessibility and enable CopyPaste.'** - String get permissionsMessage; - - /// Button to open macOS System Settings - /// - /// In en, this message translates to: - /// **'Open Settings'** - String get permissionsOpenSettings; - - /// Dismiss button for permissions dialog - /// - /// In en, this message translates to: - /// **'Later'** - String get permissionsDismiss; - - /// Snackbar message when permission is confirmed - /// - /// In en, this message translates to: - /// **'Permission granted'** - String get permissionsGranted; - - /// Title shown when permission was previously granted but is no longer recognised (Gatekeeper identity change) - /// - /// In en, this message translates to: - /// **'Accessibility Permission Lost'** - String get permissionsResetTitle; - - /// Instructions for fixing stale TCC entries after Gatekeeper re-authorisation - /// - /// In en, this message translates to: - /// **'macOS no longer recognises CopyPaste\'s permission because the app was re-authorised through Gatekeeper.\n\nTo fix this:\n1. Open Accessibility settings below\n2. Remove CopyPaste from the list (−)\n3. Re-add it or toggle it back on'** - String get permissionsResetMessage; - - /// Shown after polling times out without detecting the permission grant - /// - /// In en, this message translates to: - /// **'Make sure CopyPaste is enabled in Privacy & Security > Accessibility.\n\nThe app will continue automatically when the permission is detected.'** - String get permissionsRestartMessage; - - /// Button to manually re-check accessibility permission - /// - /// In en, this message translates to: - /// **'Check Again'** - String get permissionsCheckAgain; - - /// Button to restart the app when permission detection is stuck - /// - /// In en, this message translates to: - /// **'Restart App'** - String get permissionsRestartApp; - - /// Label shown while polling for the accessibility permission grant - /// - /// In en, this message translates to: - /// **'Waiting for permission…'** - String get permissionsWaiting; - - /// Short text shown in the footer when an update is available - /// - /// In en, this message translates to: - /// **'v{version} is available, please update'** - String updateBadge(String version); - - /// Update dialog message for Windows standalone builds - /// - /// In en, this message translates to: - /// **'Version {version} is available.\n\nDownload the latest installer from GitHub.'** - String updateAvailableWindows(String version); - - /// Update dialog message for macOS - /// - /// In en, this message translates to: - /// **'Version {version} is available.\n\nUpdate via Homebrew:\nbrew upgrade copypaste\n\nOr download the latest release from GitHub.'** - String updateAvailableMac(String version); - - /// Update dialog message for MS Store builds - /// - /// In en, this message translates to: - /// **'Version {version} is available.\n\nMicrosoft Store delivers updates automatically. New versions may take a few days to appear after release.'** - String updateAvailableStore(String version); - - /// Short tooltip for MS Store badge - /// - /// In en, this message translates to: - /// **'Update {version} coming via Microsoft Store'** - String updateTooltipStore(String version); - - /// Short tooltip for non-Store badge - /// - /// In en, this message translates to: - /// **'Update {version} available — click for details'** - String updateTooltipGeneric(String version); - - /// Title of the update available dialog - /// - /// In en, this message translates to: - /// **'Update Available'** - String get updateDialogTitle; - - /// Button to open the GitHub release page - /// - /// In en, this message translates to: - /// **'View release'** - String get updateViewRelease; - - /// Button to dismiss the update notification - /// - /// In en, this message translates to: - /// **'Later'** - String get updateDismiss; - - /// Footer badge text for minor/major updates - /// - /// In en, this message translates to: - /// **'v{version} available — important update'** - String updateBadgeImportant(String version); - - /// Action button to open the installer download page - /// - /// In en, this message translates to: - /// **'Download installer'** - String get updateActionDownload; - - /// Action button to open the MS Store update page - /// - /// In en, this message translates to: - /// **'Open Microsoft Store'** - String get updateActionOpenStore; - - /// Action button to copy the package manager upgrade command - /// - /// In en, this message translates to: - /// **'Copy {tool} command'** - String updateActionCopyCommand(String tool); - - /// Snack/tooltip shown after copying the upgrade command - /// - /// In en, this message translates to: - /// **'Copied to clipboard'** - String get updateActionCopied; - - /// Title of the blocked-version full-screen gate - /// - /// In en, this message translates to: - /// **'Update required'** - String get blockedTitle; - - /// Body of the blocked-version full-screen gate - /// - /// In en, this message translates to: - /// **'Version {current} of CopyPaste is no longer supported. Please install version {required} or newer to continue using the app.'** - String blockedDescription(String current, String required); - - /// Generic reason shown in the blocked screen when the manifest does not provide one - /// - /// In en, this message translates to: - /// **'This version was retired by the maintainers for safety or compatibility reasons.'** - String get blockedReasonGeneric; - - /// Secondary action on the blocked-version screen - /// - /// In en, this message translates to: - /// **'Quit CopyPaste'** - String get blockedQuit; - - /// Hint shown when no channel-specific action is available - /// - /// In en, this message translates to: - /// **'Visit https://github.com/rgdevment/CopyPaste/releases to download the latest installer.'** - String get blockedFallbackHint; - - /// In-app snackbar shown inside the window when it is raised by a second launch attempt - /// - /// In en, this message translates to: - /// **'CopyPaste runs in the background — press {hotkey} or click the tray icon to open it anytime.'** - String wakeupHint(String hotkey); - - /// Hint shown when user opens CopyPaste from the taskbar in taskbar mode - /// - /// In en, this message translates to: - /// **'Tip: press {hotkey} to open and paste automatically — no focus lost.'** - String taskbarOpenHint(String hotkey); - - /// Windows balloon shown at startup when window starts hidden - /// - /// In en, this message translates to: - /// **'Running in the background. Press {hotkey} or click the tray icon.'** - String balloonStartupBody(String hotkey); - - /// Windows balloon title when a second instance is launched - /// - /// In en, this message translates to: - /// **'CopyPaste is already open'** - String get balloonWakeupTitle; - - /// Windows balloon body when a second instance is launched - /// - /// In en, this message translates to: - /// **'Press {hotkey} or click the tray icon to bring it up.'** - String balloonWakeupBody(String hotkey); - - /// Onboarding screen title - /// - /// In en, this message translates to: - /// **'Welcome to CopyPaste'** - String get onboardingTitle; - - /// Onboarding screen subtitle - /// - /// In en, this message translates to: - /// **'Everything you copy, saved.'** - String get onboardingSubtitle; - - /// Onboarding privacy badge chip - /// - /// In en, this message translates to: - /// **'No cloud · No tracking · 100% local'** - String get onboardingPrivacyBadge; - - /// Onboarding main description - /// - /// In en, this message translates to: - /// **'Runs silently in the background. Press {hotkey} anytime to open your clipboard history.'** - String onboardingDescription(String hotkey); - - /// Onboarding tray location hint - /// - /// In en, this message translates to: - /// **'Look for the CP icon next to your clock.'** - String get onboardingTrayHint; - - /// Onboarding settings button - /// - /// In en, this message translates to: - /// **'Settings'** - String get onboardingSettingsButton; - - /// Onboarding dismiss button - /// - /// In en, this message translates to: - /// **'Get started'** - String get onboardingDismissButton; - - /// Performance tab label (paste, perf, multimedia) - /// - /// In en, this message translates to: - /// **'Performance'** - String get tabCapture; - - /// Multimedia tab label (legacy, unused since tabs were merged) - /// - /// In en, this message translates to: - /// **'Multimedia'** - String get tabMultimedia; - - /// Cleanup & privacy tab label - /// - /// In en, this message translates to: - /// **'Cleanup & Privacy'** - String get tabCleanupPrivacy; - - /// Multimedia section header - /// - /// In en, this message translates to: - /// **'MULTIMEDIA & THUMBNAILS'** - String get sectionMultimedia; - - /// Multimedia section subtitle - /// - /// In en, this message translates to: - /// **'Control how images, videos and audio files are previewed.'** - String get subtitleMultimedia; - - /// Image thumbs toggle - /// - /// In en, this message translates to: - /// **'Generate image thumbnails'** - String get settingGenerateImageThumbnails; - - /// Image thumbs subtitle - /// - /// In en, this message translates to: - /// **'Show preview tiles for copied or referenced images.'** - String get subtitleGenerateImageThumbnails; - - /// Video thumbs toggle - /// - /// In en, this message translates to: - /// **'Generate video thumbnails'** - String get settingGenerateVideoThumbnails; - - /// Video thumbs subtitle - /// - /// In en, this message translates to: - /// **'Use the OS shell cache to show a preview frame for video files.'** - String get subtitleGenerateVideoThumbnails; - - /// Audio thumbs toggle - /// - /// In en, this message translates to: - /// **'Generate audio thumbnails'** - String get settingGenerateAudioThumbnails; - - /// Audio thumbs subtitle - /// - /// In en, this message translates to: - /// **'Show cover art when available for audio files.'** - String get subtitleGenerateAudioThumbnails; - - /// Max image size label - /// - /// In en, this message translates to: - /// **'Max image size for processing (MB)'** - String get settingMaxImageSize; - - /// Max image size subtitle - /// - /// In en, this message translates to: - /// **'Larger images keep their original bitmap fallback and are not re-encoded.'** - String get subtitleMaxImageSize; - - /// Cleanup & privacy section header - /// - /// In en, this message translates to: - /// **'CLEANUP & PRIVACY'** - String get sectionCleanupPrivacy; - - /// Days to keep broken external refs - /// - /// In en, this message translates to: - /// **'Keep unavailable items (days)'** - String get settingKeepBrokenItemsLabel; - - /// Broken-items subtitle - /// - /// In en, this message translates to: - /// **'Items that point to a missing file or unmounted volume are pruned after this many days. 0 prunes immediately.'** - String get subtitleKeepBrokenItems; - - /// Quota label - /// - /// In en, this message translates to: - /// **'Storage cap for images'** - String get settingImagesQuotaLabel; - - /// Quota subtitle - /// - /// In en, this message translates to: - /// **'When the images folder exceeds this size, oldest unpinned items are deleted to free space.'** - String get subtitleImagesQuota; - - /// Quota disabled label - /// - /// In en, this message translates to: - /// **'Unlimited'** - String get imagesQuotaOff; -} - -class _AppLocalizationsDelegate - extends LocalizationsDelegate { - const _AppLocalizationsDelegate(); - - @override - Future load(Locale locale) { - return SynchronousFuture(lookupAppLocalizations(locale)); - } - - @override - bool isSupported(Locale locale) => - ['en', 'es'].contains(locale.languageCode); - - @override - bool shouldReload(_AppLocalizationsDelegate old) => false; -} - -AppLocalizations lookupAppLocalizations(Locale locale) { - // Lookup logic when only language code is specified. - switch (locale.languageCode) { - case 'en': - return AppLocalizationsEn(); - case 'es': - return AppLocalizationsEs(); - } - - throw FlutterError( - 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' - 'an issue with the localizations generation tool. Please file an issue ' - 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.', - ); -} diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart deleted file mode 100644 index 1bfbfcf2..00000000 --- a/app/lib/l10n/app_localizations_en.dart +++ /dev/null @@ -1,873 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for English (`en`). -class AppLocalizationsEn extends AppLocalizations { - AppLocalizationsEn([String locale = 'en']) : super(locale); - - @override - String get searchPlaceholder => 'Search clipboard…'; - - @override - String get emptyState => 'No items in this section'; - - @override - String get emptyStateSubtitle => 'Copy something to get started'; - - @override - String get hintBannerText => - 'CopyPaste is active and running in the background. Look for it in the system tray or just use your shortcut. Feel free to customize your experience in'; - - @override - String get hintBannerAction => 'Settings'; - - @override - String get settingsTitle => 'Settings'; - - @override - String get sectionShortcuts => 'KEYBOARD SHORTCUTS'; - - @override - String get sectionStorage => 'STORAGE'; - - @override - String get settingRunOnStartup => 'Run on startup'; - - @override - String get settingLanguage => 'Interface language'; - - @override - String get hotkeyWillApply => 'Hotkey will apply immediately'; - - @override - String get sectionSupport => 'SUPPORT'; - - @override - String get supportExportLogs => 'Export logs'; - - @override - String get supportExportLogsSubtitle => - 'Save a zip with app logs for a bug report. Your clipboard content is never included.'; - - @override - String get supportOpenLogsFolder => 'Open logs folder'; - - @override - String get supportOpenLogsFolderSubtitle => - 'Browse the raw log files in your file manager.'; - - @override - String get supportGitHub => 'Report a bug on GitHub'; - - @override - String get supportExportSuccess => 'Logs saved to Downloads.'; - - @override - String get supportShowInFiles => 'Show'; - - @override - String get supportExportEmpty => 'No log files found.'; - - @override - String get supportExportError => 'Failed to export logs.'; - - @override - String get sectionReset => 'RESET & CLEAN INSTALL'; - - @override - String get resetSoftLabel => 'Soft Reset'; - - @override - String get resetSoftSubtitle => - 'Resets all settings to defaults and marks app as fresh install. Clipboard history is preserved.'; - - @override - String get resetHardLabel => 'Hard Reset'; - - @override - String get resetHardSubtitle => - 'Deletes all clipboard history, images, and settings. This cannot be undone.'; - - @override - String get resetSoftConfirmTitle => 'Soft reset?'; - - @override - String get resetSoftConfirmMessage => - 'All settings will return to defaults and the app will restart as if freshly installed. Your clipboard history will not be deleted.'; - - @override - String get resetHardConfirmTitle => 'Hard reset?'; - - @override - String get resetHardConfirmMessage => - 'This will permanently delete all clipboard history, images, and settings, then restart the app. This cannot be undone.'; - - @override - String get resetConfirmButton => 'Reset & Restart'; - - @override - String get clearHistoryConfirmTitle => 'Clear history?'; - - @override - String get clearHistoryConfirmMessage => - 'This will permanently delete all non-pinned clipboard items. This action cannot be undone.'; - - @override - String get clearHistoryConfirmButton => 'Clear'; - - @override - String backupLastDate(String date) { - return 'Last backup: $date'; - } - - @override - String get backupNone => 'No backup created yet.'; - - @override - String get backupCreateLabel => 'Create backup'; - - @override - String get backupRestoreLabel => 'Restore backup'; - - @override - String get backupError => 'Failed to create backup. Check permissions.'; - - @override - String get restoreDialogTitle => 'Restore backup'; - - @override - String get restoreDialogWarning => - 'This will replace all current data with the backup contents. Continue?'; - - @override - String get restoreFileNotFound => 'File not found.'; - - @override - String restoreSuccess(int count) { - return 'Restored $count items.'; - } - - @override - String get restoreError => - 'Restore failed. Your previous data has been preserved.'; - - @override - String get buttonSave => 'Save'; - - @override - String get buttonClose => 'Close'; - - @override - String get buttonCancel => 'Cancel'; - - @override - String get buttonReset => 'Restore defaults'; - - @override - String get savingIndicator => 'Saving…'; - - @override - String get savedIndicator => 'Saved'; - - @override - String get menuPaste => 'Paste'; - - @override - String get menuPastePlain => 'Paste plain'; - - @override - String get menuCopy => 'Copy'; - - @override - String get copiedToClipboard => 'Copied to clipboard'; - - @override - String get menuPin => 'Pin'; - - @override - String get menuUnpin => 'Unpin'; - - @override - String get menuEdit => 'Edit card'; - - @override - String get menuDelete => 'Delete'; - - @override - String get editColorLabel => 'Color'; - - @override - String get colorRed => 'Red'; - - @override - String get colorGreen => 'Green'; - - @override - String get colorPurple => 'Purple'; - - @override - String get colorYellow => 'Yellow'; - - @override - String get colorBlue => 'Blue'; - - @override - String get colorOrange => 'Orange'; - - @override - String get typeText => 'Text'; - - @override - String get typeImage => 'Image'; - - @override - String get typeFile => 'File'; - - @override - String get typeFolder => 'Folder'; - - @override - String get typeLink => 'Link'; - - @override - String get typeAudio => 'Audio'; - - @override - String get typeVideo => 'Video'; - - @override - String get typeEmail => 'Email'; - - @override - String get typePhone => 'Phone'; - - @override - String get typeColor => 'Color'; - - @override - String get typeIp => 'IP'; - - @override - String get typeUuid => 'UUID'; - - @override - String get typeJson => 'JSON'; - - @override - String get filterAll => 'All'; - - @override - String get filterPinned => 'Pinned'; - - @override - String get trayTooltip => 'CopyPaste'; - - @override - String get trayExit => 'Exit'; - - @override - String get subtitleShortcutScopes => - 'Ctrl+V stays with the active app. History shortcuts work while the CopyPaste panel is open.'; - - @override - String get shortcutOpenClose => 'CopyPaste global: Open / close CopyPaste'; - - @override - String get shortcutPastePlainDirect => - 'CopyPaste global: Paste the current clipboard as plain text'; - - @override - String get shortcutSystemPaste => - 'Active app: Paste the current clipboard normally (CopyPaste does not intercept it)'; - - @override - String get shortcutEscape => 'Clear search or close window'; - - @override - String get shortcutTab1 => 'Switch to Recent tab'; - - @override - String get shortcutTab2 => 'Switch to Pinned tab'; - - @override - String get shortcutArrows => 'Navigate between items'; - - @override - String get shortcutEnter => - 'CopyPaste open: Paste the hovered, selected, or first history item normally'; - - @override - String get shortcutPasteSelectedPlain => - 'CopyPaste open: Paste the hovered, selected, or first history item as plain text'; - - @override - String get shortcutDelete => 'Delete selected item'; - - @override - String get shortcutPin => 'Pin / Unpin selected item'; - - @override - String get shortcutEdit => 'Edit card (label and color)'; - - @override - String get tabGeneral => 'General'; - - @override - String get tabBackupRestore => 'Backup & Support'; - - @override - String get tabAppearance => 'Appearance'; - - @override - String get tabShortcuts => 'Shortcuts'; - - @override - String get tabAbout => 'About'; - - @override - String get sectionLanguage => 'LANGUAGE'; - - @override - String get sectionStartup => 'STARTUP'; - - @override - String get sectionKeyboardShortcut => 'KEYBOARD SHORTCUT'; - - @override - String get sectionCategories => 'CATEGORIES'; - - @override - String get sectionPerformance => 'PERFORMANCE'; - - @override - String get sectionPaste => 'PASTE'; - - @override - String get sectionBackupRestore => 'BACKUP & RESTORE'; - - @override - String get sectionAppearance => 'APPEARANCE'; - - @override - String get settingTheme => 'Theme'; - - @override - String get themeLight => 'Light'; - - @override - String get themeDark => 'Dark'; - - @override - String get themeAuto => 'Auto'; - - @override - String get sectionBehavior => 'BEHAVIOR'; - - @override - String get sectionAbout => 'COPYPASTE'; - - @override - String get sectionLinks => 'LINKS'; - - @override - String get settingItemsPerPage => 'Items per page'; - - @override - String get settingMemoryLimit => 'Memory limit'; - - @override - String get settingScrollThreshold => 'Scroll threshold (px)'; - - @override - String get settingPasteSpeed => 'Paste speed'; - - @override - String get settingPanelWidth => 'Panel width (px)'; - - @override - String get settingPanelHeight => 'Panel height (px)'; - - @override - String get settingLinesCollapsed => 'Lines collapsed'; - - @override - String get settingLinesExpanded => 'Lines expanded'; - - @override - String get settingHideOnDeactivate => 'Hide on deactivate'; - - @override - String get settingRememberWindowPosition => 'Remember window position'; - - @override - String get settingScrollToTopOnOpen => 'Scroll to top on open'; - - @override - String get settingClearSearchOnOpen => 'Clear search on open'; - - @override - String get settingRetentionDaysLabel => 'Retention days (0 = unlimited)'; - - @override - String get settingClearHistoryLabel => 'Clear clipboard history'; - - @override - String get settingHotkeyShortcutLabel => 'Shortcut to open/close CopyPaste'; - - @override - String get subtitleGlobalHotkeyWarning => - 'System-wide shortcut. It may replace the same combination in another application.'; - - @override - String get settingPlainPasteHotkeyLabel => 'Optional global plain-text paste'; - - @override - String get subtitlePlainPasteHotkey => - 'Pastes the current clipboard as plain text without opening CopyPaste. Enabling a global shortcut can override the same shortcut in other apps.'; - - @override - String get shortcutDisabled => 'Disabled'; - - @override - String currentShortcut(String shortcut) { - return 'Current: $shortcut'; - } - - @override - String get hotkeyRequiresModifier => - 'Add at least one modifier key before saving this shortcut.'; - - @override - String get hotkeyConflictWarning => - 'This combination is already assigned to the other CopyPaste shortcut.'; - - @override - String get restoreRecommendedHotkeys => 'Restore recommended shortcuts'; - - @override - String get plainPasteHotkeyRegistrationFailed => - 'The direct plain-text paste shortcut could not be registered. It may already be in use by the system or another app.'; - - @override - String get pasteDestinationUnavailable => - 'Paste was cancelled because the original destination could not be restored. Open CopyPaste with its keyboard shortcut and try again.'; - - @override - String get plainPasteItemUnavailable => - 'The hovered, selected, or first item cannot be pasted as plain text.'; - - @override - String get plainClipboardUnavailable => - 'There is no text on the clipboard. Copy some text first, then use plain-text paste again.'; - - @override - String get clipboardWriteFailed => - 'The item could not be placed on the clipboard because another app is holding it. Try again in a moment.'; - - @override - String get pasteTargetElevated => - 'The destination app runs as administrator, so Windows blocks the simulated paste. Run CopyPaste as administrator too, or press Ctrl+V yourself.'; - - @override - String hotkeyRegistrationFailed(String shortcut) { - return 'The shortcut $shortcut could not be registered. It may already be in use by the system or another app.'; - } - - @override - String hotkeyFallbackActive(String requested, String effective) { - return 'The shortcut $requested was unavailable. CopyPaste is temporarily using $effective.'; - } - - @override - String get subtitleStartupDesc => 'Launches in background when you sign in'; - - @override - String get subtitleHideOnDeactivate => 'Close window when clicking outside'; - - @override - String get subtitleRememberWindowPosition => - 'Reopen the window where you left it last time'; - - @override - String get subtitleScrollToTopOnOpen => - 'Resets scroll and selects latest item'; - - @override - String get subtitleClearSearchOnOpen => 'Clears the search text each time'; - - @override - String get subtitlePasteSpeed => 'Adjust restoration and paste timings'; - - @override - String get subtitleCategories => 'Customize the names of color categories.'; - - @override - String get linkGitHub => 'Support & Source code — GitHub'; - - @override - String get linkCoffee => 'Buy me a coffee'; - - @override - String get editDialogTitle => 'Label & Color'; - - @override - String get editDialogHint => 'Add a label...'; - - @override - String get historyCleared => 'History cleared'; - - @override - String backupSavedFile(String filename) { - return 'Backup saved: $filename'; - } - - @override - String get buttonRestore => 'Restore'; - - @override - String get restoreCompleted => 'Restore completed'; - - @override - String get restoreRestartRequired => - 'Restore completed. The app will restart to apply changes.'; - - @override - String get shortcutExpand => 'Expand / collapse card'; - - @override - String get shortcutFocusSearch => 'Focus search box'; - - @override - String get trayShowHide => 'Show/Hide'; - - @override - String get fileNotFound => 'Not found'; - - @override - String get audioFile => 'Audio file'; - - @override - String get videoFile => 'Video file'; - - @override - String get imageFile => 'Image file'; - - @override - String get timeNow => 'now'; - - @override - String get clearAllFilters => 'Clear all filters'; - - @override - String get colorSectionLabel => 'COLOR'; - - @override - String get colorNone => 'None'; - - @override - String get subtitlePastePreset => - 'Automatic paste speed. Instant is optimized for Windows; use Safe if a destination app misses a paste.'; - - @override - String get subtitlePastePresetStandard => - 'Automatic paste speed. Normal/Safe recommended for most computers.'; - - @override - String get pastePresetInstant => 'Instant'; - - @override - String get pastePresetFast => 'Fast'; - - @override - String get pastePresetNormal => 'Normal'; - - @override - String get pastePresetSafe => 'Safe'; - - @override - String get pastePresetSlow => 'Slow'; - - @override - String get pastePresetCustom => 'Custom'; - - @override - String get pastePresetWarning => - '⚡ Instant (Windows): lowest latency with native focus verification.\n⚠️ Fast: may cause unexpected behavior in heavy apps.\n⚠️ Slow: may feel sluggish on modern computers.'; - - @override - String get pastePresetWarningStandard => - '⚠️ Fast: may cause unexpected behavior in heavy apps.\n⚠️ Slow: may feel sluggish on modern computers.'; - - @override - String get settingResetFiltersOnOpen => 'Switch to All on open'; - - @override - String get subtitleResetFiltersOnOpen => - 'Clears category and type filters and returns to the All tab'; - - @override - String get subtitleBackup => - 'Create a backup of your clipboard history, images, and settings. Restore at any time on this or another device.'; - - @override - String get aboutDescription => - 'A modern clipboard manager built to feel native on Windows and macOS.\nLocal-first — your history, always at hand. No accounts, no telemetry, no subscriptions.'; - - @override - String get sectionPrivacy => 'PRIVACY'; - - @override - String get privacyStatement => - 'Everything local. Nothing leaves your PC — no telemetry, no sync, no accounts.'; - - @override - String get privacyPolicy => 'Privacy Policy'; - - @override - String get aboutTagLocal => 'Local-only'; - - @override - String get aboutTagOpenSource => 'Open source'; - - @override - String get aboutTagFree => 'Free'; - - @override - String get sectionOtherTools => 'OTHER TOOLS'; - - @override - String get otherToolLinkUnbound => 'LinkUnbound'; - - @override - String get otherToolLinkUnboundDesc => - 'Open-source browser selector for Windows and Mac. Same philosophy: no ads, no telemetry, everything local.'; - - @override - String get aboutLicense => 'GPL v3 License — Free and open source.'; - - @override - String get permissionsTitle => 'Accessibility Permission Required'; - - @override - String get permissionsMessage => - 'CopyPaste needs Accessibility permission to paste content into other apps.\n\nGo to System Settings → Privacy & Security → Accessibility and enable CopyPaste.'; - - @override - String get permissionsOpenSettings => 'Open Settings'; - - @override - String get permissionsDismiss => 'Later'; - - @override - String get permissionsGranted => 'Permission granted'; - - @override - String get permissionsResetTitle => 'Accessibility Permission Lost'; - - @override - String get permissionsResetMessage => - 'macOS no longer recognises CopyPaste\'s permission because the app was re-authorised through Gatekeeper.\n\nTo fix this:\n1. Open Accessibility settings below\n2. Remove CopyPaste from the list (−)\n3. Re-add it or toggle it back on'; - - @override - String get permissionsRestartMessage => - 'Make sure CopyPaste is enabled in Privacy & Security > Accessibility.\n\nThe app will continue automatically when the permission is detected.'; - - @override - String get permissionsCheckAgain => 'Check Again'; - - @override - String get permissionsRestartApp => 'Restart App'; - - @override - String get permissionsWaiting => 'Waiting for permission…'; - - @override - String updateBadge(String version) { - return 'v$version is available, please update'; - } - - @override - String updateAvailableWindows(String version) { - return 'Version $version is available.\n\nDownload the latest installer from GitHub.'; - } - - @override - String updateAvailableMac(String version) { - return 'Version $version is available.\n\nUpdate via Homebrew:\nbrew upgrade copypaste\n\nOr download the latest release from GitHub.'; - } - - @override - String updateAvailableStore(String version) { - return 'Version $version is available.\n\nMicrosoft Store delivers updates automatically. New versions may take a few days to appear after release.'; - } - - @override - String updateTooltipStore(String version) { - return 'Update $version coming via Microsoft Store'; - } - - @override - String updateTooltipGeneric(String version) { - return 'Update $version available — click for details'; - } - - @override - String get updateDialogTitle => 'Update Available'; - - @override - String get updateViewRelease => 'View release'; - - @override - String get updateDismiss => 'Later'; - - @override - String updateBadgeImportant(String version) { - return 'v$version available — important update'; - } - - @override - String get updateActionDownload => 'Download installer'; - - @override - String get updateActionOpenStore => 'Open Microsoft Store'; - - @override - String updateActionCopyCommand(String tool) { - return 'Copy $tool command'; - } - - @override - String get updateActionCopied => 'Copied to clipboard'; - - @override - String get blockedTitle => 'Update required'; - - @override - String blockedDescription(String current, String required) { - return 'Version $current of CopyPaste is no longer supported. Please install version $required or newer to continue using the app.'; - } - - @override - String get blockedReasonGeneric => - 'This version was retired by the maintainers for safety or compatibility reasons.'; - - @override - String get blockedQuit => 'Quit CopyPaste'; - - @override - String get blockedFallbackHint => - 'Visit https://github.com/rgdevment/CopyPaste/releases to download the latest installer.'; - - @override - String wakeupHint(String hotkey) { - return 'CopyPaste runs in the background — press $hotkey or click the tray icon to open it anytime.'; - } - - @override - String taskbarOpenHint(String hotkey) { - return 'Tip: press $hotkey to open and paste automatically — no focus lost.'; - } - - @override - String balloonStartupBody(String hotkey) { - return 'Running in the background. Press $hotkey or click the tray icon.'; - } - - @override - String get balloonWakeupTitle => 'CopyPaste is already open'; - - @override - String balloonWakeupBody(String hotkey) { - return 'Press $hotkey or click the tray icon to bring it up.'; - } - - @override - String get onboardingTitle => 'Welcome to CopyPaste'; - - @override - String get onboardingSubtitle => 'Everything you copy, saved.'; - - @override - String get onboardingPrivacyBadge => 'No cloud · No tracking · 100% local'; - - @override - String onboardingDescription(String hotkey) { - return 'Runs silently in the background. Press $hotkey anytime to open your clipboard history.'; - } - - @override - String get onboardingTrayHint => 'Look for the CP icon next to your clock.'; - - @override - String get onboardingSettingsButton => 'Settings'; - - @override - String get onboardingDismissButton => 'Get started'; - - @override - String get tabCapture => 'Performance'; - - @override - String get tabMultimedia => 'Multimedia'; - - @override - String get tabCleanupPrivacy => 'Cleanup & Privacy'; - - @override - String get sectionMultimedia => 'MULTIMEDIA & THUMBNAILS'; - - @override - String get subtitleMultimedia => - 'Control how images, videos and audio files are previewed.'; - - @override - String get settingGenerateImageThumbnails => 'Generate image thumbnails'; - - @override - String get subtitleGenerateImageThumbnails => - 'Show preview tiles for copied or referenced images.'; - - @override - String get settingGenerateVideoThumbnails => 'Generate video thumbnails'; - - @override - String get subtitleGenerateVideoThumbnails => - 'Use the OS shell cache to show a preview frame for video files.'; - - @override - String get settingGenerateAudioThumbnails => 'Generate audio thumbnails'; - - @override - String get subtitleGenerateAudioThumbnails => - 'Show cover art when available for audio files.'; - - @override - String get settingMaxImageSize => 'Max image size for processing (MB)'; - - @override - String get subtitleMaxImageSize => - 'Larger images keep their original bitmap fallback and are not re-encoded.'; - - @override - String get sectionCleanupPrivacy => 'CLEANUP & PRIVACY'; - - @override - String get settingKeepBrokenItemsLabel => 'Keep unavailable items (days)'; - - @override - String get subtitleKeepBrokenItems => - 'Items that point to a missing file or unmounted volume are pruned after this many days. 0 prunes immediately.'; - - @override - String get settingImagesQuotaLabel => 'Storage cap for images'; - - @override - String get subtitleImagesQuota => - 'When the images folder exceeds this size, oldest unpinned items are deleted to free space.'; - - @override - String get imagesQuotaOff => 'Unlimited'; -} diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart deleted file mode 100644 index 7f962d09..00000000 --- a/app/lib/l10n/app_localizations_es.dart +++ /dev/null @@ -1,882 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Spanish Castilian (`es`). -class AppLocalizationsEs extends AppLocalizations { - AppLocalizationsEs([String locale = 'es']) : super(locale); - - @override - String get searchPlaceholder => 'Buscar en portapapeles…'; - - @override - String get emptyState => 'No hay elementos en esta sección'; - - @override - String get emptyStateSubtitle => 'Copia algo para comenzar'; - - @override - String get hintBannerText => - 'CopyPaste se ejecuta en segundo plano — encuéntralo en la bandeja del sistema o usa tu atajo de teclado. Personaliza tu experiencia en'; - - @override - String get hintBannerAction => 'Ajustes'; - - @override - String get settingsTitle => 'Configuración'; - - @override - String get sectionShortcuts => 'ATAJOS DE TECLADO'; - - @override - String get sectionStorage => 'ALMACENAMIENTO'; - - @override - String get settingRunOnStartup => 'Iniciar con el sistema'; - - @override - String get settingLanguage => 'Idioma de la interfaz'; - - @override - String get hotkeyWillApply => 'El atajo se aplicará de inmediato'; - - @override - String get sectionSupport => 'SOPORTE'; - - @override - String get supportExportLogs => 'Exportar registros'; - - @override - String get supportExportLogsSubtitle => - 'Guarda un zip con registros de la app para adjuntar a un reporte. El contenido del portapapeles nunca se incluye.'; - - @override - String get supportOpenLogsFolder => 'Abrir carpeta de registros'; - - @override - String get supportOpenLogsFolderSubtitle => - 'Explora los archivos de registro en tu gestor de archivos.'; - - @override - String get supportGitHub => 'Reportar un error en GitHub'; - - @override - String get supportExportSuccess => 'Registros guardados en Descargas.'; - - @override - String get supportShowInFiles => 'Mostrar'; - - @override - String get supportExportEmpty => 'No se encontraron archivos de registro.'; - - @override - String get supportExportError => 'Error al exportar los registros.'; - - @override - String get sectionReset => 'RESTABLECER E INSTALACIÓN LIMPIA'; - - @override - String get resetSoftLabel => 'Restablecimiento suave'; - - @override - String get resetSoftSubtitle => - 'Restablece la configuración a los valores predeterminados y marca la app como nueva instalación. El historial del portapapeles se conserva.'; - - @override - String get resetHardLabel => 'Restablecimiento completo'; - - @override - String get resetHardSubtitle => - 'Elimina todo el historial, imágenes y configuración. Esta acción no se puede deshacer.'; - - @override - String get resetSoftConfirmTitle => '¿Restablecimiento suave?'; - - @override - String get resetSoftConfirmMessage => - 'Toda la configuración volverá a los valores predeterminados y la app se reiniciará como si fuera una instalación nueva. El historial del portapapeles no se eliminará.'; - - @override - String get resetHardConfirmTitle => '¿Restablecimiento completo?'; - - @override - String get resetHardConfirmMessage => - 'Se eliminará permanentemente todo el historial, imágenes y configuración, y luego la app se reiniciará. Esta acción no se puede deshacer.'; - - @override - String get resetConfirmButton => 'Restablecer y Reiniciar'; - - @override - String get clearHistoryConfirmTitle => '¿Limpiar historial?'; - - @override - String get clearHistoryConfirmMessage => - 'Esto eliminará permanentemente todos los elementos no anclados. Esta acción no se puede deshacer.'; - - @override - String get clearHistoryConfirmButton => 'Limpiar'; - - @override - String backupLastDate(String date) { - return 'Último respaldo: $date'; - } - - @override - String get backupNone => 'Aún no se ha creado un respaldo.'; - - @override - String get backupCreateLabel => 'Crear respaldo'; - - @override - String get backupRestoreLabel => 'Restaurar respaldo'; - - @override - String get backupError => - 'Error al crear el respaldo. Verifica los permisos.'; - - @override - String get restoreDialogTitle => 'Restaurar respaldo'; - - @override - String get restoreDialogWarning => - 'Esto reemplazará todos los datos actuales con el contenido del respaldo. ¿Continuar?'; - - @override - String get restoreFileNotFound => 'Archivo no encontrado.'; - - @override - String restoreSuccess(int count) { - return 'Se restauraron $count elementos.'; - } - - @override - String get restoreError => - 'Error al restaurar. Tus datos anteriores se han preservado.'; - - @override - String get buttonSave => 'Guardar'; - - @override - String get buttonClose => 'Cerrar'; - - @override - String get buttonCancel => 'Cancelar'; - - @override - String get buttonReset => 'Restaurar predeterminados'; - - @override - String get savingIndicator => 'Guardando…'; - - @override - String get savedIndicator => 'Guardado'; - - @override - String get menuPaste => 'Pegar'; - - @override - String get menuPastePlain => 'Pegar sin formato'; - - @override - String get menuCopy => 'Copiar'; - - @override - String get copiedToClipboard => 'Copiado al portapapeles'; - - @override - String get menuPin => 'Anclar'; - - @override - String get menuUnpin => 'Desanclar'; - - @override - String get menuEdit => 'Editar tarjeta'; - - @override - String get menuDelete => 'Eliminar'; - - @override - String get editColorLabel => 'Color'; - - @override - String get colorRed => 'Rojo'; - - @override - String get colorGreen => 'Verde'; - - @override - String get colorPurple => 'Morado'; - - @override - String get colorYellow => 'Amarillo'; - - @override - String get colorBlue => 'Azul'; - - @override - String get colorOrange => 'Naranja'; - - @override - String get typeText => 'Texto'; - - @override - String get typeImage => 'Imagen'; - - @override - String get typeFile => 'Archivo'; - - @override - String get typeFolder => 'Carpeta'; - - @override - String get typeLink => 'Enlace'; - - @override - String get typeAudio => 'Audio'; - - @override - String get typeVideo => 'Video'; - - @override - String get typeEmail => 'Email'; - - @override - String get typePhone => 'Teléfono'; - - @override - String get typeColor => 'Color'; - - @override - String get typeIp => 'IP'; - - @override - String get typeUuid => 'UUID'; - - @override - String get typeJson => 'JSON'; - - @override - String get filterAll => 'Todo'; - - @override - String get filterPinned => 'Anclados'; - - @override - String get trayTooltip => 'CopyPaste'; - - @override - String get trayExit => 'Salir'; - - @override - String get subtitleShortcutScopes => - 'Ctrl+V pertenece a la aplicación activa. Los atajos del historial funcionan mientras el panel de CopyPaste está abierto.'; - - @override - String get shortcutOpenClose => - 'Global de CopyPaste: Abrir / cerrar CopyPaste'; - - @override - String get shortcutPastePlainDirect => - 'Global de CopyPaste: Pegar el portapapeles actual como texto plano'; - - @override - String get shortcutSystemPaste => - 'Aplicación activa: Pegar normalmente el portapapeles actual (CopyPaste no lo intercepta)'; - - @override - String get shortcutEscape => 'Limpiar búsqueda o cerrar ventana'; - - @override - String get shortcutTab1 => 'Cambiar a pestaña Recientes'; - - @override - String get shortcutTab2 => 'Cambiar a pestaña Anclados'; - - @override - String get shortcutArrows => 'Navegar entre elementos'; - - @override - String get shortcutEnter => - 'Con CopyPaste abierto: Pegar normalmente el elemento bajo el cursor, el seleccionado o el primero'; - - @override - String get shortcutPasteSelectedPlain => - 'Con CopyPaste abierto: Pegar como texto plano el elemento bajo el cursor, el seleccionado o el primero'; - - @override - String get shortcutDelete => 'Eliminar elemento seleccionado'; - - @override - String get shortcutPin => 'Anclar / Desanclar elemento'; - - @override - String get shortcutEdit => 'Editar tarjeta (etiqueta y color)'; - - @override - String get tabGeneral => 'General'; - - @override - String get tabBackupRestore => 'Backup y soporte'; - - @override - String get tabAppearance => 'Apariencia'; - - @override - String get tabShortcuts => 'Atajos'; - - @override - String get tabAbout => 'Acerca de'; - - @override - String get sectionLanguage => 'IDIOMA'; - - @override - String get sectionStartup => 'INICIO'; - - @override - String get sectionKeyboardShortcut => 'ATAJO DE TECLADO'; - - @override - String get sectionCategories => 'CATEGORÍAS'; - - @override - String get sectionPerformance => 'RENDIMIENTO'; - - @override - String get sectionPaste => 'PEGADO'; - - @override - String get sectionBackupRestore => 'RESPALDO Y RESTAURACIÓN'; - - @override - String get sectionAppearance => 'APARIENCIA'; - - @override - String get settingTheme => 'Tema'; - - @override - String get themeLight => 'Claro'; - - @override - String get themeDark => 'Oscuro'; - - @override - String get themeAuto => 'Auto'; - - @override - String get sectionBehavior => 'COMPORTAMIENTO'; - - @override - String get sectionAbout => 'COPYPASTE'; - - @override - String get sectionLinks => 'ENLACES'; - - @override - String get settingItemsPerPage => 'Elementos por página'; - - @override - String get settingMemoryLimit => 'Límite de memoria'; - - @override - String get settingScrollThreshold => 'Umbral de desplazamiento (px)'; - - @override - String get settingPasteSpeed => 'Velocidad de pegado'; - - @override - String get settingPanelWidth => 'Ancho del panel (px)'; - - @override - String get settingPanelHeight => 'Alto del panel (px)'; - - @override - String get settingLinesCollapsed => 'Líneas contraídas'; - - @override - String get settingLinesExpanded => 'Líneas expandidas'; - - @override - String get settingHideOnDeactivate => 'Ocultar al hacer clic fuera'; - - @override - String get settingRememberWindowPosition => 'Recordar posición de la ventana'; - - @override - String get settingScrollToTopOnOpen => 'Ir al inicio al abrir'; - - @override - String get settingClearSearchOnOpen => 'Limpiar búsqueda al abrir'; - - @override - String get settingRetentionDaysLabel => 'Días de retención (0 = sin límite)'; - - @override - String get settingClearHistoryLabel => 'Limpiar historial del portapapeles'; - - @override - String get settingHotkeyShortcutLabel => 'Atajo para abrir/cerrar CopyPaste'; - - @override - String get subtitleGlobalHotkeyWarning => - 'Atajo global del sistema. Puede reemplazar la misma combinación en otra aplicación.'; - - @override - String get settingPlainPasteHotkeyLabel => - 'Pegado global opcional como texto plano'; - - @override - String get subtitlePlainPasteHotkey => - 'Pega el portapapeles actual como texto plano sin abrir CopyPaste. Activar un atajo global puede reemplazar el mismo atajo en otras aplicaciones.'; - - @override - String get shortcutDisabled => 'Desactivado'; - - @override - String currentShortcut(String shortcut) { - return 'Actual: $shortcut'; - } - - @override - String get hotkeyRequiresModifier => - 'Agrega al menos una tecla modificadora antes de guardar este atajo.'; - - @override - String get hotkeyConflictWarning => - 'Esta combinación ya está asignada al otro atajo de CopyPaste.'; - - @override - String get restoreRecommendedHotkeys => 'Restaurar atajos recomendados'; - - @override - String get plainPasteHotkeyRegistrationFailed => - 'No se pudo registrar el atajo de pegado directo como texto plano. Es posible que el sistema u otra aplicación ya lo esté usando.'; - - @override - String get pasteDestinationUnavailable => - 'Se canceló el pegado porque no se pudo restaurar el destino original. Abre CopyPaste con su atajo de teclado e inténtalo nuevamente.'; - - @override - String get plainPasteItemUnavailable => - 'El elemento bajo el cursor, el seleccionado o el primero no se puede pegar como texto plano.'; - - @override - String get plainClipboardUnavailable => - 'No hay texto en el portapapeles. Copia primero un texto y vuelve a usar el pegado como texto plano.'; - - @override - String get clipboardWriteFailed => - 'No se pudo copiar el elemento al portapapeles porque otra aplicación lo tiene retenido. Vuelve a intentarlo en un momento.'; - - @override - String get pasteTargetElevated => - 'La aplicación de destino se ejecuta como administrador, así que Windows bloquea el pegado simulado. Ejecuta CopyPaste también como administrador o pulsa Ctrl+V tú mismo.'; - - @override - String hotkeyRegistrationFailed(String shortcut) { - return 'No se pudo registrar el atajo $shortcut. Es posible que el sistema u otra aplicación ya lo esté usando.'; - } - - @override - String hotkeyFallbackActive(String requested, String effective) { - return 'El atajo $requested no estaba disponible. CopyPaste está usando temporalmente $effective.'; - } - - @override - String get subtitleStartupDesc => - 'Se inicia en segundo plano al iniciar sesión'; - - @override - String get subtitleHideOnDeactivate => - 'Cerrar la ventana al hacer clic fuera'; - - @override - String get subtitleRememberWindowPosition => - 'Reabrir la ventana donde la dejaste la última vez'; - - @override - String get subtitleScrollToTopOnOpen => - 'Restablece el desplazamiento y selecciona el último elemento'; - - @override - String get subtitleClearSearchOnOpen => 'Borra el texto de búsqueda cada vez'; - - @override - String get subtitlePasteSpeed => 'Ajustar tiempos de restauración y pegado'; - - @override - String get subtitleCategories => - 'Personaliza los nombres de las categorías de color.'; - - @override - String get linkGitHub => 'Soporte y Código fuente — GitHub'; - - @override - String get linkCoffee => 'Invítame un café'; - - @override - String get editDialogTitle => 'Etiqueta y Color'; - - @override - String get editDialogHint => 'Agregar una etiqueta...'; - - @override - String get historyCleared => 'Historial limpiado'; - - @override - String backupSavedFile(String filename) { - return 'Respaldo guardado: $filename'; - } - - @override - String get buttonRestore => 'Restaurar'; - - @override - String get restoreCompleted => 'Restauración completada'; - - @override - String get restoreRestartRequired => - 'Restauración completada. La app se reiniciará para aplicar los cambios.'; - - @override - String get shortcutExpand => 'Expandir / contraer tarjeta'; - - @override - String get shortcutFocusSearch => 'Enfocar el buscador'; - - @override - String get trayShowHide => 'Mostrar/Ocultar'; - - @override - String get fileNotFound => 'No encontrado'; - - @override - String get audioFile => 'Archivo de audio'; - - @override - String get videoFile => 'Archivo de video'; - - @override - String get imageFile => 'Archivo de imagen'; - - @override - String get timeNow => 'ahora'; - - @override - String get clearAllFilters => 'Limpiar todos los filtros'; - - @override - String get colorSectionLabel => 'COLOR'; - - @override - String get colorNone => 'Ninguno'; - - @override - String get subtitlePastePreset => - 'Velocidad de pegado automático. Instantáneo está optimizado para Windows; usa Seguro si alguna aplicación no recibe el pegado.'; - - @override - String get subtitlePastePresetStandard => - 'Velocidad de pegado automático. Normal/Seguro recomendado para la mayoría.'; - - @override - String get pastePresetInstant => 'Instantáneo'; - - @override - String get pastePresetFast => 'Rápido'; - - @override - String get pastePresetNormal => 'Normal'; - - @override - String get pastePresetSafe => 'Seguro'; - - @override - String get pastePresetSlow => 'Lento'; - - @override - String get pastePresetCustom => 'Personalizado'; - - @override - String get pastePresetWarning => - '⚡ Instantáneo (Windows): latencia mínima con verificación nativa del foco.\n⚠️ Rápido: puede causar comportamientos extraños en apps pesadas.\n⚠️ Lento: puede sentirse pesado en equipos modernos.'; - - @override - String get pastePresetWarningStandard => - '⚠️ Rápido: puede causar comportamientos extraños en apps pesadas.\n⚠️ Lento: puede sentirse pesado en equipos modernos.'; - - @override - String get settingResetFiltersOnOpen => 'Volver a Todos al abrir'; - - @override - String get subtitleResetFiltersOnOpen => - 'Limpia los filtros de categoría y tipo, y vuelve a la pestaña Todos'; - - @override - String get subtitleBackup => - 'Crea un respaldo de tu historial, imágenes y configuración. Restaura en cualquier momento en este u otro dispositivo.'; - - @override - String get aboutDescription => - 'Un gestor de portapapeles moderno, nativo en Windows y macOS.\nTodo local — tu historial, siempre a mano. Sin cuentas, sin telemetría, sin suscripciones.'; - - @override - String get sectionPrivacy => 'PRIVACIDAD'; - - @override - String get privacyStatement => - 'Todo local. Nada sale de tu PC — sin telemetría, sin sincronización, sin cuentas.'; - - @override - String get privacyPolicy => 'Política de privacidad'; - - @override - String get aboutTagLocal => 'Todo local'; - - @override - String get aboutTagOpenSource => 'Código abierto'; - - @override - String get aboutTagFree => 'Gratis'; - - @override - String get sectionOtherTools => 'OTRAS HERRAMIENTAS'; - - @override - String get otherToolLinkUnbound => 'LinkUnbound'; - - @override - String get otherToolLinkUnboundDesc => - 'Selector de navegadores de código abierto para Windows y Mac. Misma filosofía: sin anuncios, sin telemetría, todo local.'; - - @override - String get aboutLicense => 'Licencia GPL v3 — Libre y de código abierto.'; - - @override - String get permissionsTitle => 'Permiso de Accesibilidad requerido'; - - @override - String get permissionsMessage => - 'CopyPaste necesita permiso de Accesibilidad para pegar contenido en otras apps.\n\nVe a Configuración del Sistema → Privacidad y Seguridad → Accesibilidad y activa CopyPaste.'; - - @override - String get permissionsOpenSettings => 'Abrir Configuración'; - - @override - String get permissionsDismiss => 'Después'; - - @override - String get permissionsGranted => 'Permiso concedido'; - - @override - String get permissionsResetTitle => 'Permiso de Accesibilidad perdido'; - - @override - String get permissionsResetMessage => - 'macOS ya no reconoce el permiso de CopyPaste porque la app fue re-autorizada a través de Gatekeeper.\n\nPara solucionarlo:\n1. Abre la configuración de Accesibilidad\n2. Elimina CopyPaste de la lista (−)\n3. Vuelve a añadirlo o actívalo de nuevo'; - - @override - String get permissionsRestartMessage => - 'Asegúrate de que CopyPaste esté activado en Privacidad y seguridad > Accesibilidad.\n\nLa app continuará automáticamente cuando detecte el permiso.'; - - @override - String get permissionsCheckAgain => 'Verificar'; - - @override - String get permissionsRestartApp => 'Reiniciar app'; - - @override - String get permissionsWaiting => 'Esperando permiso…'; - - @override - String updateBadge(String version) { - return 'v$version disponible, por favor actualiza'; - } - - @override - String updateAvailableWindows(String version) { - return 'La versión $version está disponible.\n\nDescarga el instalador más reciente desde GitHub.'; - } - - @override - String updateAvailableMac(String version) { - return 'La versión $version está disponible.\n\nActualiza con Homebrew:\nbrew upgrade copypaste\n\nO descarga la última versión desde GitHub.'; - } - - @override - String updateAvailableStore(String version) { - return 'La versión $version está disponible.\n\nLa Microsoft Store entrega las actualizaciones automáticamente. Las nuevas versiones pueden tardar unos días en aparecer tras su publicación.'; - } - - @override - String updateTooltipStore(String version) { - return 'Actualización $version en camino por Microsoft Store'; - } - - @override - String updateTooltipGeneric(String version) { - return 'Actualización $version disponible — haz clic para detalles'; - } - - @override - String get updateDialogTitle => 'Actualización disponible'; - - @override - String get updateViewRelease => 'Ver versión'; - - @override - String get updateDismiss => 'Después'; - - @override - String updateBadgeImportant(String version) { - return 'v$version disponible — actualización importante'; - } - - @override - String get updateActionDownload => 'Descargar instalador'; - - @override - String get updateActionOpenStore => 'Abrir Microsoft Store'; - - @override - String updateActionCopyCommand(String tool) { - return 'Copiar comando $tool'; - } - - @override - String get updateActionCopied => 'Copiado al portapapeles'; - - @override - String get blockedTitle => 'Actualización requerida'; - - @override - String blockedDescription(String current, String required) { - return 'La versión $current de CopyPaste ya no está soportada. Instala la versión $required o más reciente para continuar.'; - } - - @override - String get blockedReasonGeneric => - 'Esta versión fue retirada por motivos de seguridad o compatibilidad.'; - - @override - String get blockedQuit => 'Salir de CopyPaste'; - - @override - String get blockedFallbackHint => - 'Visita https://github.com/rgdevment/CopyPaste/releases para descargar el instalador más reciente.'; - - @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.'; - } - - @override - String taskbarOpenHint(String hotkey) { - return 'Tip: presiona $hotkey para abrir y pegar automáticamente, sin perder el foco.'; - } - - @override - String balloonStartupBody(String hotkey) { - return 'Ejecutándose en segundo plano. Presiona $hotkey o haz clic en el ícono de la bandeja.'; - } - - @override - String get balloonWakeupTitle => 'CopyPaste ya está abierto'; - - @override - String balloonWakeupBody(String hotkey) { - return 'Presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo.'; - } - - @override - String get onboardingTitle => 'Bienvenido a CopyPaste'; - - @override - String get onboardingSubtitle => 'Todo lo que copias, guardado.'; - - @override - String get onboardingPrivacyBadge => 'Sin nube · Sin rastreo · 100% local'; - - @override - String onboardingDescription(String hotkey) { - return 'Corre en segundo plano sin que lo notes. Presiona $hotkey cuando quieras para abrir tu historial.'; - } - - @override - String get onboardingTrayHint => - 'Encuéntralo junto al reloj, abajo a la derecha.'; - - @override - String get onboardingSettingsButton => 'Configuración'; - - @override - String get onboardingDismissButton => 'Empezar'; - - @override - String get tabCapture => 'Rendimiento'; - - @override - String get tabMultimedia => 'Multimedia'; - - @override - String get tabCleanupPrivacy => 'Limpieza y privacidad'; - - @override - String get sectionMultimedia => 'MULTIMEDIA Y MINIATURAS'; - - @override - String get subtitleMultimedia => - 'Controla cómo se previsualizan imágenes, vídeos y archivos de audio.'; - - @override - String get settingGenerateImageThumbnails => 'Generar miniaturas de imágenes'; - - @override - String get subtitleGenerateImageThumbnails => - 'Muestra una vista previa de las imágenes copiadas o referenciadas.'; - - @override - String get settingGenerateVideoThumbnails => 'Generar miniaturas de vídeos'; - - @override - String get subtitleGenerateVideoThumbnails => - 'Usa la caché del sistema para mostrar un fotograma de los vídeos.'; - - @override - String get settingGenerateAudioThumbnails => 'Generar miniaturas de audio'; - - @override - String get subtitleGenerateAudioThumbnails => - 'Muestra la carátula cuando esté disponible.'; - - @override - String get settingMaxImageSize => 'Tamaño máximo a procesar (MB)'; - - @override - String get subtitleMaxImageSize => - 'Las imágenes más grandes mantienen su mapa de bits original sin reprocesarse.'; - - @override - String get sectionCleanupPrivacy => 'LIMPIEZA Y PRIVACIDAD'; - - @override - String get settingKeepBrokenItemsLabel => - 'Conservar elementos no disponibles (días)'; - - @override - String get subtitleKeepBrokenItems => - 'Los elementos que apuntan a archivos perdidos o volúmenes desconectados se eliminan tras estos días. 0 los elimina al instante.'; - - @override - String get settingImagesQuotaLabel => - 'Límite de almacenamiento para imágenes'; - - @override - String get subtitleImagesQuota => - 'Cuando la carpeta de imágenes supera este tamaño, se eliminan los elementos más antiguos no fijados para liberar espacio.'; - - @override - String get imagesQuotaOff => 'Sin límite'; -} diff --git a/app/lib/main.dart b/app/lib/main.dart deleted file mode 100644 index 53f77981..00000000 --- a/app/lib/main.dart +++ /dev/null @@ -1,1426 +0,0 @@ -// coverage:ignore-file -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:ui' show PlatformDispatcher; - -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_acrylic/flutter_acrylic.dart'; -import 'package:listener/listener.dart'; -import 'package:path/path.dart' as p; -import 'package:window_manager/window_manager.dart'; - -import 'services/auto_update_service.dart'; -import 'services/install_channel.dart'; -import 'services/release_manifest_service.dart'; - -import 'shell/app_window.dart'; -import 'shell/focus_manager.dart'; -import 'shell/hotkey_binding.dart'; -import 'shell/hotkey_handler.dart'; -import 'shell/single_instance.dart'; -import 'shell/startup_helper.dart'; -import 'shell/tray_icon.dart'; -import 'shell/win_known_folders.dart'; -import 'shell/win_package_context.dart'; -import 'shell/desktop_notifier.dart'; -import 'screens/main_screen.dart'; -import 'screens/settings_screen.dart'; -import 'theme/compact_theme.dart'; -import 'theme/theme_provider.dart'; -import 'l10n/app_localizations.dart'; -import 'screens/permission_gate_screen.dart'; -import 'screens/desktop_onboarding_screen.dart'; -import 'screens/blocked_version_screen.dart'; - -bool _isMicaDark(String themeMode) => switch (themeMode) { - 'dark' => true, - 'auto' || - 'system' => PlatformDispatcher.instance.platformBrightness == Brightness.dark, - _ => false, -}; - -void main() async { - await runZonedGuarded>(_run, (error, stack) { - CrashLogger.report(error, stack, context: 'zoneGuarded'); - AppLogger.error('Zone unhandled: $error\n$stack'); - }); -} - -Future _run() async { - try { - WidgetsFlutterBinding.ensureInitialized(); - - if (!SingleInstance.acquire()) { - exit(0); - } - - await windowManager.ensureInitialized(); - - bool acrylicInitialized = false; - if (Platform.isWindows || Platform.isMacOS) { - try { - await Window.initialize().timeout(const Duration(seconds: 3)); - acrylicInitialized = true; - } catch (e, s) { - CrashLogger.report(e, s, context: 'Window.initialize'); - } - } - - final storage = await StorageConfig.create( - windowsLocalAppDataResolver: Platform.isWindows - ? WinKnownFolders.localAppData - : null, - ); - await storage.ensureDirectories(); - CrashLogger.initialize(storage.baseDir); - AppLogger.initialize(storage.logsPath); - final isMsix = Platform.isWindows && WinPackageContext.isMsix; - AppLogger.info( - 'Bootstrap: CopyPaste ${AppConfig.appVersion} starting ' - '(platform=${Platform.operatingSystem}, ' - 'osVersion=${Platform.operatingSystemVersion}, ' - 'msix=$isMsix, ' - 'package=${WinPackageContext.packageFullName ?? '-'}, ' - 'base=${storage.baseDir}, ' - 'acrylicInit=$acrylicInitialized)', - ); - - FlutterError.onError = (details) { - AppLogger.error( - 'FlutterError: ${details.exceptionAsString()}\n${details.stack}', - ); - CrashLogger.report( - details.exception, - details.stack, - context: 'FlutterError', - ); - }; - PlatformDispatcher.instance.onError = (error, stack) { - AppLogger.error('Unhandled: $error\n$stack'); - CrashLogger.report(error, stack, context: 'PlatformDispatcher'); - return false; - }; - - final config = await AppConfig.load(storage.configFilePath); - - final repo = SqliteRepository.fromPath(storage.databasePath); - final NativeThumbnailProvider? nativeThumbProvider = Platform.isWindows - ? WindowsNativeThumbnailProvider() - : Platform.isMacOS - ? MacOSNativeThumbnailProvider() - : null; - final clipboardService = ClipboardService( - repo, - imagesPath: storage.imagesPath, - nativeThumbnailProvider: nativeThumbProvider, - isThumbnailTypeEnabled: (t) => switch (t) { - ClipboardContentType.image => config.generateImageThumbnails, - ClipboardContentType.video => config.generateVideoThumbnails, - ClipboardContentType.audio => config.generateAudioThumbnails, - _ => true, - }, - getMaxImageBytes: () => config.maxImageProcessingSizeMB * 1024 * 1024, - )..pasteIgnoreWindowMs = config.duplicateIgnoreWindowMs; - - final cleanupService = CleanupService( - repo, - () => config.retentionDays, - storage: storage, - getKeepBrokenDays: () => config.keepBrokenItemsDays, - getImagesQuotaMB: () => config.imagesQuotaMB, - )..start(storage.baseDir); - - final listener = ClipboardListener(); - - await StartupHelper.apply(config.runOnStartup); - - try { - if (Platform.isWindows) { - AppLogger.info('main: applying initial Mica effect'); - await Window.setEffect( - effect: WindowEffect.mica, - color: const Color(0x00000000), - dark: _isMicaDark(config.themeMode), - ).timeout(const Duration(seconds: 2)); - AppLogger.info('main: Mica effect applied'); - } else if (Platform.isMacOS) { - await Window.setEffect( - effect: WindowEffect.sidebar, - color: const Color(0x00000000), - dark: _isMicaDark(config.themeMode), - ).timeout(const Duration(seconds: 2)); - } - } catch (e) { - AppLogger.warn('main: Window.setEffect failed (non-fatal): $e'); - } - - runApp( - CopyPasteApp( - storage: storage, - config: config, - repo: repo, - clipboardService: clipboardService, - cleanupService: cleanupService, - listener: listener, - ), - ); - } catch (e, s) { - CrashLogger.report(e, s, context: 'main'); - rethrow; - } -} - -class CopyPasteApp extends StatefulWidget { - const CopyPasteApp({ - required this.storage, - required this.config, - required this.repo, - required this.clipboardService, - required this.cleanupService, - required this.listener, - super.key, - }); - - final StorageConfig storage; - final AppConfig config; - final SqliteRepository repo; - final ClipboardService clipboardService; - final CleanupService cleanupService; - final ClipboardListener listener; - - @override - State createState() => _CopyPasteAppState(); -} - -class _CopyPasteAppState extends State - with WindowListener, WidgetsBindingObserver { - late final AppWindow _appWindow; - late final TrayIcon _trayIcon; - late HotkeyHandler _hotkeyHandler; - late AppConfig _config; - final WindowFocusManager _focusManager = WindowFocusManager(); - final WindowFocusManager _directPasteFocusManager = WindowFocusManager(); - final _mainScreenKey = GlobalKey(); - final _navigatorKey = GlobalKey(); - StreamSubscription? _listenerSubscription; - String? _lastTrayLocale; - Future? _pendingConfigSave; - bool _showPermissionGate = false; - bool _showOnboarding = false; - String? _availableUpdateVersion; - ManifestState? _manifestState; - bool _programmaticRestore = false; - bool _hotkeyToggleInProgress = false; - bool _directPlainPasteInProgress = false; - bool _itemPasteInProgress = false; - bool _shuttingDown = false; - Future? _cleanupFuture; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _config = widget.config; - _appWindow = AppWindow( - onVisibilityChanged: _onWindowVisibilityChanged, - popupWidth: _config.popupWidth.toDouble(), - popupHeight: _config.popupHeight.toDouble(), - rememberPositionEnabled: () => _config.rememberWindowPosition, - savedPositionProvider: () { - final x = _config.lastWindowX; - final y = _config.lastWindowY; - if (x == null || y == null) return null; - return (x, y); - }, - onPositionPersist: _onPositionPersist, - ); - _trayIcon = TrayIcon(onToggle: _toggleWindow, onExit: _exitApp); - _hotkeyHandler = HotkeyHandler( - config: _config, - onHotkey: _onHotkey, - onPlainPasteHotkey: _onPlainPasteHotkey, - ); - - unawaited( - _initShell().catchError( - (Object e, StackTrace s) => - AppLogger.error('_initShell failed: $e\n$s'), - ), - ); - } - - @override - void didChangePlatformBrightness() { - if (_config.themeMode == 'auto' && - (Platform.isWindows || Platform.isMacOS)) { - unawaited( - _appWindow - .applyEffect(dark: _isMicaDark('auto')) - .catchError( - (Object e) => AppLogger.error('applyEffect failed: $e'), - ), - ); - } - } - - Future _initShell() async { - var initCompleted = false; - final watchdog = Timer(const Duration(seconds: 10), () { - if (initCompleted) return; - AppLogger.error('Watchdog: _initShell did not complete within 10s'); - CrashLogger.report( - StateError('Init watchdog fired'), - StackTrace.current, - context: '_initShell watchdog', - ); - unawaited(_forceVisibleFallback()); - }); - try { - await _initShellBody(); - } catch (e, s) { - AppLogger.error('_initShell crashed: $e\n$s'); - CrashLogger.report(e, s, context: '_initShell'); - await _forceVisibleFallback(); - } finally { - initCompleted = true; - watchdog.cancel(); - } - } - - Future _forceVisibleFallback() async { - try { - if (!_appWindow.isGateMode) { - await _appWindow.enterGateMode(); - } - } catch (e) { - AppLogger.error('forceVisibleFallback failed: $e'); - } - } - - Future _initShellBody() async { - windowManager.addListener(this); - final isFirstRun = widget.storage.isFirstRun; - _startListening(); - - bool macosGranted = true; - if (Platform.isMacOS) { - macosGranted = await ClipboardWriter.checkAccessibility(); - } - - final isUpdate = _config.lastRunVersion != AppConfig.appVersion; - final desktopNeedsOnboarding = - Platform.isWindows && !_config.hasSeenOnboarding; - final showOnStart = - isFirstRun && - ((Platform.isMacOS && macosGranted) || Platform.isWindows) || - desktopNeedsOnboarding; - await _appWindow.init(startVisible: showOnStart); - if (showOnStart && Platform.isWindows) { - try { - await _appWindow.enterGateMode(); - } catch (e) { - AppLogger.error('Initial enterGateMode failed: $e'); - } - } - SingleInstance.listenForWakeup(() { - if (Platform.isWindows) { - unawaited(_showOnboardingFromWakeup()); - } else { - unawaited(_safeShow()); - } - }); - - try { - if (Platform.isWindows || Platform.isMacOS) { - await _appWindow.applyEffect(dark: _isMicaDark(_config.themeMode)); - } - } catch (e) { - AppLogger.error('applyEffect in _initShell failed: $e'); - } - - try { - await _trayIcon.init(); - } catch (e) { - AppLogger.error('trayIcon.init failed: $e'); - } - - if (Platform.isWindows && !isFirstRun && _config.hasSeenOnboarding) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => unawaited(_showStartupBalloon()), - ); - } - - await _registerHotkeyWithFeedback(); - - if (Platform.isMacOS) { - if (!macosGranted) { - setState(() => _showPermissionGate = true); - await _appWindow.enterGateMode(); - } else { - if (!_config.accessibilityWasGranted) { - unawaited( - _persistConfig((c) => c.copyWith(accessibilityWasGranted: true)), - ); - } - if (isFirstRun) { - widget.storage.markAsInitialized(); - } - } - } else { - final shouldShowOnboarding = desktopNeedsOnboarding; - if (shouldShowOnboarding) { - if (isFirstRun) widget.storage.markAsInitialized(); - if (mounted) setState(() => _showOnboarding = true); - if (!_appWindow.isGateMode) { - try { - await _appWindow.enterGateMode(); - } catch (e) { - AppLogger.error('enterGateMode (post-init) failed: $e'); - } - } - } else if (isFirstRun) { - widget.storage.markAsInitialized(); - } - if (isUpdate && Platform.isWindows) { - unawaited( - _persistConfig( - (c) => c.copyWith(lastRunVersion: AppConfig.appVersion), - ), - ); - } - } - - AutoUpdateService.onUpdateAvailable = _onUpdateAvailable; - unawaited( - AutoUpdateService.initialize(storageConfigDir: widget.storage.configPath), - ); - if (_needsClassifierMigration(_config.lastRunVersion)) { - unawaited(_runClassifierMigration()); - } - } - - Future _runClassifierMigration() async { - try { - await widget.clipboardService.reclassifyLegacyTextItems(); - } catch (e, s) { - AppLogger.error('Classifier migration failed: $e\n$s'); - return; // version not saved → retries on next startup - } - unawaited( - _persistConfig((c) => c.copyWith(lastRunVersion: AppConfig.appVersion)), - ); - } - - static bool _needsClassifierMigration(String lastVersion) { - if (lastVersion.isEmpty) return true; - final parts = lastVersion.split('.'); - if (parts.length < 3) return true; - final major = int.tryParse(parts[0]) ?? 0; - final minor = int.tryParse(parts[1]) ?? 0; - final patch = int.tryParse(parts[2]) ?? 0; - if (major < 2) return true; - if (major == 2 && minor < 1) return true; - if (major == 2 && minor == 1 && patch <= 5) return true; - return false; - } - - Future _registerHotkeyWithFeedback() async { - final result = await _hotkeyHandler.registerWithFallback(); - _reportPlainPasteHotkeyFailure(); - if (result.status == HotkeyRegistrationStatus.failed) { - _showShellNotice( - (l) => l.hotkeyRegistrationFailed(result.requestedBinding.label()), - ); - } else if (result.status == HotkeyRegistrationStatus.fallbackRegistered) { - _showShellNotice( - (l) => l.hotkeyFallbackActive( - result.requestedBinding.label(), - result.effectiveBinding?.label() ?? '', - ), - ); - } - } - - void _reportPlainPasteHotkeyFailure() { - if (!_config.plainPasteHotkeyEnabled || - _hotkeyHandler.plainPasteRegistrationSucceeded != false) { - return; - } - AppLogger.error('The direct plain-text paste hotkey is unavailable'); - _showShellNotice((l) => l.plainPasteHotkeyRegistrationFailed); - } - - ThemeMode get _effectiveThemeMode { - final mode = _config.themeMode; - return switch (mode) { - 'dark' => ThemeMode.dark, - 'auto' || 'system' => ThemeMode.system, - _ => ThemeMode.light, - }; - } - - void _showShellNotice( - String Function(AppLocalizations l) messageBuilder, { - bool revealWhenHidden = false, - }) { - if (revealWhenHidden && !_appWindow.isVisible) { - unawaited(_revealAndShowShellNotice(messageBuilder)); - return; - } - _enqueueShellNotice(messageBuilder); - } - - Future _revealAndShowShellNotice( - String Function(AppLocalizations l) messageBuilder, - ) async { - await _safeShow(); - _enqueueShellNotice(messageBuilder); - } - - void _enqueueShellNotice(String Function(AppLocalizations l) messageBuilder) { - WidgetsBinding.instance.addPostFrameCallback((_) { - final ctx = _navigatorKey.currentContext; - if (ctx == null || !ctx.mounted) return; - final message = messageBuilder(AppLocalizations.of(ctx)); - final messenger = ScaffoldMessenger.maybeOf(ctx); - if (messenger == null) return; - - messenger.showSnackBar( - SnackBar(content: Text(message), duration: const Duration(seconds: 12)), - ); - }); - } - - void _startListening() { - if (!Platform.isWindows && !Platform.isMacOS) return; - AppLogger.info('_startListening: subscribing to clipboard event stream'); - _listenerSubscription = widget.listener.onEvent.listen( - _onClipboardEvent, - onError: (Object e, StackTrace s) { - AppLogger.error('Clipboard listener error: $e\n$s'); - // Re-subscribe so a single error does not permanently stop capturing. - _listenerSubscription?.cancel(); - _startListening(); - }, - cancelOnError: false, - ); - } - - Future _onClipboardEvent(ClipboardEvent event) async { - for (var attempt = 0; attempt < 3; attempt++) { - try { - await _processClipboardEvent(event); - return; - } catch (e, s) { - if (attempt < 2) { - await Future.delayed( - Duration(milliseconds: 500 * (attempt + 1)), - ); - } else { - AppLogger.error('Clipboard event failed after 3 retries: $e\n$s'); - } - } - } - } - - Future _processClipboardEvent(ClipboardEvent event) async { - switch (event.type) { - case ClipboardContentType.text: - case ClipboardContentType.link: - await widget.clipboardService.processText( - event.text ?? '', - event.type, - source: event.source, - rtfBytes: event.rtfBytes, - htmlBytes: event.htmlBytes, - ); - case ClipboardContentType.image: - if (event.bytes != null && event.bytes!.isNotEmpty) { - await widget.clipboardService.processImage( - event.contentHash, - source: event.source, - imageBytes: event.bytes, - ); - } else if (event.files != null && event.files!.isNotEmpty) { - final item = await widget.clipboardService.processImage( - event.contentHash, - source: event.source, - imagePath: event.files!.first, - ); - if (item != null) { - unawaited(_processMediaMetadata(item, event.files!.first)); - } - } - case ClipboardContentType.file: - case ClipboardContentType.folder: - if (event.files != null && event.files!.isNotEmpty) { - await widget.clipboardService.processFiles( - event.files!, - event.type, - source: event.source, - ); - } - case ClipboardContentType.audio: - case ClipboardContentType.video: - if (event.files != null && event.files!.isNotEmpty) { - final item = await widget.clipboardService.processFiles( - event.files!, - event.type, - source: event.source, - ); - if (item != null) { - unawaited(_processMediaMetadata(item, event.files!.first)); - } - } - case ClipboardContentType.email: - case ClipboardContentType.phone: - case ClipboardContentType.color: - case ClipboardContentType.ip: - case ClipboardContentType.uuid: - case ClipboardContentType.json: - case ClipboardContentType.unknown: - break; - } - } - - Future _processMediaMetadata( - ClipboardItem item, - String filePath, - ) async { - try { - final meta = {}; - if (item.metadata != null && item.metadata!.isNotEmpty) { - final existing = jsonDecode(item.metadata!) as Map; - existing.forEach((k, v) { - if (v != null) meta[k] = v as Object; - }); - } - - final mediaInfo = await ClipboardWriter.getMediaInfo(filePath); - if (mediaInfo != null) { - mediaInfo.forEach((k, v) { - if (v != null) meta[k] = v; - }); - } - - if (meta.isNotEmpty) { - await widget.clipboardService.updateMetadata(item.id, jsonEncode(meta)); - } - } catch (e, s) { - AppLogger.error('Media metadata failed: $e\n$s'); - } - } - - Future _showOnboardingFromWakeup() async { - if (_showOnboarding || _appWindow.isSettingsMode) { - try { - await windowManager.show(); - await windowManager.focus(); - } catch (_) {} - return; - } - setState(() => _showOnboarding = true); - try { - await _appWindow.enterGateMode(); - } catch (e) { - AppLogger.error('enterGateMode failed on wakeup: $e'); - } - unawaited(_showWakeupBalloon()); - } - - /// Shows the window safely — errors from Mica/acrylic effects are logged - /// but never propagate to callers (e.g. the wakeup signal callback). - Future _safeShow() async { - try { - await _appWindow.show(); - } catch (e) { - AppLogger.error('show failed: $e'); - } - } - - Future _showWakeupBalloon() async { - final binding = HotkeyBinding( - virtualKey: _config.hotkeyVirtualKey, - keyName: _config.hotkeyKeyName, - useCtrl: _config.hotkeyUseCtrl, - useWin: _config.hotkeyUseWin, - useAlt: _config.hotkeyUseAlt, - useShift: _config.hotkeyUseShift, - ); - final ctx = _navigatorKey.currentContext; - final l = ctx != null && ctx.mounted ? AppLocalizations.of(ctx) : null; - await DesktopNotifier.show( - title: l?.balloonWakeupTitle ?? 'CopyPaste is already open', - body: - l?.balloonWakeupBody(binding.label()) ?? - 'Press ${binding.label()} or click the tray icon to bring it up.', - ); - } - - Future _showStartupBalloon() async { - final binding = HotkeyBinding( - virtualKey: _config.hotkeyVirtualKey, - keyName: _config.hotkeyKeyName, - useCtrl: _config.hotkeyUseCtrl, - useWin: _config.hotkeyUseWin, - useAlt: _config.hotkeyUseAlt, - useShift: _config.hotkeyUseShift, - ); - final ctx = _navigatorKey.currentContext; - final l = ctx != null && ctx.mounted ? AppLocalizations.of(ctx) : null; - await DesktopNotifier.show( - title: 'CopyPaste', - body: - l?.balloonStartupBody(binding.label()) ?? - 'Running in the background. Press ${binding.label()} or click the tray icon.', - ); - } - - Future _onHotkey() async { - if (_shuttingDown || _hotkeyToggleInProgress) return; - _hotkeyToggleInProgress = true; - _programmaticRestore = true; - try { - if (_appWindow.isVisible) { - await _closePanel(); - return; - } - await _focusManager.capturePreviousWindow(); - await _appWindow.show(); - } finally { - _programmaticRestore = false; - _hotkeyToggleInProgress = false; - } - } - - Future _onPlainPasteHotkey() async { - if (_shuttingDown || _directPlainPasteInProgress || _itemPasteInProgress) { - return; - } - _directPlainPasteInProgress = true; - final panelWasVisible = _appWindow.isVisible; - final pasteFocusManager = panelWasVisible - ? _focusManager - : _directPasteFocusManager; - try { - // When hidden, capture the active destination before touching the - // clipboard. An open panel already owns a destination captured when it - // was shown; re-capturing here would incorrectly capture CopyPaste. - if (!panelWasVisible && - !await pasteFocusManager.capturePreviousWindow()) { - _reportPasteFailure( - const PasteResponse(success: false, errorCode: 'noPreviousWindow'), - ); - return; - } - if (panelWasVisible && !pasteFocusManager.hasDestination) { - _reportPasteFailure( - const PasteResponse(success: false, errorCode: 'noPreviousWindow'), - ); - return; - } - if (_shuttingDown) { - pasteFocusManager.clear(); - return; - } - final data = await Clipboard.getData(Clipboard.kTextPlain); - final text = data?.text; - if (text == null || text.isEmpty) { - if (!panelWasVisible) { - // The error reveals the history panel. Preserve the original target - // there so the user can immediately use Shift+Enter on history. - await _focusManager.capturePreviousWindow(); - pasteFocusManager.clear(); - } - _showShellNotice( - (l) => l.plainClipboardUnavailable, - revealWhenHidden: !panelWasVisible, - ); - return; - } - - widget.clipboardService.notifyDirectPasteInitiated(text); - final written = await ClipboardWriter.setText(text, plainText: true); - if (!written) { - AppLogger.warn('Plain-text paste aborted: clipboard write failed'); - if (!panelWasVisible) pasteFocusManager.clear(); - _showShellNotice( - (l) => l.clipboardWriteFailed, - revealWhenHidden: !panelWasVisible, - ); - return; - } - - if (panelWasVisible) await _appWindow.hide(); - - // Windows SendInput neutralizes still-held shortcut modifiers in the - // same atomic input batch. Other platforms still wait for key-up. - if (!Platform.isWindows && !await _waitForShortcutModifiersReleased()) { - pasteFocusManager.clear(); - return; - } - if (_shuttingDown) { - pasteFocusManager.clear(); - return; - } - // With the panel open the destination lost activation and has to settle - // exactly like the item paste does; only the hidden path can skip it. - final response = await pasteFocusManager.restoreAndPaste( - delayBeforeFocusMs: panelWasVisible ? _config.delayBeforeFocusMs : 0, - maxFocusVerifyAttempts: _config.maxFocusVerifyAttempts, - delayBeforePasteMs: panelWasVisible ? _config.delayBeforePasteMs : 0, - ); - if (!response.success) _reportPasteFailure(response); - } on PlatformException catch (e) { - pasteFocusManager.clear(); - if (e.code == 'ACCESSIBILITY_DENIED' && mounted) { - _enterPermissionGate(); - } - } catch (e, s) { - pasteFocusManager.clear(); - AppLogger.error('Direct plain-text paste failed: $e\n$s'); - } finally { - _directPlainPasteInProgress = false; - } - } - - void _dismissHint() { - if (_config.hasSeenHint) return; - unawaited(_persistConfig((c) => c.copyWith(hasSeenHint: true))); - if (mounted) setState(() {}); - } - - Future _persistConfig(AppConfig Function(AppConfig) update) { - _config = update(_config); - final path = widget.storage.configFilePath; - final next = (_pendingConfigSave ?? Future.value()) - .catchError((Object _) {}) - .then((_) => _config.save(path)); - _pendingConfigSave = next; - next.catchError((Object e) { - AppLogger.warn('config save failed: $e'); - }); - return next; - } - - Future _toggleWindow() async { - _programmaticRestore = true; - try { - if (_appWindow.isVisible) { - await _closePanel(); - return; - } - // A tray click normally leaves the previously active application in the - // foreground long enough to capture it. If the shell has already taken - // focus, the focus manager rejects CopyPaste and paste will fail safely. - await _focusManager.capturePreviousWindow(); - await _appWindow.show(); - } finally { - _programmaticRestore = false; - } - } - - Future _closePanel() async { - await _appWindow.hide(); - _focusManager.clear(); - } - - void _onWindowVisibilityChanged(bool visible) { - if (visible) { - _mainScreenKey.currentState?.onWindowShow(); - } else { - _mainScreenKey.currentState?.onWindowHide(); - final ctx = _navigatorKey.currentContext; - if (ctx != null && ctx.mounted) { - ScaffoldMessenger.maybeOf(ctx)?.clearSnackBars(); - } - } - } - - void _onPositionPersist(double x, double y) { - if (_config.lastWindowX == x && _config.lastWindowY == y) return; - unawaited( - _persistConfig((c) => c.copyWith(lastWindowX: x, lastWindowY: y)), - ); - } - - Future _onPasteItem( - ClipboardItem item, { - bool plainText = false, - }) async { - if (_itemPasteInProgress || - _directPlainPasteInProgress || - (item.isFileBasedType && !item.isFileAvailable())) { - return; - } - if (!_focusManager.hasDestination) { - _reportPasteFailure( - const PasteResponse(success: false, errorCode: 'noPreviousWindow'), - ); - return; - } - _itemPasteInProgress = true; - try { - await widget.clipboardService.notifyPasteInitiated(item.id); - final ok = await ClipboardWriter.setFromItem( - typeValue: item.type.value, - content: item.content, - metadata: item.metadata, - plainText: plainText, - ); - if (!ok) { - AppLogger.warn('Item paste aborted: clipboard write failed'); - // The destination is intentionally kept so the user can retry. - _showShellNotice((l) => l.clipboardWriteFailed); - return; - } - await _appWindow.hide(); - if (!Platform.isWindows && !await _waitForShortcutModifiersReleased()) { - _focusManager.clear(); - _reportPasteFailure( - const PasteResponse( - success: false, - errorCode: 'shortcutModifiersHeld', - ), - ); - return; - } - final response = await _focusManager.restoreAndPaste( - delayBeforeFocusMs: _config.delayBeforeFocusMs, - maxFocusVerifyAttempts: _config.maxFocusVerifyAttempts, - delayBeforePasteMs: _config.delayBeforePasteMs, - ); - if (!response.success) { - _reportPasteFailure(response); - return; - } - await widget.clipboardService.recordPaste(item.id); - } on PlatformException catch (e) { - _focusManager.clear(); - if (e.code == 'ACCESSIBILITY_DENIED' && mounted) { - _enterPermissionGate(); - } - } catch (e, s) { - _focusManager.clear(); - AppLogger.error('Item paste failed: $e\n$s'); - } finally { - _itemPasteInProgress = false; - } - } - - void _reportPasteFailure(PasteResponse response) { - AppLogger.warn( - 'Paste was not sent: error=${response.errorCode ?? 'unknown'}', - ); - if (response.errorCode == 'targetElevated') { - _showShellNotice((l) => l.pasteTargetElevated, revealWhenHidden: true); - return; - } - _showShellNotice( - (l) => l.pasteDestinationUnavailable, - revealWhenHidden: true, - ); - } - - Future _waitForShortcutModifiersReleased() async { - for (var attempt = 0; attempt < 25; attempt++) { - final released = - !HardwareKeyboard.instance.isControlPressed && - !HardwareKeyboard.instance.isShiftPressed && - !HardwareKeyboard.instance.isAltPressed && - !HardwareKeyboard.instance.isMetaPressed; - if (released) { - if (attempt > 0) { - AppLogger.info( - 'Shortcut modifiers released after ${attempt * 20} ms', - ); - } - return true; - } - await Future.delayed(const Duration(milliseconds: 20)); - } - AppLogger.warn('Paste cancelled because shortcut modifiers remain held'); - return false; - } - - Future _onCopyItem(ClipboardItem item) async { - if (item.isFileBasedType && !item.isFileAvailable()) return; - // Suppress the listener so writing to the clipboard is not re-captured as - // a brand new item; recordCopy then bumps it to the top of the list. - await widget.clipboardService.notifyPasteInitiated(item.id); - final ok = await ClipboardWriter.setFromItem( - typeValue: item.type.value, - content: item.content, - metadata: item.metadata, - ); - if (!ok) { - AppLogger.warn('Copy aborted: clipboard write failed'); - _showShellNotice((l) => l.clipboardWriteFailed); - return; - } - await widget.clipboardService.recordCopy(item.id); - if (!mounted) return; - final ctx = _navigatorKey.currentContext; - if (ctx == null || !ctx.mounted) return; - ScaffoldMessenger.maybeOf(ctx) - ?..clearSnackBars() - ..showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(ctx).copiedToClipboard), - duration: const Duration(seconds: 2), - ), - ); - } - - Future _cleanup() => _cleanupFuture ??= _performCleanup(); - - Future _performCleanup() async { - _shuttingDown = true; - _focusManager.clear(); - _directPasteFocusManager.clear(); - SingleInstance.stopListening(); - try { - await _listenerSubscription?.cancel(); - } catch (e) { - AppLogger.error('cleanup listener: $e'); - } - try { - await _hotkeyHandler.dispose(); - } catch (e) { - AppLogger.error('cleanup hotkey: $e'); - } - try { - await _trayIcon.dispose(); - } catch (e) { - AppLogger.error('cleanup tray: $e'); - } - try { - await widget.clipboardService.dispose(); - } catch (e) { - AppLogger.error('cleanup clipboard: $e'); - } - try { - widget.cleanupService.dispose(); - } catch (e) { - AppLogger.error('cleanup cleanup: $e'); - } - try { - await widget.repo.close(); - } catch (e) { - AppLogger.error('cleanup repo: $e'); - } - } - - Future _exitApp() async { - await _cleanup(); - SingleInstance.release(); - exit(0); - } - - /// Resets config and first-run flag, preserves clipboard history, restarts. - Future _softReset() async { - await _cleanup(); - SingleInstance.release(); - try { - // Remove config so next run starts with defaults - final configFile = File(widget.storage.configFilePath); - if (configFile.existsSync()) configFile.deleteSync(); - // Remove .initialized so first-run onboarding shows again - widget.storage.clearInitialized(); - } catch (e) { - AppLogger.error('softReset file cleanup: $e'); - } - await Process.start( - Platform.resolvedExecutable, - [], - mode: ProcessStartMode.detached, - ); - exit(0); - } - - /// Deletes all data (db, images, config, first-run flag), restarts. - Future _hardReset() async { - await _cleanup(); - SingleInstance.release(); - try { - final storage = widget.storage; - final baseDir = Directory(storage.baseDir); - if (baseDir.existsSync() && _isSafeToWipe(storage)) { - baseDir.deleteSync(recursive: true); - } else { - AppLogger.error( - 'hardReset refused: baseDir failed safety check ' - '("${storage.baseDir}")', - ); - } - } catch (e) { - AppLogger.error('hardReset dir cleanup: $e'); - } - await Process.start( - Platform.resolvedExecutable, - [], - mode: ProcessStartMode.detached, - ); - exit(0); - } - - /// Safety guard for [_hardReset]. Refuses to wipe a directory that does not - /// look like our own data folder. Requires the path to: - /// - be non-empty and not a filesystem root, - /// - end with "CopyPaste" (our fixed app folder name), - /// - contain at least one of our known subpaths (db, images, config, logs). - bool _isSafeToWipe(StorageConfig storage) { - final base = storage.baseDir; - if (base.isEmpty) return false; - final canonical = p.canonicalize(base); - final parent = p.dirname(canonical); - if (canonical == parent) return false; // filesystem root - if (p.basename(canonical) != 'CopyPaste') return false; - final hasOwnedChild = - File(storage.databasePath).existsSync() || - Directory(storage.imagesPath).existsSync() || - Directory(storage.configPath).existsSync() || - Directory(storage.logsPath).existsSync(); - return hasOwnedChild; - } - - Future _openSettings(BuildContext ctx) async { - await _appWindow.enterSettingsMode(); - if (!ctx.mounted) return; - await Navigator.of(ctx).push( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => SettingsScreen( - config: _config, - configPath: widget.storage.configFilePath, - clipboardService: widget.clipboardService, - storage: widget.storage, - onSoftReset: _softReset, - onHardReset: _hardReset, - onSave: (newConfig, hotkeyChanged) async { - setState(() => _config = newConfig); - widget.cleanupService.updateRetentionCallback( - () => newConfig.retentionDays, - ); - widget.cleanupService.updateKeepBrokenCallback( - () => newConfig.keepBrokenItemsDays, - ); - widget.cleanupService.updateImagesQuotaCallback( - () => newConfig.imagesQuotaMB, - ); - widget.clipboardService.updateThumbnailTypeGate( - (t) => switch (t) { - ClipboardContentType.image => newConfig.generateImageThumbnails, - ClipboardContentType.video => newConfig.generateVideoThumbnails, - ClipboardContentType.audio => newConfig.generateAudioThumbnails, - _ => true, - }, - ); - widget.clipboardService.updateMaxImageBytesGate( - () => newConfig.maxImageProcessingSizeMB * 1024 * 1024, - ); - widget.clipboardService.pasteIgnoreWindowMs = - newConfig.duplicateIgnoreWindowMs; - _appWindow.updatePopupSize( - newConfig.popupWidth.toDouble(), - newConfig.popupHeight.toDouble(), - ); - if (Platform.isWindows || Platform.isMacOS) { - await _appWindow.applyEffect( - dark: _isMicaDark(newConfig.themeMode), - ); - } - if (hotkeyChanged) { - await _hotkeyHandler.dispose(); - _hotkeyHandler = HotkeyHandler( - config: newConfig, - onHotkey: _onHotkey, - onPlainPasteHotkey: _onPlainPasteHotkey, - ); - await _registerHotkeyWithFeedback(); - } - }, - ), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - child, - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - ), - ); - await _appWindow.exitSettingsMode(); - } - - @override - void onWindowBlur() { - if (!_appWindow.isReady || !_appWindow.isVisible) return; - if (_appWindow.isGateMode) return; - if (!_config.hideOnDeactivate) return; - unawaited(_appWindow.hideIfNotPinned()); - } - - @override - void onWindowClose() { - unawaited(_closePanel()); - } - - @override - void onWindowRestore() { - if (!Platform.isWindows) return; - if (_programmaticRestore) { - _programmaticRestore = false; // consume the flag on the first event - return; - } - // Native user click on the taskbar button - unawaited(_safeShow()); - _showTaskbarOpenHint(); - } - - void _showTaskbarOpenHint() { - WidgetsBinding.instance.addPostFrameCallback((_) { - final ctx = _navigatorKey.currentContext; - if (ctx == null || !ctx.mounted) return; - if (_navigatorKey.currentState?.canPop() ?? false) return; - final messenger = ScaffoldMessenger.maybeOf(ctx); - if (messenger == null) return; - final binding = HotkeyBinding( - virtualKey: _config.hotkeyVirtualKey, - keyName: _config.hotkeyKeyName, - useCtrl: _config.hotkeyUseCtrl, - useWin: _config.hotkeyUseWin, - useAlt: _config.hotkeyUseAlt, - useShift: _config.hotkeyUseShift, - ); - messenger - ..clearSnackBars() - ..showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(ctx).taskbarOpenHint(binding.label()), - ), - duration: const Duration(seconds: 4), - ), - ); - }); - } - - void _enterPermissionGate() { - setState(() => _showPermissionGate = true); - _appWindow.enterGateMode(); - } - - Future _onPermissionGranted() async { - unawaited(_persistConfig((c) => c.copyWith(accessibilityWasGranted: true))); - await _appWindow.exitGateMode(); - if (mounted) setState(() => _showPermissionGate = false); - } - - Future _onOnboardingDismissed(AppConfig fromOnboarding) async { - unawaited( - _persistConfig( - (_) => fromOnboarding.copyWith( - hasSeenOnboarding: true, - lastRunVersion: AppConfig.appVersion, - ), - ), - ); - _applyOnboardingPersistence(); - setState(() => _showOnboarding = false); - await _appWindow.exitGateMode(); - unawaited(_showStartupBalloon()); - } - - Future _onOnboardingGoSettings( - BuildContext ctx, - AppConfig fromOnboarding, - ) async { - unawaited( - _persistConfig( - (_) => fromOnboarding.copyWith( - hasSeenOnboarding: true, - lastRunVersion: AppConfig.appVersion, - ), - ), - ); - _applyOnboardingPersistence(); - setState(() => _showOnboarding = false); - await _appWindow.exitGateMode(); - await Future.delayed(const Duration(milliseconds: 150)); - if (ctx.mounted) await _openSettings(ctx); - unawaited(_showStartupBalloon()); - } - - void _applyOnboardingPersistence() { - widget.cleanupService.updateKeepBrokenCallback( - () => _config.keepBrokenItemsDays, - ); - widget.clipboardService.updateThumbnailTypeGate( - (t) => switch (t) { - ClipboardContentType.image => _config.generateImageThumbnails, - ClipboardContentType.video => _config.generateVideoThumbnails, - ClipboardContentType.audio => _config.generateAudioThumbnails, - _ => true, - }, - ); - widget.clipboardService.updateMaxImageBytesGate( - () => _config.maxImageProcessingSizeMB * 1024 * 1024, - ); - } - - Future _restartApp() async { - await _cleanup(); - SingleInstance.release(); - await Process.start( - Platform.resolvedExecutable, - [], - mode: ProcessStartMode.detached, - ); - exit(0); - } - - void _onUpdateAvailable(String version) { - if (!mounted) return; - setState(() => _availableUpdateVersion = version); - } - - @override - void dispose() { - _shuttingDown = true; - _focusManager.clear(); - _directPasteFocusManager.clear(); - WidgetsBinding.instance.removeObserver(this); - windowManager.removeListener(this); - unawaited(AutoUpdateService.dispose()); - unawaited(_cleanup()); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return CopyPasteTheme( - themeData: CompactTheme(), - child: MaterialApp( - navigatorKey: _navigatorKey, - title: 'CopyPaste', - debugShowCheckedModeBanner: false, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - locale: _config.preferredLanguage == 'auto' - ? null - : Locale(_config.preferredLanguage), - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF4F46E5)), - scaffoldBackgroundColor: Colors.transparent, - fontFamily: 'Inter', - useMaterial3: true, - ), - darkTheme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF4F46E5), - brightness: Brightness.dark, - ), - scaffoldBackgroundColor: Colors.transparent, - fontFamily: 'Inter', - useMaterial3: true, - ), - themeMode: _effectiveThemeMode, - home: Builder( - builder: (ctx) { - final l = AppLocalizations.of(ctx); - final currentLocale = Localizations.localeOf(ctx).toString(); - if (_lastTrayLocale != currentLocale) { - _lastTrayLocale = currentLocale; - unawaited( - _trayIcon.rebuild( - showHideLabel: l.trayShowHide, - exitLabel: l.trayExit, - tooltip: l.trayTooltip, - ), - ); - } - - if (_showOnboarding) { - final binding = HotkeyBinding( - virtualKey: _config.hotkeyVirtualKey, - keyName: _config.hotkeyKeyName, - useCtrl: _config.hotkeyUseCtrl, - useWin: _config.hotkeyUseWin, - useAlt: _config.hotkeyUseAlt, - useShift: _config.hotkeyUseShift, - ); - return DesktopOnboardingScreen( - hotkey: binding.label(), - initialConfig: _config, - onDismiss: (updated) => - unawaited(_onOnboardingDismissed(updated)), - onSettings: (updated) => - unawaited(_onOnboardingGoSettings(ctx, updated)), - ); - } - - if (_showPermissionGate) { - return PermissionGateScreen( - previouslyGranted: _config.accessibilityWasGranted, - onGranted: _onPermissionGranted, - onRestart: _restartApp, - ); - } - - if (_manifestState != null && - InstallChannelDetector.detect() != InstallChannel.msStore && - ReleaseManifestService.isBlocked( - current: AppConfig.appVersion, - state: _manifestState, - )) { - return BlockedVersionScreen( - currentVersion: AppConfig.appVersion, - manifest: _manifestState!.manifest, - ); - } - - final bg = (Platform.isWindows || Platform.isMacOS) - ? CopyPasteTheme.colorsOf( - ctx, - ).background.withValues(alpha: 0.85) - : CopyPasteTheme.colorsOf(ctx).background; - return Scaffold( - backgroundColor: bg, - body: LayoutBuilder( - builder: (_, constraints) { - if (constraints.maxHeight < 100) { - return const SizedBox.shrink(); - } - return MainScreen( - key: _mainScreenKey, - clipboardService: widget.clipboardService, - colorLabels: _config.colorLabels, - resetScrollOnShow: _config.resetScrollOnShow, - resetSearchOnShow: _config.resetSearchOnShow, - resetFiltersOnShow: _config.resetFiltersOnShow, - cardMinLines: _config.cardMinLines, - cardMaxLines: _config.cardMaxLines, - showHint: !_config.hasSeenHint, - onDismissHint: _dismissHint, - onPaste: _onPasteItem, - onPastePlain: (item) => _onPasteItem(item, plainText: true), - onPlainPasteUnavailable: () => - _showShellNotice((l) => l.plainPasteItemUnavailable), - onCopy: _onCopyItem, - onExit: _closePanel, - onSettings: () => _openSettings(ctx), - updateVersion: _availableUpdateVersion, - updateSeverity: ReleaseManifestService.badgeSeverity( - current: AppConfig.appVersion, - state: _manifestState, - ), - ); - }, - ), - ); - }, - ), - ), - ); - } -} diff --git a/app/lib/screens/blocked_version_screen.dart b/app/lib/screens/blocked_version_screen.dart deleted file mode 100644 index fda9efc0..00000000 --- a/app/lib/screens/blocked_version_screen.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../helpers/url_helper.dart'; -import '../l10n/app_localizations.dart'; -import '../services/install_channel.dart'; -import '../services/release_manifest_service.dart'; - -class BlockedVersionScreen extends StatelessWidget { - const BlockedVersionScreen({ - required this.currentVersion, - required this.manifest, - super.key, - }); - - final String currentVersion; - final ReleaseManifest manifest; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - final cs = Theme.of(context).colorScheme; - final tt = Theme.of(context).textTheme; - - final channel = InstallChannelDetector.detect(); - final channelInfo = - manifest.channels[InstallChannelDetector.manifestKey(channel)]; - final notes = manifest.notesFor( - Localizations.localeOf(context).toLanguageTag(), - ); - - final action = _resolveAction(context, l, channel, channelInfo); - - return Scaffold( - backgroundColor: cs.surface, - body: Center( - child: SizedBox( - width: 420, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: cs.errorContainer.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(20), - ), - child: Icon( - Icons.lock_outline, - size: 32, - color: cs.onErrorContainer, - ), - ), - const SizedBox(height: 16), - Text( - l.blockedTitle, - style: tt.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Text( - l.blockedDescription( - currentVersion, - manifest.minimumSupported, - ), - style: tt.bodyMedium?.copyWith( - color: cs.onSurfaceVariant, - height: 1.5, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Text( - notes?.summary ?? l.blockedReasonGeneric, - style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - if (action != null) - FilledButton( - onPressed: action.onPressed, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 14, - ), - ), - child: Text(action.label), - ) - else - Text( - l.blockedFallbackHint, - style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - TextButton( - onPressed: () => exit(0), - child: Text(l.blockedQuit), - ), - ], - ), - ), - ), - ), - ); - } - - _BlockAction? _resolveAction( - BuildContext context, - AppLocalizations l, - InstallChannel channel, - ChannelInfo? info, - ) { - if (info == null) return null; - - if (channel == InstallChannel.msStore) { - final url = info.url; - if (url == null) return null; - return _BlockAction( - label: l.updateActionOpenStore, - onPressed: () => UrlHelper.open(url), - ); - } - - final tool = _packageManagerName(channel); - if (tool != null) { - final cmd = info.command; - if (cmd == null) return null; - return _BlockAction( - label: l.updateActionCopyCommand(tool), - onPressed: () async { - await Clipboard.setData(ClipboardData(text: cmd)); - if (!context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l.updateActionCopied))); - }, - ); - } - - final url = info.url; - if (url == null) return null; - return _BlockAction( - label: l.updateActionDownload, - onPressed: () => UrlHelper.open(url), - ); - } - - static String? _packageManagerName(InstallChannel channel) => - switch (channel) { - InstallChannel.homebrew => 'brew', - InstallChannel.scoop => 'scoop', - _ => null, - }; -} - -class _BlockAction { - _BlockAction({required this.label, required this.onPressed}); - final String label; - final VoidCallback onPressed; -} diff --git a/app/lib/screens/desktop_onboarding_screen.dart b/app/lib/screens/desktop_onboarding_screen.dart deleted file mode 100644 index 0722a9aa..00000000 --- a/app/lib/screens/desktop_onboarding_screen.dart +++ /dev/null @@ -1,202 +0,0 @@ -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/main_screen.dart b/app/lib/screens/main_screen.dart deleted file mode 100644 index ba8e3059..00000000 --- a/app/lib/screens/main_screen.dart +++ /dev/null @@ -1,954 +0,0 @@ -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, - this.onPlainPasteUnavailable, - this.onCopy, - 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? onPlainPasteUnavailable; - final void Function(ClipboardItem item)? onCopy; - 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; - String? _hoveredItemId; - 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, - ); - - if (!mounted) return; - 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'); - if (!mounted) return; - 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); - } - - int get _preferredPasteIndex { - if (_items.isEmpty) return -1; - final hoveredIndex = _hoveredItemId == null - ? -1 - : _items.indexWhere((item) => item.id == _hoveredItemId); - if (hoveredIndex >= 0) return hoveredIndex; - if (_selectedIndex >= 0 && _selectedIndex < _items.length) { - return _selectedIndex; - } - return 0; - } - - /// Pastes the hovered item normally, then falls back to the keyboard - /// selection or first visible result. - bool pasteSelectedOrFirst() { - final index = _preferredPasteIndex; - if (index < 0) return false; - if (_selectedIndex != index) { - setState(() => _selectedIndex = index); - } - widget.onPaste(_items[index]); - return true; - } - - /// Pastes the hovered item as plain text, then falls back to the keyboard - /// selection or first visible result. This keeps both mouse and keyboard - /// workflows complete without writing the item to the clipboard first. - bool pasteSelectedPlainOrFirst() { - final index = _preferredPasteIndex; - if (index < 0) { - widget.onPlainPasteUnavailable?.call(); - return false; - } - final item = _items[index]; - if (item.type != ClipboardContentType.text && - item.type != ClipboardContentType.link) { - widget.onPlainPasteUnavailable?.call(); - return false; - } - if (_selectedIndex != index) { - setState(() => _selectedIndex = index); - } - widget.onPastePlain(item); - return true; - } - - 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 (_isPlainPasteGesture(event)) { - if (event is KeyUpEvent) pasteSelectedPlainOrFirst(); - return KeyEventResult.handled; - } - if (_isNormalPasteGesture(event)) { - if (event is KeyUpEvent) pasteSelectedOrFirst(); - return KeyEventResult.handled; - } - 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 (_isPlainPasteGesture(event)) { - if (event is KeyUpEvent) pasteSelectedPlainOrFirst(); - return KeyEventResult.handled; - } - if (_isNormalPasteGesture(event)) { - if (event is KeyUpEvent) pasteSelectedOrFirst(); - return KeyEventResult.handled; - } - 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.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; - } - - bool _isPlainPasteGesture(KeyEvent event) { - final keyboard = HardwareKeyboard.instance; - return event.logicalKey == LogicalKeyboardKey.enter && - keyboard.isShiftPressed && - !keyboard.isControlPressed && - !keyboard.isMetaPressed && - !keyboard.isAltPressed; - } - - bool _isNormalPasteGesture(KeyEvent event) { - final keyboard = HardwareKeyboard.instance; - return event.logicalKey == LogicalKeyboardKey.enter && - !keyboard.isShiftPressed && - !keyboard.isControlPressed && - !keyboard.isMetaPressed && - !keyboard.isAltPressed; - } - - 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), - onCopy: widget.onCopy == null ? null : () => widget.onCopy!(item), - onOpen: () => _onItemOpen(item), - onRequestThumbnailRefresh: - widget.clipboardService.requestThumbnailIfStale, - onSelect: () { - setState(() => _selectedIndex = index); - _focusNode.requestFocus(); - }, - onHoverChanged: (hovering) { - if (hovering) { - _hoveredItemId = item.id; - } else if (_hoveredItemId == item.id) { - _hoveredItemId = null; - } - }, - 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) - : 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/permission_gate_screen.dart b/app/lib/screens/permission_gate_screen.dart deleted file mode 100644 index 13ab44cc..00000000 --- a/app/lib/screens/permission_gate_screen.dart +++ /dev/null @@ -1,260 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:listener/listener.dart'; - -import '../l10n/app_localizations.dart'; - -class PermissionGateScreen extends StatefulWidget { - const PermissionGateScreen({ - required this.onGranted, - required this.previouslyGranted, - this.onRestart, - super.key, - }); - - final VoidCallback onGranted; - final bool previouslyGranted; - final VoidCallback? onRestart; - - @override - State createState() => _PermissionGateScreenState(); -} - -class _PermissionGateScreenState extends State - with SingleTickerProviderStateMixin { - Timer? _pollTimer; - int _pollCount = 0; - bool _timedOut = false; - bool _checking = false; - - late final AnimationController _pulseController; - late final Animation _pulseAnimation; - - static const _maxPollsBeforeHint = 30; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - )..repeat(reverse: true); - _pulseAnimation = Tween(begin: 0.4, end: 1.0).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - - _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) async { - _pollCount++; - final granted = await ClipboardWriter.checkAccessibility(); - if (granted && mounted) { - _pollTimer?.cancel(); - widget.onGranted(); - return; - } - if (_pollCount >= _maxPollsBeforeHint && !_timedOut && mounted) { - _pollTimer?.cancel(); - setState(() => _timedOut = true); - } - }); - } - - Future _manualCheck() async { - if (_checking) return; - setState(() => _checking = true); - final granted = await ClipboardWriter.requestAccessibility(); - if (granted && mounted) { - _pollTimer?.cancel(); - widget.onGranted(); - } else if (mounted) { - setState(() { - _checking = false; - _timedOut = true; - }); - } - } - - @override - void dispose() { - _pollTimer?.cancel(); - _pulseController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - final cs = Theme.of(context).colorScheme; - final isStale = widget.previouslyGranted; - - return Scaffold( - backgroundColor: cs.surface, - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 28), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Image.asset( - 'assets/icons/icon_app_256.png', - width: 72, - height: 72, - ), - ), - const SizedBox(height: 16), - Text( - 'CopyPaste', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: cs.onSurface, - letterSpacing: -0.3, - ), - ), - const SizedBox(height: 20), - _StatusRow( - icon: isStale - ? Icons.warning_amber_rounded - : Icons.lock_outline_rounded, - iconColor: isStale ? Colors.red : Colors.orange, - label: isStale ? l.permissionsResetTitle : l.permissionsTitle, - colorScheme: cs, - ), - const SizedBox(height: 16), - Text( - isStale - ? l.permissionsResetMessage - : (_timedOut - ? l.permissionsRestartMessage - : l.permissionsMessage), - style: TextStyle( - fontSize: 13, - color: cs.onSurfaceVariant, - height: 1.5, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_timedOut || isStale) ...[ - OutlinedButton( - onPressed: _checking ? null : _manualCheck, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - ), - child: Text(_checking ? '...' : l.permissionsCheckAgain), - ), - const SizedBox(width: 12), - ], - FilledButton.icon( - onPressed: () => - ClipboardWriter.openAccessibilitySettings(), - icon: const Icon(Icons.settings, size: 18), - label: Text(l.permissionsOpenSettings), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - ), - ), - ], - ), - const SizedBox(height: 20), - if (_timedOut || isStale) - TextButton.icon( - onPressed: widget.onRestart, - icon: Icon( - Icons.refresh_rounded, - size: 16, - color: cs.onSurfaceVariant.withValues(alpha: 0.7), - ), - label: Text( - l.permissionsRestartApp, - style: TextStyle( - fontSize: 12, - color: cs.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ), - if (!_timedOut && !isStale) - FadeTransition( - opacity: _pulseAnimation, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: cs.onSurfaceVariant.withValues(alpha: 0.5), - ), - ), - const SizedBox(width: 8), - Text( - l.permissionsWaiting, - style: TextStyle( - fontSize: 11, - color: cs.onSurfaceVariant.withValues(alpha: 0.6), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} - -class _StatusRow extends StatelessWidget { - const _StatusRow({ - required this.icon, - required this.iconColor, - required this.label, - required this.colorScheme, - }); - - final IconData icon; - final Color iconColor; - final String label; - final ColorScheme colorScheme; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 20, color: iconColor), - const SizedBox(width: 10), - Flexible( - child: Text( - label, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - ), - ], - ), - ); - } -} diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart deleted file mode 100644 index f257b9e7..00000000 --- a/app/lib/screens/settings_screen.dart +++ /dev/null @@ -1,2816 +0,0 @@ -// coverage:ignore-file -import 'dart:async'; -import 'dart:io'; - -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:window_manager/window_manager.dart'; - -import '../helpers/url_helper.dart'; -import '../l10n/app_localizations.dart'; - -import '../shell/startup_helper.dart'; -import '../theme/app_theme_data.dart'; -import '../theme/theme_provider.dart'; - -class SettingsScreen extends StatefulWidget { - const SettingsScreen({ - required this.config, - required this.configPath, - required this.clipboardService, - required this.storage, - required this.onSave, - required this.onSoftReset, - required this.onHardReset, - super.key, - }); - - final AppConfig config; - final String configPath; - final ClipboardService clipboardService; - final StorageConfig storage; - final Future Function(AppConfig newConfig, bool hotkeyChanged) onSave; - - /// Resets config + first-run flag, keeps clipboard history, then restarts. - final Future Function() onSoftReset; - - /// Deletes all data (db, images, config, first-run flag), then restarts. - final Future Function() onHardReset; - - @override - State createState() => _SettingsScreenState(); -} - -class _SettingsScreenState extends State { - int _selectedTab = 0; - Timer? _autosaveTimer; - bool _saving = false; - bool _savedRecently = false; - static const _autosaveDebounce = Duration(milliseconds: 350); - - late String _preferredLanguage; - late bool _runOnStartup; - - late bool _hotkeyCtrl; - late bool _hotkeyWin; - late bool _hotkeyAlt; - late bool _hotkeyShift; - late int _hotkeyVirtualKey; - late String _hotkeyKeyName; - late bool _plainPasteHotkeyEnabled; - late bool _plainPasteHotkeyCtrl; - late bool _plainPasteHotkeyWin; - late bool _plainPasteHotkeyAlt; - late bool _plainPasteHotkeyShift; - late int _plainPasteHotkeyVirtualKey; - late String _plainPasteHotkeyKeyName; - late String _lastSavedHotkeySignature; - - late Map _colorLabels; - - late int _pageSize; - late int _maxItemsBeforeCleanup; - late int _scrollLoadThreshold; - - late int _retentionDays; - - late int _duplicateIgnoreWindowMs; - late int _delayBeforeFocusMs; - late int _delayBeforePasteMs; - late int _maxFocusVerifyAttempts; - - late DateTime? _lastBackupDateUtc; - - late int _popupWidth; - late int _popupHeight; - late int _cardMinLines; - late int _cardMaxLines; - late String _themeMode; - - late bool _hideOnDeactivate; - late bool _rememberWindowPosition; - late bool _resetScrollOnShow; - late bool _resetSearchOnShow; - late bool _resetFiltersOnShow; - - // Cleanup & privacy - late int _keepBrokenItemsDays; - late int _imagesQuotaMB; - - // Multimedia - late bool _generateImageThumbnails; - late bool _generateVideoThumbnails; - late bool _generateAudioThumbnails; - late int _maxImageProcessingSizeMB; - - String get _hotkeySignature => [ - _hotkeyCtrl, - _hotkeyWin, - _hotkeyAlt, - _hotkeyShift, - _hotkeyVirtualKey, - _plainPasteHotkeyEnabled, - _plainPasteHotkeyCtrl, - _plainPasteHotkeyWin, - _plainPasteHotkeyAlt, - _plainPasteHotkeyShift, - _plainPasteHotkeyVirtualKey, - ].join(':'); - - bool get _hotkeyChanged => _hotkeySignature != _lastSavedHotkeySignature; - - bool get _openHotkeyHasModifier => - _hotkeyCtrl || _hotkeyWin || _hotkeyAlt || _hotkeyShift; - - bool get _plainPasteHotkeyHasModifier => - _plainPasteHotkeyCtrl || - _plainPasteHotkeyWin || - _plainPasteHotkeyAlt || - _plainPasteHotkeyShift; - - bool get _hotkeysConflict => - _plainPasteHotkeyEnabled && - _hotkeyCtrl == _plainPasteHotkeyCtrl && - _hotkeyWin == _plainPasteHotkeyWin && - _hotkeyAlt == _plainPasteHotkeyAlt && - _hotkeyShift == _plainPasteHotkeyShift && - _hotkeyVirtualKey == _plainPasteHotkeyVirtualKey; - - bool get _hasInvalidHotkey => - !_openHotkeyHasModifier || - (_plainPasteHotkeyEnabled && !_plainPasteHotkeyHasModifier) || - _hotkeysConflict; - - @override - void initState() { - super.initState(); - _preferredLanguage = widget.config.preferredLanguage; - _runOnStartup = widget.config.runOnStartup; - _hotkeyCtrl = widget.config.hotkeyUseCtrl; - _hotkeyWin = widget.config.hotkeyUseWin; - _hotkeyAlt = widget.config.hotkeyUseAlt; - _hotkeyShift = widget.config.hotkeyUseShift; - _hotkeyVirtualKey = widget.config.hotkeyVirtualKey; - _hotkeyKeyName = widget.config.hotkeyKeyName; - _plainPasteHotkeyEnabled = widget.config.plainPasteHotkeyEnabled; - _plainPasteHotkeyCtrl = widget.config.plainPasteHotkeyUseCtrl; - _plainPasteHotkeyWin = widget.config.plainPasteHotkeyUseWin; - _plainPasteHotkeyAlt = widget.config.plainPasteHotkeyUseAlt; - _plainPasteHotkeyShift = widget.config.plainPasteHotkeyUseShift; - _plainPasteHotkeyVirtualKey = widget.config.plainPasteHotkeyVirtualKey; - _plainPasteHotkeyKeyName = widget.config.plainPasteHotkeyKeyName; - _lastSavedHotkeySignature = _hotkeySignature; - _colorLabels = Map.of(widget.config.colorLabels); - _pageSize = widget.config.pageSize; - _maxItemsBeforeCleanup = widget.config.maxItemsBeforeCleanup; - _scrollLoadThreshold = widget.config.scrollLoadThreshold; - _retentionDays = widget.config.retentionDays; - _duplicateIgnoreWindowMs = widget.config.duplicateIgnoreWindowMs; - _delayBeforeFocusMs = widget.config.delayBeforeFocusMs; - _delayBeforePasteMs = widget.config.delayBeforePasteMs; - _maxFocusVerifyAttempts = widget.config.maxFocusVerifyAttempts; - _lastBackupDateUtc = widget.config.lastBackupDateUtc; - _popupWidth = widget.config.popupWidth; - _popupHeight = widget.config.popupHeight; - _cardMinLines = widget.config.cardMinLines; - _cardMaxLines = widget.config.cardMaxLines; - _themeMode = widget.config.themeMode; - _hideOnDeactivate = widget.config.hideOnDeactivate; - _rememberWindowPosition = widget.config.rememberWindowPosition; - _resetScrollOnShow = widget.config.resetScrollOnShow; - _resetSearchOnShow = widget.config.resetSearchOnShow; - _resetFiltersOnShow = widget.config.resetFiltersOnShow; - _keepBrokenItemsDays = widget.config.keepBrokenItemsDays; - _imagesQuotaMB = widget.config.imagesQuotaMB; - _generateImageThumbnails = widget.config.generateImageThumbnails; - _generateVideoThumbnails = widget.config.generateVideoThumbnails; - _generateAudioThumbnails = widget.config.generateAudioThumbnails; - _maxImageProcessingSizeMB = widget.config.maxImageProcessingSizeMB; - } - - void _markChanged() { - _autosaveTimer?.cancel(); - _autosaveTimer = Timer(_autosaveDebounce, _save); - } - - @override - void dispose() { - if (_autosaveTimer?.isActive ?? false) { - _autosaveTimer!.cancel(); - // Persist any pending change synchronously-ish before tearing down. - unawaited(_save()); - } - super.dispose(); - } - - AppConfig _buildConfig() => widget.config.copyWith( - preferredLanguage: _preferredLanguage, - runOnStartup: _runOnStartup, - hotkeyUseCtrl: _hotkeyCtrl, - hotkeyUseWin: _hotkeyWin, - hotkeyUseAlt: _hotkeyAlt, - hotkeyUseShift: _hotkeyShift, - hotkeyVirtualKey: _hotkeyVirtualKey, - hotkeyKeyName: _hotkeyKeyName, - plainPasteHotkeyEnabled: _plainPasteHotkeyEnabled, - plainPasteHotkeyUseCtrl: _plainPasteHotkeyCtrl, - plainPasteHotkeyUseWin: _plainPasteHotkeyWin, - plainPasteHotkeyUseAlt: _plainPasteHotkeyAlt, - plainPasteHotkeyUseShift: _plainPasteHotkeyShift, - plainPasteHotkeyVirtualKey: _plainPasteHotkeyVirtualKey, - plainPasteHotkeyKeyName: _plainPasteHotkeyKeyName, - colorLabels: _colorLabels, - pageSize: _pageSize, - maxItemsBeforeCleanup: _maxItemsBeforeCleanup, - scrollLoadThreshold: _scrollLoadThreshold, - retentionDays: _retentionDays, - duplicateIgnoreWindowMs: _duplicateIgnoreWindowMs, - delayBeforeFocusMs: _delayBeforeFocusMs, - delayBeforePasteMs: _delayBeforePasteMs, - maxFocusVerifyAttempts: _maxFocusVerifyAttempts, - lastBackupDateUtc: _lastBackupDateUtc, - popupWidth: _popupWidth, - popupHeight: _popupHeight, - cardMinLines: _cardMinLines, - cardMaxLines: _cardMaxLines, - themeMode: _themeMode, - hideOnDeactivate: _hideOnDeactivate, - rememberWindowPosition: _rememberWindowPosition, - lastWindowX: _rememberWindowPosition ? widget.config.lastWindowX : null, - lastWindowY: _rememberWindowPosition ? widget.config.lastWindowY : null, - resetScrollOnShow: _resetScrollOnShow, - resetSearchOnShow: _resetSearchOnShow, - resetFiltersOnShow: _resetFiltersOnShow, - keepBrokenItemsDays: _keepBrokenItemsDays, - generateImageThumbnails: _generateImageThumbnails, - generateVideoThumbnails: _generateVideoThumbnails, - generateAudioThumbnails: _generateAudioThumbnails, - maxImageProcessingSizeMB: _maxImageProcessingSizeMB, - imagesQuotaMB: _imagesQuotaMB, - ); - - Future _save() async { - _autosaveTimer?.cancel(); - if (_saving) { - _autosaveTimer = Timer(_autosaveDebounce, _save); - return; - } - if (!mounted) return; - if (_hasInvalidHotkey) return; - setState(() => _saving = true); - try { - final hotkeyChanged = _hotkeyChanged; - final newConfig = _buildConfig(); - await newConfig.save(widget.configPath); - await StartupHelper.apply(_runOnStartup, fromUserAction: true); - await widget.onSave(newConfig, hotkeyChanged); - if (!mounted) return; - setState(() { - _lastSavedHotkeySignature = _hotkeySignature; - _saving = false; - _savedRecently = true; - }); - Future.delayed(const Duration(seconds: 2), () { - if (!mounted) return; - setState(() => _savedRecently = false); - }); - } catch (e) { - if (!mounted) return; - setState(() => _saving = false); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Save failed: $e'))); - } - } - - void _resetToDefaults() { - final d = AppConfig.defaultForCurrentPlatform(); - setState(() { - _preferredLanguage = d.preferredLanguage; - _runOnStartup = d.runOnStartup; - _hotkeyCtrl = d.hotkeyUseCtrl; - _hotkeyWin = d.hotkeyUseWin; - _hotkeyAlt = d.hotkeyUseAlt; - _hotkeyShift = d.hotkeyUseShift; - _hotkeyVirtualKey = d.hotkeyVirtualKey; - _hotkeyKeyName = d.hotkeyKeyName; - _plainPasteHotkeyEnabled = d.plainPasteHotkeyEnabled; - _plainPasteHotkeyCtrl = d.plainPasteHotkeyUseCtrl; - _plainPasteHotkeyWin = d.plainPasteHotkeyUseWin; - _plainPasteHotkeyAlt = d.plainPasteHotkeyUseAlt; - _plainPasteHotkeyShift = d.plainPasteHotkeyUseShift; - _plainPasteHotkeyVirtualKey = d.plainPasteHotkeyVirtualKey; - _plainPasteHotkeyKeyName = d.plainPasteHotkeyKeyName; - _colorLabels = {}; - _pageSize = d.pageSize; - _maxItemsBeforeCleanup = d.maxItemsBeforeCleanup; - _scrollLoadThreshold = d.scrollLoadThreshold; - _retentionDays = d.retentionDays; - _duplicateIgnoreWindowMs = d.duplicateIgnoreWindowMs; - _delayBeforeFocusMs = d.delayBeforeFocusMs; - _delayBeforePasteMs = d.delayBeforePasteMs; - _maxFocusVerifyAttempts = d.maxFocusVerifyAttempts; - _popupWidth = d.popupWidth; - _popupHeight = d.popupHeight; - _cardMinLines = d.cardMinLines; - _cardMaxLines = d.cardMaxLines; - _themeMode = d.themeMode; - _hideOnDeactivate = d.hideOnDeactivate; - _rememberWindowPosition = d.rememberWindowPosition; - _resetScrollOnShow = d.resetScrollOnShow; - _resetSearchOnShow = d.resetSearchOnShow; - _resetFiltersOnShow = d.resetFiltersOnShow; - _keepBrokenItemsDays = d.keepBrokenItemsDays; - _imagesQuotaMB = d.imagesQuotaMB; - _generateImageThumbnails = d.generateImageThumbnails; - _generateVideoThumbnails = d.generateVideoThumbnails; - _generateAudioThumbnails = d.generateAudioThumbnails; - _maxImageProcessingSizeMB = d.maxImageProcessingSizeMB; - }); - _markChanged(); - } - - String _imagesQuotaKey(int mb) { - if (mb <= 0) return 'off'; - const presets = ['256', '512', '1024', '2048', '5120', '10240']; - final asString = mb.toString(); - return presets.contains(asString) ? asString : 'off'; - } - - String _hotkeyString([String separator = '+']) { - return _formatHotkey( - useCtrl: _hotkeyCtrl, - useMeta: _hotkeyWin, - useAlt: _hotkeyAlt, - useShift: _hotkeyShift, - keyName: _hotkeyKeyName, - separator: separator, - ); - } - - String _plainPasteHotkeyString([String separator = '+']) { - return _formatHotkey( - useCtrl: _plainPasteHotkeyCtrl, - useMeta: _plainPasteHotkeyWin, - useAlt: _plainPasteHotkeyAlt, - useShift: _plainPasteHotkeyShift, - keyName: _plainPasteHotkeyKeyName, - separator: separator, - ); - } - - String _formatHotkey({ - required bool useCtrl, - required bool useMeta, - required bool useAlt, - required bool useShift, - required String keyName, - required String separator, - }) { - final parts = []; - if (Platform.isMacOS) { - if (useCtrl) parts.add('⌃'); - if (useAlt) parts.add('⌥'); - if (useShift) parts.add('⇧'); - if (useMeta) parts.add('⌘'); - parts.add(keyName); - return parts.join(); - } - if (useCtrl) parts.add('Ctrl'); - if (useMeta) parts.add('Win'); - if (useAlt) parts.add('Alt'); - if (useShift) parts.add('Shift'); - parts.add(keyName); - return parts.join(separator); - } - - void _restoreRecommendedHotkeys() { - final defaults = AppConfig.defaultForCurrentPlatform(); - setState(() { - _hotkeyCtrl = defaults.hotkeyUseCtrl; - _hotkeyWin = defaults.hotkeyUseWin; - _hotkeyAlt = defaults.hotkeyUseAlt; - _hotkeyShift = defaults.hotkeyUseShift; - _hotkeyVirtualKey = defaults.hotkeyVirtualKey; - _hotkeyKeyName = defaults.hotkeyKeyName; - _plainPasteHotkeyEnabled = defaults.plainPasteHotkeyEnabled; - _plainPasteHotkeyCtrl = defaults.plainPasteHotkeyUseCtrl; - _plainPasteHotkeyWin = defaults.plainPasteHotkeyUseWin; - _plainPasteHotkeyAlt = defaults.plainPasteHotkeyUseAlt; - _plainPasteHotkeyShift = defaults.plainPasteHotkeyUseShift; - _plainPasteHotkeyVirtualKey = defaults.plainPasteHotkeyVirtualKey; - _plainPasteHotkeyKeyName = defaults.plainPasteHotkeyKeyName; - }); - _markChanged(); - } - - String? get _pastePresetName { - if (Platform.isWindows && - _delayBeforeFocusMs == 0 && - _delayBeforePasteMs == 20 && - _maxFocusVerifyAttempts == 15 && - _duplicateIgnoreWindowMs == 300) { - return 'Instant'; - } - if (_delayBeforeFocusMs == 50 && - _delayBeforePasteMs == 80 && - _maxFocusVerifyAttempts == 10 && - _duplicateIgnoreWindowMs == 300) { - return 'Fast'; - } - if (_delayBeforeFocusMs == 80 && - _delayBeforePasteMs == 120 && - _maxFocusVerifyAttempts == 12 && - _duplicateIgnoreWindowMs == 350) { - return 'Normal'; - } - if (_delayBeforeFocusMs == 100 && - _delayBeforePasteMs == 180 && - _maxFocusVerifyAttempts == 15 && - _duplicateIgnoreWindowMs == 450) { - return 'Safe'; - } - if (_delayBeforeFocusMs == 150 && - _delayBeforePasteMs == 250 && - _maxFocusVerifyAttempts == 20 && - _duplicateIgnoreWindowMs == 600) { - return 'Slow'; - } - return null; - } - - void _applyPastePreset(String name) { - setState(() { - switch (name) { - case 'Instant': - _delayBeforeFocusMs = 0; - _delayBeforePasteMs = 20; - _maxFocusVerifyAttempts = 15; - _duplicateIgnoreWindowMs = 300; - case 'Fast': - _delayBeforeFocusMs = 50; - _delayBeforePasteMs = 80; - _maxFocusVerifyAttempts = 10; - _duplicateIgnoreWindowMs = 300; - case 'Normal': - _delayBeforeFocusMs = 80; - _delayBeforePasteMs = 120; - _maxFocusVerifyAttempts = 12; - _duplicateIgnoreWindowMs = 350; - case 'Safe': - _delayBeforeFocusMs = 100; - _delayBeforePasteMs = 180; - _maxFocusVerifyAttempts = 15; - _duplicateIgnoreWindowMs = 450; - case 'Slow': - _delayBeforeFocusMs = 150; - _delayBeforePasteMs = 250; - _maxFocusVerifyAttempts = 20; - _duplicateIgnoreWindowMs = 600; - } - }); - _markChanged(); - } - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - - return Scaffold( - backgroundColor: Platform.isWindows - ? colors.background.withValues(alpha: 0.85) - : colors.background, - body: Column( - children: [ - DragToMoveArea( - child: Container( - height: 36, - color: colors.surface, - padding: const EdgeInsets.only(right: 8), - alignment: Alignment.centerRight, - child: Icon( - Icons.drag_indicator_rounded, - size: 14, - color: colors.onSurfaceMuted.withValues(alpha: 0.4), - ), - ), - ), - Expanded( - child: Row( - children: [ - _buildSidebar(colors), - VerticalDivider( - width: 1, - thickness: 0.5, - color: colors.divider, - ), - Expanded(child: _buildContent(theme, colors)), - ], - ), - ), - Divider(height: 1, thickness: 0.5, color: colors.divider), - _buildFooter(colors), - ], - ), - ); - } - - Widget _buildSidebar(AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - return Container( - width: 220, - color: colors.surface, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 24, 16, 4), - child: Row( - children: [ - Icon(Icons.settings_rounded, size: 22, color: colors.primary), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.settingsTitle, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: colors.onSurface, - ), - ), - Text( - 'CopyPaste v${AppConfig.appVersion}', - style: TextStyle( - fontSize: 10, - color: colors.onSurfaceMuted, - ), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 16), - _NavItem( - icon: Icons.tune_rounded, - label: l.tabGeneral, - selected: _selectedTab == 0, - colors: colors, - onTap: () => setState(() => _selectedTab = 0), - ), - _NavItem( - icon: Icons.keyboard_rounded, - label: l.tabShortcuts, - selected: _selectedTab == 1, - colors: colors, - onTap: () => setState(() => _selectedTab = 1), - ), - _NavItem( - icon: Icons.speed_rounded, - label: l.tabCapture, - selected: _selectedTab == 2, - colors: colors, - onTap: () => setState(() => _selectedTab = 2), - ), - _NavItem( - icon: Icons.cleaning_services_rounded, - label: l.tabCleanupPrivacy, - selected: _selectedTab == 3, - colors: colors, - onTap: () => setState(() => _selectedTab = 3), - ), - _NavItem( - icon: Icons.archive_rounded, - label: l.tabBackupRestore, - selected: _selectedTab == 4, - colors: colors, - onTap: () => setState(() => _selectedTab = 4), - ), - _NavItem( - icon: Icons.info_outline_rounded, - label: l.tabAbout, - selected: _selectedTab == 5, - colors: colors, - onTap: () => setState(() => _selectedTab = 5), - ), - ], - ), - ); - } - - Widget _buildContent(AppThemeData theme, AppThemeColorScheme colors) { - return switch (_selectedTab) { - 0 => _buildGeneralTab(colors), - 1 => _buildShortcutsTab(colors), - 2 => _buildPerformanceTab(colors), - 3 => _buildCleanupTab(colors), - 4 => _buildBackupTab(colors), - 5 => _buildAboutTab(colors), - _ => const SizedBox.shrink(), - }; - } - - Widget _buildGeneralTab(AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - return ListView( - padding: const EdgeInsets.all(24), - children: [ - _SectionCard( - colors: colors, - icon: Icons.language_rounded, - title: l.sectionLanguage, - children: [ - _SettingsRow( - label: l.settingLanguage, - colors: colors, - trailing: SegmentedButton( - style: _segmentedStyle(colors), - showSelectedIcon: false, - segments: const [ - ButtonSegment(value: 'auto', label: Text('Auto')), - ButtonSegment(value: 'en', label: Text('EN')), - ButtonSegment(value: 'es', label: Text('ES')), - ], - selected: {_preferredLanguage}, - onSelectionChanged: (s) { - setState(() => _preferredLanguage = s.first); - _markChanged(); - }, - ), - ), - ], - ), - - _SectionCard( - colors: colors, - icon: Icons.power_settings_new_rounded, - title: l.sectionStartup, - children: [ - _SettingsRow( - label: l.settingRunOnStartup, - subtitle: l.subtitleStartupDesc, - colors: colors, - trailing: Switch( - value: _runOnStartup, - activeThumbColor: colors.primary, - onChanged: (v) { - setState(() => _runOnStartup = v); - _markChanged(); - }, - ), - ), - ], - ), - - _SectionCard( - colors: colors, - icon: Icons.category_rounded, - title: l.sectionCategories, - subtitle: l.subtitleCategories, - children: [ - ..._colorEntries(l).map( - (e) => Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row( - children: [ - Container( - width: 14, - height: 14, - decoration: BoxDecoration( - color: e.color, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _CompactTextField( - initialValue: _colorLabels[e.key] ?? e.defaultName, - colors: colors, - onChanged: (v) { - _colorLabels[e.key] = v; - _markChanged(); - }, - ), - ), - ], - ), - ), - ), - ], - ), - - _SectionCard( - colors: colors, - icon: Icons.aspect_ratio_rounded, - title: l.sectionAppearance, - children: [ - _ThemeRow( - label: l.settingTheme, - value: _themeMode, - colors: colors, - options: [ - (value: 'light', label: l.themeLight), - (value: 'dark', label: l.themeDark), - (value: 'auto', label: l.themeAuto), - ], - onChanged: (v) { - setState(() => _themeMode = v); - _markChanged(); - }, - ), - _NumberRow( - label: l.settingPanelWidth, - value: _popupWidth, - min: 300, - max: 600, - colors: colors, - onChanged: (v) { - setState(() => _popupWidth = v); - _markChanged(); - }, - ), - _NumberRow( - label: l.settingPanelHeight, - value: _popupHeight, - min: 300, - max: 800, - colors: colors, - onChanged: (v) { - setState(() => _popupHeight = v); - _markChanged(); - }, - ), - _NumberRow( - label: l.settingLinesCollapsed, - value: _cardMinLines, - min: 1, - max: 10, - colors: colors, - onChanged: (v) { - setState(() => _cardMinLines = v); - _markChanged(); - }, - ), - _NumberRow( - label: l.settingLinesExpanded, - value: _cardMaxLines, - min: 1, - max: 20, - colors: colors, - onChanged: (v) { - setState(() => _cardMaxLines = v); - _markChanged(); - }, - ), - ], - ), - - const SizedBox(height: 16), - ], - ); - } - - Widget _buildPerformanceTab(AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - return ListView( - padding: const EdgeInsets.all(24), - children: [ - _SectionCard( - colors: colors, - icon: Icons.speed_rounded, - title: l.sectionPerformance, - children: [ - _NumberRow( - label: l.settingItemsPerPage, - value: _pageSize, - min: 5, - max: 100, - colors: colors, - onChanged: (v) { - setState(() => _pageSize = v); - _markChanged(); - }, - ), - _NumberRow( - label: l.settingMemoryLimit, - value: _maxItemsBeforeCleanup, - min: 20, - max: 500, - colors: colors, - onChanged: (v) { - setState(() => _maxItemsBeforeCleanup = v); - _markChanged(); - }, - ), - _NumberRow( - label: l.settingScrollThreshold, - value: _scrollLoadThreshold, - min: 50, - max: 500, - colors: colors, - onChanged: (v) { - setState(() => _scrollLoadThreshold = v); - _markChanged(); - }, - ), - ], - ), - - _SectionCard( - colors: colors, - icon: Icons.content_paste_go_rounded, - title: l.sectionPaste, - subtitle: Platform.isWindows - ? l.subtitlePastePreset - : l.subtitlePastePresetStandard, - children: [ - _SettingsRow( - label: l.settingPasteSpeed, - subtitle: l.subtitlePasteSpeed, - colors: colors, - trailing: _PresetDropdown( - value: _pastePresetName, - items: Platform.isWindows - ? const ['Instant', 'Fast', 'Normal', 'Safe', 'Slow'] - : const ['Fast', 'Normal', 'Safe', 'Slow'], - labels: { - 'Instant': l.pastePresetInstant, - 'Fast': l.pastePresetFast, - 'Normal': l.pastePresetNormal, - 'Safe': l.pastePresetSafe, - 'Slow': l.pastePresetSlow, - }, - hint: l.pastePresetCustom, - colors: colors, - onChanged: _applyPastePreset, - ), - ), - const SizedBox(height: 6), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colors.warning.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: colors.warning.withValues(alpha: 0.3), - ), - ), - child: Text( - Platform.isWindows - ? l.pastePresetWarning - : l.pastePresetWarningStandard, - style: TextStyle( - fontSize: 10.5, - color: colors.onSurfaceVariant, - ), - ), - ), - ], - ), - - _SectionCard( - colors: colors, - icon: Icons.image_rounded, - title: l.sectionMultimedia, - subtitle: l.subtitleMultimedia, - children: [ - _ToggleRow( - label: l.settingGenerateImageThumbnails, - subtitle: l.subtitleGenerateImageThumbnails, - value: _generateImageThumbnails, - colors: colors, - onChanged: (v) { - setState(() => _generateImageThumbnails = v); - _markChanged(); - }, - ), - _ToggleRow( - label: l.settingGenerateVideoThumbnails, - subtitle: l.subtitleGenerateVideoThumbnails, - value: _generateVideoThumbnails, - colors: colors, - onChanged: (v) { - setState(() => _generateVideoThumbnails = v); - _markChanged(); - }, - ), - _ToggleRow( - label: l.settingGenerateAudioThumbnails, - subtitle: l.subtitleGenerateAudioThumbnails, - value: _generateAudioThumbnails, - colors: colors, - onChanged: (v) { - setState(() => _generateAudioThumbnails = v); - _markChanged(); - }, - ), - const SizedBox(height: 8), - _NumberRow( - label: l.settingMaxImageSize, - value: _maxImageProcessingSizeMB, - min: 1, - max: 200, - colors: colors, - onChanged: (v) { - setState(() => _maxImageProcessingSizeMB = v); - _markChanged(); - }, - ), - const SizedBox(height: 4), - Text( - l.subtitleMaxImageSize, - style: TextStyle(fontSize: 10.5, color: colors.onSurfaceMuted), - ), - ], - ), - - _SectionCard( - colors: colors, - icon: Icons.toggle_on_rounded, - title: l.sectionBehavior, - children: [ - _ToggleRow( - label: l.settingHideOnDeactivate, - subtitle: l.subtitleHideOnDeactivate, - value: _hideOnDeactivate, - colors: colors, - onChanged: (v) { - setState(() => _hideOnDeactivate = v); - _markChanged(); - }, - ), - _ToggleRow( - label: l.settingRememberWindowPosition, - subtitle: l.subtitleRememberWindowPosition, - value: _rememberWindowPosition, - colors: colors, - onChanged: (v) { - setState(() => _rememberWindowPosition = v); - _markChanged(); - }, - ), - _ToggleRow( - label: l.settingScrollToTopOnOpen, - subtitle: l.subtitleScrollToTopOnOpen, - value: _resetScrollOnShow, - colors: colors, - onChanged: (v) { - setState(() => _resetScrollOnShow = v); - _markChanged(); - }, - ), - _ToggleRow( - label: l.settingClearSearchOnOpen, - subtitle: l.subtitleClearSearchOnOpen, - value: _resetSearchOnShow, - colors: colors, - onChanged: (v) { - setState(() => _resetSearchOnShow = v); - _markChanged(); - }, - ), - _ToggleRow( - label: l.settingResetFiltersOnOpen, - subtitle: l.subtitleResetFiltersOnOpen, - value: _resetFiltersOnShow, - colors: colors, - onChanged: (v) { - setState(() => _resetFiltersOnShow = v); - _markChanged(); - }, - ), - ], - ), - - const SizedBox(height: 16), - ], - ); - } - - Widget _buildCleanupTab(AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - return ListView( - padding: const EdgeInsets.all(24), - children: [ - _SectionCard( - colors: colors, - icon: Icons.cleaning_services_rounded, - title: l.sectionCleanupPrivacy, - children: [ - _NumberRow( - label: l.settingRetentionDaysLabel, - value: _retentionDays, - min: 0, - max: 365, - colors: colors, - onChanged: (v) { - setState(() => _retentionDays = v); - _markChanged(); - }, - ), - _NumberRow( - label: l.settingKeepBrokenItemsLabel, - value: _keepBrokenItemsDays, - min: 0, - max: 365, - colors: colors, - onChanged: (v) { - setState(() => _keepBrokenItemsDays = v); - _markChanged(); - }, - ), - const SizedBox(height: 4), - Text( - l.subtitleKeepBrokenItems, - style: TextStyle(fontSize: 10.5, color: colors.onSurfaceMuted), - ), - const SizedBox(height: 12), - _SettingsRow( - label: l.settingImagesQuotaLabel, - subtitle: l.subtitleImagesQuota, - colors: colors, - trailing: _PresetDropdown( - value: _imagesQuotaKey(_imagesQuotaMB), - items: const [ - 'off', - '256', - '512', - '1024', - '2048', - '5120', - '10240', - ], - labels: { - 'off': l.imagesQuotaOff, - '256': '256 MB', - '512': '512 MB', - '1024': '1 GB', - '2048': '2 GB', - '5120': '5 GB', - '10240': '10 GB', - }, - hint: l.imagesQuotaOff, - colors: colors, - onChanged: (v) { - setState(() { - _imagesQuotaMB = v == 'off' ? 0 : int.parse(v); - }); - _markChanged(); - }, - ), - ), - const SizedBox(height: 12), - _ActionTile( - icon: Icons.delete_sweep_outlined, - label: l.settingClearHistoryLabel, - colors: colors, - onTap: _clearHistory, - ), - ], - ), - _SectionCard( - colors: colors, - icon: Icons.restart_alt_rounded, - title: l.sectionReset, - children: [ - _ActionTile( - icon: Icons.settings_backup_restore_rounded, - label: l.resetSoftLabel, - subtitle: l.resetSoftSubtitle, - colors: colors, - onTap: _softReset, - ), - _ActionTile( - icon: Icons.delete_forever_rounded, - label: l.resetHardLabel, - subtitle: l.resetHardSubtitle, - colors: colors, - onTap: _hardReset, - ), - ], - ), - const SizedBox(height: 16), - ], - ); - } - - Widget _buildBackupTab(AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - return ListView( - padding: const EdgeInsets.all(24), - children: [ - _SectionCard( - colors: colors, - icon: Icons.archive_rounded, - title: l.sectionBackupRestore, - subtitle: l.subtitleBackup, - children: [ - if (_lastBackupDateUtc != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - l.backupLastDate(_formatDate(_lastBackupDateUtc!)), - style: TextStyle(fontSize: 11, color: colors.onSurfaceMuted), - ), - ) - else - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - l.backupNone, - style: TextStyle(fontSize: 11, color: colors.onSurfaceMuted), - ), - ), - Row( - children: [ - Expanded( - child: _ActionTile( - icon: Icons.backup_rounded, - label: l.backupCreateLabel, - colors: colors, - onTap: _createBackup, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _ActionTile( - icon: Icons.restore_rounded, - label: l.backupRestoreLabel, - colors: colors, - onTap: _restoreBackup, - ), - ), - ], - ), - ], - ), - _SectionCard( - colors: colors, - icon: Icons.help_outline_rounded, - title: l.sectionSupport, - children: [ - _ActionTile( - icon: Icons.download_rounded, - label: l.supportExportLogs, - subtitle: l.supportExportLogsSubtitle, - colors: colors, - onTap: _exportLogs, - ), - _ActionTile( - icon: Icons.folder_open_rounded, - label: l.supportOpenLogsFolder, - subtitle: l.supportOpenLogsFolderSubtitle, - colors: colors, - onTap: _openLogsFolder, - ), - _ActionTile( - icon: Icons.bug_report_outlined, - label: l.supportGitHub, - colors: colors, - onTap: () => - _openUrl('https://github.com/rgdevment/CopyPaste/issues'), - ), - ], - ), - ], - ); - } - - Widget _buildShortcutsTab(AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - return ListView( - padding: const EdgeInsets.all(24), - children: [ - _SectionCard( - colors: colors, - icon: Icons.keyboard_rounded, - title: l.sectionKeyboardShortcut, - children: [ - _SettingsRow( - label: l.settingHotkeyShortcutLabel, - subtitle: - '${l.currentShortcut(_hotkeyString(' + '))}\n' - '${l.subtitleGlobalHotkeyWarning}', - colors: colors, - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _ModifierChip( - label: Platform.isMacOS ? '⌃ Control' : 'Ctrl', - selected: _hotkeyCtrl, - colors: colors, - onTap: () { - setState(() => _hotkeyCtrl = !_hotkeyCtrl); - _markChanged(); - }, - ), - _ModifierChip( - label: Platform.isMacOS ? '⌘ Command' : 'Win', - selected: _hotkeyWin, - colors: colors, - onTap: () { - setState(() => _hotkeyWin = !_hotkeyWin); - _markChanged(); - }, - ), - _ModifierChip( - label: Platform.isMacOS ? '⌥ Option' : 'Alt', - selected: _hotkeyAlt, - colors: colors, - onTap: () { - setState(() => _hotkeyAlt = !_hotkeyAlt); - _markChanged(); - }, - ), - _ModifierChip( - label: Platform.isMacOS ? '⇧ Shift' : 'Shift', - selected: _hotkeyShift, - colors: colors, - onTap: () { - setState(() => _hotkeyShift = !_hotkeyShift); - _markChanged(); - }, - ), - const SizedBox(width: 4), - _KeySelector( - currentKey: _hotkeyKeyName, - colors: colors, - onChanged: (k, vk) { - setState(() { - _hotkeyKeyName = k; - _hotkeyVirtualKey = vk; - }); - _markChanged(); - }, - ), - ], - ), - if (!_openHotkeyHasModifier) - _HotkeyValidationText( - message: l.hotkeyRequiresModifier, - colors: colors, - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Divider(height: 1, color: colors.divider), - ), - _SettingsRow( - label: l.settingPlainPasteHotkeyLabel, - subtitle: - '${_plainPasteHotkeyEnabled ? l.currentShortcut(_plainPasteHotkeyString(' + ')) : l.shortcutDisabled}\n' - '${l.subtitlePlainPasteHotkey}', - colors: colors, - trailing: Switch( - value: _plainPasteHotkeyEnabled, - activeThumbColor: colors.primary, - onChanged: (value) { - setState(() => _plainPasteHotkeyEnabled = value); - _markChanged(); - }, - ), - ), - const SizedBox(height: 8), - IgnorePointer( - ignoring: !_plainPasteHotkeyEnabled, - child: Opacity( - opacity: _plainPasteHotkeyEnabled ? 1 : 0.45, - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _ModifierChip( - label: Platform.isMacOS ? '⌃ Control' : 'Ctrl', - selected: _plainPasteHotkeyCtrl, - colors: colors, - onTap: () { - setState( - () => _plainPasteHotkeyCtrl = !_plainPasteHotkeyCtrl, - ); - _markChanged(); - }, - ), - _ModifierChip( - label: Platform.isMacOS ? '⌘ Command' : 'Win', - selected: _plainPasteHotkeyWin, - colors: colors, - onTap: () { - setState( - () => _plainPasteHotkeyWin = !_plainPasteHotkeyWin, - ); - _markChanged(); - }, - ), - _ModifierChip( - label: Platform.isMacOS ? '⌥ Option' : 'Alt', - selected: _plainPasteHotkeyAlt, - colors: colors, - onTap: () { - setState( - () => _plainPasteHotkeyAlt = !_plainPasteHotkeyAlt, - ); - _markChanged(); - }, - ), - _ModifierChip( - label: Platform.isMacOS ? '⇧ Shift' : 'Shift', - selected: _plainPasteHotkeyShift, - colors: colors, - onTap: () { - setState( - () => - _plainPasteHotkeyShift = !_plainPasteHotkeyShift, - ); - _markChanged(); - }, - ), - const SizedBox(width: 4), - _KeySelector( - currentKey: _plainPasteHotkeyKeyName, - colors: colors, - onChanged: (key, virtualKey) { - setState(() { - _plainPasteHotkeyKeyName = key; - _plainPasteHotkeyVirtualKey = virtualKey; - }); - _markChanged(); - }, - ), - ], - ), - ), - ), - if (_plainPasteHotkeyEnabled && !_plainPasteHotkeyHasModifier) - _HotkeyValidationText( - message: l.hotkeyRequiresModifier, - colors: colors, - ), - if (_hotkeysConflict) - _HotkeyValidationText( - message: l.hotkeyConflictWarning, - colors: colors, - ), - const SizedBox(height: 12), - Align( - alignment: Alignment.centerLeft, - child: OutlinedButton.icon( - onPressed: _restoreRecommendedHotkeys, - icon: const Icon(Icons.restore_rounded, size: 16), - label: Text(l.restoreRecommendedHotkeys), - ), - ), - ], - ), - _SectionCard( - colors: colors, - icon: Icons.keyboard_rounded, - title: l.sectionShortcuts, - subtitle: l.subtitleShortcutScopes, - children: [ - _ShortcutRow( - keys: _hotkeyString(), - description: l.shortcutOpenClose, - colors: colors, - ), - if (_plainPasteHotkeyEnabled) - _ShortcutRow( - keys: _plainPasteHotkeyString(), - description: l.shortcutPastePlainDirect, - colors: colors, - ), - _ShortcutRow( - keys: Platform.isMacOS ? '⌘V' : 'Ctrl+V', - description: l.shortcutSystemPaste, - colors: colors, - ), - _ShortcutRow( - keys: '\u2191 / \u2193', - description: l.shortcutArrows, - colors: colors, - ), - _ShortcutRow( - keys: 'Enter', - description: l.shortcutEnter, - colors: colors, - ), - _ShortcutRow( - keys: 'Shift+Enter', - description: l.shortcutPasteSelectedPlain, - colors: colors, - ), - _ShortcutRow( - keys: 'Delete', - description: l.shortcutDelete, - colors: colors, - ), - _ShortcutRow(keys: 'P', description: l.shortcutPin, colors: colors), - _ShortcutRow( - keys: 'E', - description: l.shortcutEdit, - colors: colors, - ), - _ShortcutRow( - keys: '\u2192', - description: l.shortcutExpand, - colors: colors, - ), - _ShortcutRow( - keys: 'Escape', - description: l.shortcutEscape, - colors: colors, - ), - _ShortcutRow( - keys: Platform.isMacOS ? 'Cmd+1' : 'Ctrl+1', - description: l.shortcutTab1, - colors: colors, - ), - _ShortcutRow( - keys: Platform.isMacOS ? 'Cmd+2' : 'Ctrl+2', - description: l.shortcutTab2, - colors: colors, - ), - _ShortcutRow( - keys: 'Shift+Tab', - description: l.shortcutFocusSearch, - colors: colors, - ), - ], - ), - ], - ); - } - - Widget _buildAboutTab(AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - return ListView( - padding: const EdgeInsets.all(24), - children: [ - _SectionCard( - colors: colors, - icon: Icons.info_outline_rounded, - title: l.sectionAbout, - children: [ - Text( - l.aboutDescription, - style: TextStyle( - fontSize: 12, - color: colors.onSurfaceVariant, - height: 1.5, - ), - ), - const SizedBox(height: 14), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - _AboutBadge( - icon: Icons.new_releases_outlined, - label: 'v${AppConfig.appVersion}', - colors: colors, - ), - _AboutBadge( - icon: Icons.lock_outline_rounded, - label: l.aboutTagLocal, - colors: colors, - ), - _AboutBadge( - icon: Icons.code_rounded, - label: l.aboutTagOpenSource, - colors: colors, - ), - _AboutBadge( - icon: Icons.favorite_border_rounded, - label: l.aboutTagFree, - colors: colors, - ), - ], - ), - ], - ), - _SectionCard( - colors: colors, - icon: Icons.apps_rounded, - title: l.sectionOtherTools, - children: [ - _ActionTile( - icon: Icons.open_in_new_rounded, - label: l.otherToolLinkUnbound, - subtitle: l.otherToolLinkUnboundDesc, - colors: colors, - leading: Padding( - padding: const EdgeInsets.only(top: 1), - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.asset( - 'assets/icons/icon_linkunbound.png', - width: 28, - height: 28, - ), - ), - ), - onTap: () => _openUrl('https://github.com/rgdevment/LinkUnbound'), - ), - ], - ), - _SectionCard( - colors: colors, - icon: Icons.link_rounded, - title: l.sectionLinks, - children: [ - _ActionTile( - icon: Icons.code_rounded, - label: l.linkGitHub, - colors: colors, - onTap: () => _openUrl('https://github.com/rgdevment/CopyPaste'), - ), - _ActionTile( - icon: Icons.coffee_rounded, - label: l.linkCoffee, - colors: colors, - onTap: () => _openUrl('https://buymeacoffee.com/rgdevment'), - ), - ], - ), - _SectionCard( - colors: colors, - icon: Icons.shield_outlined, - title: l.sectionPrivacy, - children: [ - Text( - l.privacyStatement, - style: TextStyle( - fontSize: 12, - color: colors.onSurfaceVariant, - height: 1.5, - ), - ), - const SizedBox(height: 4), - _ActionTile( - icon: Icons.open_in_new_rounded, - label: l.privacyPolicy, - colors: colors, - onTap: () => _openUrl( - 'https://github.com/rgdevment/CopyPaste/blob/main/PRIVACY.md', - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.only(top: 12, left: 4), - child: Text( - l.aboutLicense, - style: TextStyle(fontSize: 10.5, color: colors.onSurfaceMuted), - ), - ), - ], - ); - } - - Widget _buildFooter(AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - return Container( - height: 52, - padding: const EdgeInsets.symmetric(horizontal: 20), - color: colors.surface, - child: Row( - children: [ - _SmallButton( - label: l.buttonReset, - colors: colors, - onTap: _resetToDefaults, - ), - const Spacer(), - if (_hotkeyChanged) - Padding( - padding: const EdgeInsets.only(right: 12), - child: Text( - l.hotkeyWillApply, - style: TextStyle(fontSize: 10, color: colors.onSurfaceMuted), - ), - ), - if (_saving) - Padding( - padding: const EdgeInsets.only(right: 12), - child: Text( - l.savingIndicator, - style: TextStyle(fontSize: 10, color: colors.onSurfaceMuted), - ), - ) - else if (_savedRecently) - Padding( - padding: const EdgeInsets.only(right: 12), - child: Text( - l.savedIndicator, - style: TextStyle(fontSize: 10, color: colors.onSurfaceMuted), - ), - ), - _SmallButton( - label: l.buttonClose, - colors: colors, - onTap: () => Navigator.of(context).pop(), - ), - ], - ), - ); - } - - Future _exportLogs() async { - final l = AppLocalizations.of(context); - try { - final ts = DateTime.now() - .toIso8601String() - .replaceAll(':', '-') - .split('.') - .first; - final fileName = 'CopyPaste_logs_$ts.zip'; - final savePath = _resolveDownloadsPath(fileName); - - final count = await SupportService.exportLogs( - widget.storage, - AppConfig.appVersion, - savePath, - ); - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - count > 0 ? l.supportExportSuccess : l.supportExportEmpty, - ), - action: SnackBarAction( - label: l.supportShowInFiles, - onPressed: () => SupportService.revealFile(savePath), - ), - duration: const Duration(seconds: 5), - ), - ); - } catch (e, s) { - AppLogger.exception(e, s, '_exportLogs'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l.supportExportError), - duration: const Duration(seconds: 3), - ), - ); - } - } - - String _resolveDownloadsPath(String fileName) { - final String base; - if (Platform.isWindows) { - base = p.join(Platform.environment['USERPROFILE'] ?? '', 'Downloads'); - } else { - base = p.join(Platform.environment['HOME'] ?? '', 'Downloads'); - } - final dir = Directory(base); - if (dir.existsSync()) return p.join(base, fileName); - return p.join(widget.storage.logsPath, fileName); - } - - Future _openLogsFolder() async { - try { - await SupportService.openLogsFolder(widget.storage); - } catch (e, s) { - AppLogger.exception(e, s, '_openLogsFolder'); - } - } - - Future _softReset() async { - final l = AppLocalizations.of(context); - final confirmed = await _showConfirmDialog( - l.resetSoftConfirmTitle, - l.resetSoftConfirmMessage, - l.resetConfirmButton, - ); - if (confirmed == true) await widget.onSoftReset(); - } - - Future _hardReset() async { - final l = AppLocalizations.of(context); - final confirmed = await _showConfirmDialog( - l.resetHardConfirmTitle, - l.resetHardConfirmMessage, - l.resetConfirmButton, - ); - if (confirmed == true) await widget.onHardReset(); - } - - Future _clearHistory() async { - final l = AppLocalizations.of(context); - final confirmed = await _showConfirmDialog( - l.clearHistoryConfirmTitle, - l.clearHistoryConfirmMessage, - l.clearHistoryConfirmButton, - ); - if (confirmed == true) { - await widget.clipboardService.clearUnpinnedHistory(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l.historyCleared), - duration: const Duration(seconds: 2), - ), - ); - } - } - } - - Future _createBackup() async { - try { - final ts = DateTime.now() - .toIso8601String() - .replaceAll(':', '-') - .split('.') - .first; - final suggestedName = 'CopyPaste_Backup_$ts'; - - final path = await FilePicker.saveFile( - dialogTitle: 'Save Backup', - fileName: '$suggestedName.zip', - type: FileType.custom, - allowedExtensions: ['zip'], - ); - if (path == null) return; - - final count = await widget.clipboardService.getItemCount(); - await BackupService.createBackup( - path, - widget.storage, - AppConfig.appVersion, - itemCount: count, - walCheckpoint: widget.clipboardService.walCheckpoint, - ); - setState(() => _lastBackupDateUtc = DateTime.now().toUtc()); - _markChanged(); - if (mounted) { - final l = AppLocalizations.of(context); - final filename = path.split(Platform.pathSeparator).last; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l.backupSavedFile(filename)), - duration: const Duration(seconds: 3), - ), - ); - } - } catch (e) { - if (mounted) { - final l = AppLocalizations.of(context); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l.backupError))); - } - } - } - - Future _restoreBackup() async { - final l = AppLocalizations.of(context); - - final result = await FilePicker.pickFiles( - dialogTitle: l.restoreDialogTitle, - type: FileType.custom, - allowedExtensions: ['zip'], - ); - if (result == null || result.files.isEmpty) return; - - final path = result.files.single.path; - if (path == null || !File(path).existsSync()) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l.restoreFileNotFound))); - } - return; - } - - if (!mounted) return; - final colors = CopyPasteTheme.colorsOf(context); - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: colors.cardBackground, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text( - l.restoreDialogTitle, - style: TextStyle(fontSize: 14, color: colors.onSurface), - ), - content: Text( - l.restoreDialogWarning, - style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text( - l.buttonCancel, - style: TextStyle(color: colors.onSurfaceMuted), - ), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text( - l.buttonRestore, - style: TextStyle(color: colors.danger), - ), - ), - ], - ), - ); - if (confirmed != true) return; - - try { - final manifest = await BackupService.restoreBackup(path, widget.storage); - if (manifest != null && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l.restoreRestartRequired), - duration: const Duration(seconds: 2), - ), - ); - await Future.delayed(const Duration(seconds: 2)); - exit(0); - } else if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l.restoreCompleted))); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l.restoreError))); - } - } - } - - void _openUrl(String url) { - UrlHelper.open(url); - } - - String _formatDate(DateTime dt) => - '${dt.day.toString().padLeft(2, '0')}/' - '${dt.month.toString().padLeft(2, '0')}/${dt.year}'; - - Future _showConfirmDialog( - String title, - String message, - String confirmLabel, - ) { - final colors = CopyPasteTheme.colorsOf(context); - final l = AppLocalizations.of(context); - return showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: colors.cardBackground, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text( - title, - style: TextStyle(fontSize: 14, color: colors.onSurface), - ), - content: Text( - message, - style: TextStyle(fontSize: 12.5, color: colors.onSurfaceVariant), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text( - l.buttonCancel, - style: TextStyle(color: colors.onSurfaceMuted), - ), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(confirmLabel, style: TextStyle(color: colors.danger)), - ), - ], - ), - ); - } - - ButtonStyle _segmentedStyle(AppThemeColorScheme colors) => - SegmentedButton.styleFrom( - foregroundColor: colors.onSurface, - selectedForegroundColor: colors.primary, - selectedBackgroundColor: colors.primary.withValues(alpha: 0.12), - side: BorderSide(color: colors.divider), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - textStyle: const TextStyle(fontSize: 12), - ); - - static List<({String key, String defaultName, Color color})> _colorEntries( - AppLocalizations l, - ) => [ - (key: 'Red', defaultName: l.colorRed, color: const Color(0xFFE53935)), - (key: 'Green', defaultName: l.colorGreen, color: const Color(0xFF43A047)), - (key: 'Purple', defaultName: l.colorPurple, color: const Color(0xFF8E24AA)), - (key: 'Yellow', defaultName: l.colorYellow, color: const Color(0xFFFDD835)), - (key: 'Blue', defaultName: l.colorBlue, color: const Color(0xFF1E88E5)), - (key: 'Orange', defaultName: l.colorOrange, color: const Color(0xFFFB8C00)), - ]; -} - -class _NavItem extends StatefulWidget { - const _NavItem({ - required this.icon, - required this.label, - required this.selected, - required this.colors, - required this.onTap, - }); - - final IconData icon; - final String label; - final bool selected; - final AppThemeColorScheme colors; - final VoidCallback onTap; - - @override - State<_NavItem> createState() => _NavItemState(); -} - -class _NavItemState extends State<_NavItem> { - bool _hovering = false; - - @override - Widget build(BuildContext context) { - final bg = widget.selected - ? widget.colors.primary.withValues(alpha: 0.12) - : (_hovering - ? widget.colors.onSurface.withValues(alpha: 0.05) - : Colors.transparent); - final fg = widget.selected - ? widget.colors.primary - : widget.colors.onSurface; - - return MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: widget.onTap, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(widget.icon, size: 18, color: fg), - const SizedBox(width: 12), - Flexible( - child: Text( - widget.label, - style: TextStyle( - fontSize: 13, - fontWeight: widget.selected - ? FontWeight.w600 - : FontWeight.w400, - color: fg, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _SectionCard extends StatelessWidget { - const _SectionCard({ - required this.colors, - required this.icon, - required this.title, - required this.children, - this.subtitle, - }); - - final AppThemeColorScheme colors; - final IconData icon; - final String title; - final String? subtitle; - final List children; - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colors.cardBackground, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colors.cardBorder), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 14, color: colors.onSurfaceMuted), - const SizedBox(width: 8), - Expanded( - child: Text( - title, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: colors.onSurfaceMuted, - letterSpacing: 1.0, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - if (subtitle != null) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - subtitle!, - style: TextStyle(fontSize: 11, color: colors.onSurfaceMuted), - ), - ), - const SizedBox(height: 12), - ...children, - ], - ), - ); - } -} - -class _SettingsRow extends StatelessWidget { - const _SettingsRow({ - required this.label, - required this.colors, - this.subtitle, - this.trailing, - }); - - final String label; - final String? subtitle; - final AppThemeColorScheme colors; - final Widget? trailing; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(fontSize: 12.5, color: colors.onSurface), - ), - if (subtitle != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - subtitle!, - style: TextStyle( - fontSize: 10.5, - color: colors.onSurfaceMuted, - ), - ), - ), - ], - ), - ), - ?trailing, - ], - ), - ); - } -} - -class _ToggleRow extends StatelessWidget { - const _ToggleRow({ - required this.label, - required this.value, - required this.colors, - required this.onChanged, - this.subtitle, - }); - - final String label; - final String? subtitle; - final bool value; - final AppThemeColorScheme colors; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(fontSize: 12.5, color: colors.onSurface), - ), - if (subtitle != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - subtitle!, - style: TextStyle( - fontSize: 10.5, - color: colors.onSurfaceMuted, - ), - ), - ), - ], - ), - ), - Switch( - value: value, - activeThumbColor: colors.primary, - onChanged: onChanged, - ), - ], - ), - ); - } -} - -class _ThemeRow extends StatelessWidget { - const _ThemeRow({ - required this.label, - required this.value, - required this.colors, - required this.options, - required this.onChanged, - }); - - final String label; - final String value; - final AppThemeColorScheme colors; - final List<({String value, String label})> options; - final void Function(String) onChanged; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: TextStyle(fontSize: 13, color: colors.onSurface), - ), - ), - Container( - decoration: BoxDecoration( - color: colors.surfaceVariant, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: colors.onSurface.withValues(alpha: 0.08), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final opt in options) - GestureDetector( - onTap: () => onChanged(opt.value), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 6, - ), - decoration: BoxDecoration( - color: value == opt.value - ? colors.primary.withValues(alpha: 0.12) - : Colors.transparent, - borderRadius: BorderRadius.circular(7), - border: value == opt.value - ? Border.all( - color: colors.primary.withValues(alpha: 0.3), - ) - : null, - ), - child: Text( - opt.label, - style: TextStyle( - fontSize: 12, - fontWeight: value == opt.value - ? FontWeight.w600 - : FontWeight.w400, - color: value == opt.value - ? colors.primary - : colors.onSurfaceMuted, - ), - ), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _NumberRow extends StatelessWidget { - const _NumberRow({ - required this.label, - required this.value, - required this.min, - required this.max, - required this.colors, - required this.onChanged, - }); - - final String label; - final int value; - final int min; - final int max; - final AppThemeColorScheme colors; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: TextStyle(fontSize: 12.5, color: colors.onSurface), - ), - ), - SizedBox( - width: 130, - height: 30, - child: Row( - children: [ - _StepButton( - icon: Icons.remove, - colors: colors, - isLeft: true, - onTap: value > min ? () => onChanged(value - 1) : null, - ), - Expanded( - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - border: Border.symmetric( - horizontal: BorderSide(color: colors.divider), - ), - color: colors.surface, - ), - child: Text( - '$value', - style: TextStyle( - fontSize: 12, - color: colors.onSurface, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - _StepButton( - icon: Icons.add, - colors: colors, - isLeft: false, - onTap: value < max ? () => onChanged(value + 1) : null, - ), - ], - ), - ), - ], - ), - ); - } -} - -class _StepButton extends StatelessWidget { - const _StepButton({ - required this.icon, - required this.colors, - required this.isLeft, - this.onTap, - }); - - final IconData icon; - final AppThemeColorScheme colors; - final bool isLeft; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 30, - height: 30, - decoration: BoxDecoration( - color: colors.surface, - border: Border.all(color: colors.divider), - borderRadius: isLeft - ? const BorderRadius.horizontal(left: Radius.circular(6)) - : const BorderRadius.horizontal(right: Radius.circular(6)), - ), - child: Icon( - icon, - size: 14, - color: onTap != null ? colors.onSurface : colors.onSurfaceMuted, - ), - ), - ); - } -} - -class _CompactTextField extends StatefulWidget { - const _CompactTextField({ - required this.initialValue, - required this.colors, - required this.onChanged, - }); - - final String initialValue; - final AppThemeColorScheme colors; - final ValueChanged onChanged; - - @override - State<_CompactTextField> createState() => _CompactTextFieldState(); -} - -class _CompactTextFieldState extends State<_CompactTextField> { - late final TextEditingController _controller; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.initialValue); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return TextField( - controller: _controller, - maxLength: 20, - style: TextStyle(fontSize: 12, color: widget.colors.onSurface), - decoration: InputDecoration( - isDense: true, - counterText: '', - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(6), - borderSide: BorderSide(color: widget.colors.divider), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(6), - borderSide: BorderSide(color: widget.colors.divider), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(6), - borderSide: BorderSide(color: widget.colors.primary), - ), - ), - onChanged: widget.onChanged, - ); - } -} - -class _PresetDropdown extends StatelessWidget { - const _PresetDropdown({ - required this.value, - required this.items, - required this.colors, - required this.onChanged, - this.labels = const {}, - this.hint = 'Custom', - }); - - final String? value; - final List items; - final Map labels; - final String hint; - final AppThemeColorScheme colors; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: colors.divider), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - hint: Text( - hint, - style: TextStyle(fontSize: 12, color: colors.onSurfaceMuted), - ), - isDense: true, - dropdownColor: colors.cardBackground, - style: TextStyle(fontSize: 12, color: colors.onSurface), - icon: Icon( - Icons.arrow_drop_down, - size: 16, - color: colors.onSurfaceMuted, - ), - items: items - .map( - (i) => DropdownMenuItem(value: i, child: Text(labels[i] ?? i)), - ) - .toList(), - onChanged: (v) { - if (v != null) onChanged(v); - }, - ), - ), - ); - } -} - -class _ActionTile extends StatefulWidget { - const _ActionTile({ - required this.icon, - required this.label, - required this.colors, - required this.onTap, - this.subtitle, - this.leading, - }); - - final IconData icon; - final String label; - final String? subtitle; - final Widget? leading; - final AppThemeColorScheme colors; - final VoidCallback onTap; - - @override - State<_ActionTile> createState() => _ActionTileState(); -} - -class _ActionTileState extends State<_ActionTile> { - bool _hovering = false; - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: widget.onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), - decoration: BoxDecoration( - color: _hovering - ? widget.colors.onSurface.withValues(alpha: 0.05) - : Colors.transparent, - borderRadius: BorderRadius.circular(6), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - widget.leading ?? - Padding( - padding: const EdgeInsets.only(top: 1), - child: Icon( - widget.icon, - size: 16, - color: widget.colors.onSurfaceMuted, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.label, - style: TextStyle( - fontSize: 12.5, - color: widget.colors.onSurface, - ), - ), - if (widget.subtitle != null) ...[ - const SizedBox(height: 2), - Text( - widget.subtitle!, - style: TextStyle( - fontSize: 11, - color: widget.colors.onSurfaceMuted, - height: 1.4, - ), - ), - ], - ], - ), - ), - ], - ), - ), - ), - ); - } -} - -class _AboutBadge extends StatelessWidget { - const _AboutBadge({ - required this.icon, - required this.label, - required this.colors, - }); - - final IconData icon; - final String label; - final AppThemeColorScheme colors; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - border: Border.all(color: colors.cardBorder), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 11, color: colors.onSurfaceMuted), - const SizedBox(width: 5), - Text( - label, - style: TextStyle(fontSize: 10.5, color: colors.onSurfaceVariant), - ), - ], - ), - ); - } -} - -class _ModifierChip extends StatelessWidget { - const _ModifierChip({ - required this.label, - required this.selected, - required this.colors, - required this.onTap, - }); - - final String label; - final bool selected; - final AppThemeColorScheme colors; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: selected - ? colors.primary.withValues(alpha: 0.15) - : colors.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: selected - ? colors.primary.withValues(alpha: 0.4) - : colors.divider, - ), - ), - child: Text( - label, - style: TextStyle( - fontSize: 11, - fontWeight: selected ? FontWeight.w600 : FontWeight.w400, - color: selected ? colors.primary : colors.onSurfaceMuted, - ), - ), - ), - ), - ); - } -} - -class _HotkeyValidationText extends StatelessWidget { - const _HotkeyValidationText({required this.message, required this.colors}); - - final String message; - final AppThemeColorScheme colors; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.warning_amber_rounded, size: 15, color: colors.danger), - const SizedBox(width: 6), - Expanded( - child: Text( - message, - style: TextStyle(fontSize: 11, color: colors.danger), - ), - ), - ], - ), - ); - } -} - -class _KeySelector extends StatelessWidget { - const _KeySelector({ - required this.currentKey, - required this.colors, - required this.onChanged, - }); - - final String currentKey; - final AppThemeColorScheme colors; - final void Function(String key, int virtualKey) onChanged; - - static const _keys = [ - ('A', 0x41), - ('B', 0x42), - ('C', 0x43), - ('D', 0x44), - ('E', 0x45), - ('F', 0x46), - ('G', 0x47), - ('H', 0x48), - ('I', 0x49), - ('J', 0x4A), - ('K', 0x4B), - ('L', 0x4C), - ('M', 0x4D), - ('N', 0x4E), - ('O', 0x4F), - ('P', 0x50), - ('Q', 0x51), - ('R', 0x52), - ('S', 0x53), - ('T', 0x54), - ('U', 0x55), - ('V', 0x56), - ('W', 0x57), - ('X', 0x58), - ('Y', 0x59), - ('Z', 0x5A), - ]; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: colors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: colors.primary.withValues(alpha: 0.3)), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: currentKey, - isDense: true, - dropdownColor: colors.cardBackground, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: colors.primary, - ), - icon: Icon(Icons.arrow_drop_down, size: 16, color: colors.primary), - items: _keys - .map((k) => DropdownMenuItem(value: k.$1, child: Text(k.$1))) - .toList(), - onChanged: (value) { - if (value == null) return; - final entry = _keys.firstWhere((k) => k.$1 == value); - onChanged(entry.$1, entry.$2); - }, - ), - ), - ); - } -} - -class _ShortcutRow extends StatelessWidget { - const _ShortcutRow({ - required this.keys, - required this.description, - required this.colors, - }); - - final String keys; - final String description; - final AppThemeColorScheme colors; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row( - children: [ - SizedBox( - width: 100, - child: Text( - keys, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: colors.onSurfaceVariant, - fontFamily: 'Consolas', - ), - ), - ), - Expanded( - child: Text( - description, - style: TextStyle(fontSize: 11, color: colors.onSurfaceMuted), - ), - ), - ], - ), - ); - } -} - -class _SmallButton extends StatefulWidget { - const _SmallButton({required this.label, required this.colors, this.onTap}); - - final String label; - final AppThemeColorScheme colors; - final VoidCallback? onTap; - - @override - State<_SmallButton> createState() => _SmallButtonState(); -} - -class _SmallButtonState extends State<_SmallButton> { - bool _hovering = false; - - @override - Widget build(BuildContext context) { - final enabled = widget.onTap != null; - return MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic, - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 100), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: _hovering - ? widget.colors.onSurface.withValues(alpha: 0.08) - : Colors.transparent, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - widget.label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: widget.colors.onSurface, - ), - ), - ), - ), - ); - } -} diff --git a/app/lib/services/auto_update_service.dart b/app/lib/services/auto_update_service.dart deleted file mode 100644 index 73acc488..00000000 --- a/app/lib/services/auto_update_service.dart +++ /dev/null @@ -1,45 +0,0 @@ -// coverage:ignore-file - -import 'dart:async'; - -import 'package:core/core.dart'; - -import 'release_manifest_service.dart'; - -const _isStoreBuild = bool.fromEnvironment('STORE_BUILD', defaultValue: false); - -class AutoUpdateService { - static StreamSubscription? _sub; - - static void Function(String version)? onUpdateAvailable; - - static bool get isStoreBuild => _isStoreBuild; - - static Future initialize({required String storageConfigDir}) async { - await ReleaseManifestService.initialize(storageConfigDir: storageConfigDir); - _sub ??= ReleaseManifestService.stream.listen((state) { - if (state == null) return; - final latest = state.manifest.latest; - if (ReleaseManifestService.compareVersions(latest, AppConfig.appVersion) > - 0) { - AppLogger.info('Update available: ${AppConfig.appVersion} → $latest'); - onUpdateAvailable?.call(latest); - } - }); - - final cached = ReleaseManifestService.current; - if (cached != null) { - final latest = cached.manifest.latest; - if (ReleaseManifestService.compareVersions(latest, AppConfig.appVersion) > - 0) { - onUpdateAvailable?.call(latest); - } - } - } - - static Future dispose() async { - await _sub?.cancel(); - _sub = null; - ReleaseManifestService.dispose(); - } -} diff --git a/app/lib/services/install_channel.dart b/app/lib/services/install_channel.dart deleted file mode 100644 index 016dab93..00000000 --- a/app/lib/services/install_channel.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart' show visibleForTesting; - -const bool _isStoreBuild = bool.fromEnvironment( - 'STORE_BUILD', - defaultValue: false, -); - -enum InstallChannel { - msStore, - githubWindows, - scoop, - githubMacos, - homebrew, - unknown, -} - -enum HostPlatform { macos, windows, other } - -class InstallChannelDetector { - static HostPlatform? platformOverride; - - @visibleForTesting - static InstallChannel? channelOverride; - - static InstallChannel detect({ - String? execPathOverride, - HostPlatform? platformOverride, - }) { - if (channelOverride != null) return channelOverride!; - if (_isStoreBuild) return InstallChannel.msStore; - final path = (execPathOverride ?? Platform.resolvedExecutable).replaceAll( - r'\', - '/', - ); - final host = - platformOverride ?? - InstallChannelDetector.platformOverride ?? - _currentPlatform(); - - if (host == HostPlatform.macos) { - if (_isHomebrewPath(path)) return InstallChannel.homebrew; - return InstallChannel.githubMacos; - } - - if (host == HostPlatform.windows) { - if (_isScoopPath(path)) return InstallChannel.scoop; - return InstallChannel.githubWindows; - } - - return InstallChannel.unknown; - } - - static HostPlatform _currentPlatform() { - if (Platform.isMacOS) return HostPlatform.macos; - if (Platform.isWindows) return HostPlatform.windows; - return HostPlatform.other; - } - - static String manifestKey(InstallChannel channel) { - switch (channel) { - case InstallChannel.msStore: - return 'msstore'; - case InstallChannel.githubWindows: - return 'github_windows'; - case InstallChannel.scoop: - return 'scoop'; - case InstallChannel.githubMacos: - return 'github_macos'; - case InstallChannel.homebrew: - return 'homebrew'; - case InstallChannel.unknown: - return 'unknown'; - } - } - - static bool _isHomebrewPath(String path) { - return path.contains('/Cellar/') || - path.contains('/opt/homebrew/') || - path.contains('/usr/local/Cellar/'); - } - - // The Scoop root is relocatable, so the layout below it is the tell. - static bool _isScoopPath(String path) { - final lower = path.toLowerCase(); - return lower.contains('/scoop/apps/') || - lower.contains('/apps/copypaste/') || - lower.contains('/apps/copypaste-beta/'); - } -} diff --git a/app/lib/services/manifest_signature.dart b/app/lib/services/manifest_signature.dart deleted file mode 100644 index 123c7d62..00000000 --- a/app/lib/services/manifest_signature.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'dart:convert'; - -import 'package:cryptography/cryptography.dart'; -import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:flutter/services.dart' show rootBundle; - -class ManifestSignature { - ManifestSignature._(); - - static const _pubKeyAsset = 'assets/keys/release_pubkey.txt'; - static final Ed25519 _algorithm = Ed25519(); - - static SimplePublicKey? _cachedPublicKey; - static SimplePublicKey? _overridePublicKey; - - static Future verify(List bytes, String signatureBase64) async { - try { - final pub = await _loadPublicKey(); - if (pub == null) return false; - final sigBytes = base64.decode(signatureBase64.trim()); - final signature = Signature(sigBytes, publicKey: pub); - return await _algorithm.verify(bytes, signature: signature); - } catch (_) { - return false; - } - } - - static Future _loadPublicKey() async { - if (_overridePublicKey != null) return _overridePublicKey; - if (_cachedPublicKey != null) return _cachedPublicKey; - try { - final raw = await rootBundle.loadString(_pubKeyAsset); - final pubBytes = base64.decode(raw.trim()); - _cachedPublicKey = SimplePublicKey(pubBytes, type: KeyPairType.ed25519); - return _cachedPublicKey; - } catch (_) { - return null; - } - } - - @visibleForTesting - static void overridePublicKey(List publicKeyBytes) { - _overridePublicKey = SimplePublicKey( - publicKeyBytes, - type: KeyPairType.ed25519, - ); - } - - @visibleForTesting - static void reset() { - _cachedPublicKey = null; - _overridePublicKey = null; - } -} diff --git a/app/lib/services/release_manifest_service.dart b/app/lib/services/release_manifest_service.dart deleted file mode 100644 index 4ce14167..00000000 --- a/app/lib/services/release_manifest_service.dart +++ /dev/null @@ -1,421 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:core/core.dart'; -import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:path/path.dart' as p; - -import 'manifest_signature.dart'; - -const _defaultManifestUrl = - 'https://github.com/rgdevment/CopyPaste/releases/latest/download/release-manifest.json'; -const _defaultSignatureUrl = - 'https://github.com/rgdevment/CopyPaste/releases/latest/download/release-manifest.json.sig'; - -const _cacheFileName = 'release_manifest.json'; -const _cacheMetaFileName = 'release_manifest.meta'; - -const _cacheMaxAge = Duration(days: 15); -const _checkInterval = Duration(hours: 24); -const _httpTimeout = Duration(seconds: 10); - -/// Severity declared by the release manifest. -enum ManifestSeverity { patch, minor, major, critical } - -ManifestSeverity _severityFromString(String? raw) { - switch (raw) { - case 'major': - return ManifestSeverity.major; - case 'minor': - return ManifestSeverity.minor; - case 'critical': - return ManifestSeverity.critical; - case 'patch': - default: - return ManifestSeverity.patch; - } -} - -class ChannelInfo { - const ChannelInfo({this.url, this.command}); - final String? url; - final String? command; - - static ChannelInfo? fromJson(Object? raw) { - if (raw is! Map) return null; - final url = raw['url']; - final command = raw['command']; - return ChannelInfo( - url: url is String ? url : null, - command: command is String ? command : null, - ); - } -} - -class ReleaseNotes { - const ReleaseNotes({required this.summary, this.url}); - final String summary; - final String? url; -} - -class ReleaseManifest { - ReleaseManifest({ - required this.schema, - required this.latest, - required this.minimumSupported, - required this.blockedVersions, - required this.channels, - required this.notes, - required this.severity, - }); - - final int schema; - final String latest; - final String minimumSupported; - final List blockedVersions; - final Map channels; - final Map notes; - final ManifestSeverity severity; - - ReleaseNotes? notesFor(String locale) { - if (notes.isEmpty) return null; - final key = locale.toLowerCase(); - return notes[key] ?? - notes[key.split('_').first] ?? - notes['en'] ?? - notes.values.first; - } - - static ReleaseManifest? tryParse(String body) { - Object? decoded; - try { - decoded = jsonDecode(body); - } catch (_) { - return null; - } - if (decoded is! Map) return null; - - final schema = decoded['schema']; - if (schema is! int || schema != 1) return null; - - final latest = decoded['latest']; - if (latest is! String || !_isValidSemver(latest)) return null; - - final minimumSupported = decoded['minimumSupported']; - if (minimumSupported is! String || !_isValidSemver(minimumSupported)) { - return null; - } - - final blockedRaw = decoded['blockedVersions']; - final blocked = []; - if (blockedRaw is List) { - for (final v in blockedRaw) { - if (v is String && _isValidSemver(v)) blocked.add(v); - } - } - - final channelsRaw = decoded['channels']; - final channels = {}; - if (channelsRaw is Map) { - channelsRaw.forEach((k, v) { - if (k is! String) return; - final info = ChannelInfo.fromJson(v); - if (info == null) return; - if (info.url != null && !_isValidUrl(info.url!)) return; - channels[k] = info; - }); - } - - final notesRaw = decoded['releaseNotes']; - final notes = {}; - if (notesRaw is Map) { - notesRaw.forEach((k, v) { - if (k is! String || v is! Map) return; - final summary = v['summary']; - if (summary is! String) return; - final url = v['url']; - notes[k.toLowerCase()] = ReleaseNotes( - summary: summary, - url: url is String && _isValidUrl(url) ? url : null, - ); - }); - } - - return ReleaseManifest( - schema: schema, - latest: latest, - minimumSupported: minimumSupported, - blockedVersions: blocked, - channels: channels, - notes: notes, - severity: _severityFromString(decoded['severity'] as String?), - ); - } - - static bool _isValidSemver(String v) { - final parts = v.split('-').first.split('.'); - if (parts.length != 3) return false; - return parts.every((p) => int.tryParse(p) != null); - } - - static bool _isValidUrl(String u) { - return u.startsWith('https://') || u.startsWith('ms-windows-store://'); - } -} - -/// Outcome reported back to the UI. -class ManifestState { - ManifestState({ - required this.manifest, - required this.fetchedAt, - required this.expired, - }); - - final ReleaseManifest manifest; - final DateTime fetchedAt; - final bool expired; -} - -class ReleaseManifestService { - ReleaseManifestService._(); - - @visibleForTesting - static String? cacheDirOverride; - - @visibleForTesting - static String manifestUrlOverride = ''; - - @visibleForTesting - static String signatureUrlOverride = ''; - - @visibleForTesting - static HttpClient Function()? httpClientFactory; - - static Timer? _timer; - static ManifestState? _current; - - static final StreamController _controller = - StreamController.broadcast(); - static Stream get stream => _controller.stream; - - static ManifestState? get current => _current; - - static String get _effectiveManifestUrl => manifestUrlOverride.isNotEmpty - ? manifestUrlOverride - : _defaultManifestUrl; - - static String get _effectiveSignatureUrl => signatureUrlOverride.isNotEmpty - ? signatureUrlOverride - : _defaultSignatureUrl; - - static Future initialize({required String storageConfigDir}) async { - cacheDirOverride ??= storageConfigDir; - final cached = await _readCache(); - if (cached != null) { - _current = cached; - _controller.add(cached); - } - unawaited(_refresh()); - _timer = Timer.periodic(_checkInterval, (_) => _refresh()); - } - - static Future _refresh() async { - final fresh = await _fetchAndVerify(); - if (fresh == null) { - final cached = await _readCache(); - if (cached != null && cached.expired != _current?.expired) { - _current = cached; - _controller.add(cached); - } - return; - } - await _writeCache(fresh); - _current = ManifestState( - manifest: fresh, - fetchedAt: DateTime.now().toUtc(), - expired: false, - ); - _controller.add(_current); - } - - static Future _fetchAndVerify() async { - final client = (httpClientFactory ?? HttpClient.new)() - ..connectionTimeout = _httpTimeout; - try { - final manifestBytes = await _fetchBytes(client, _effectiveManifestUrl); - if (manifestBytes == null) return null; - final sigBody = await _fetchString(client, _effectiveSignatureUrl); - if (sigBody == null) return null; - - final ok = await ManifestSignature.verify(manifestBytes, sigBody); - if (!ok) return null; - - final body = utf8.decode(manifestBytes, allowMalformed: false); - return ReleaseManifest.tryParse(body); - } catch (_) { - return null; - } finally { - client.close(); - } - } - - static Future?> _fetchBytes(HttpClient client, String url) async { - try { - final req = await client.getUrl(Uri.parse(url)); - req.headers.set('User-Agent', 'CopyPaste-ReleaseManifest'); - final res = await req.close(); - if (res.statusCode != 200) { - await res.drain(); - return null; - } - final builder = BytesBuilder(copy: false); - await for (final chunk in res) { - builder.add(chunk); - } - return builder.takeBytes(); - } catch (_) { - return null; - } - } - - static Future _fetchString(HttpClient client, String url) async { - final bytes = await _fetchBytes(client, url); - if (bytes == null) return null; - try { - return utf8.decode(bytes); - } catch (_) { - return null; - } - } - - static String? get _cacheDir => cacheDirOverride; - - static Future _readCache() async { - final dir = _cacheDir; - if (dir == null) return null; - final manifestFile = File(p.join(dir, _cacheFileName)); - final metaFile = File(p.join(dir, _cacheMetaFileName)); - if (!manifestFile.existsSync() || !metaFile.existsSync()) return null; - try { - final body = await manifestFile.readAsString(); - final manifest = ReleaseManifest.tryParse(body); - if (manifest == null) return null; - final metaRaw = jsonDecode(await metaFile.readAsString()); - if (metaRaw is! Map || metaRaw['fetchedAt'] is! String) return null; - final fetchedAt = DateTime.tryParse(metaRaw['fetchedAt'] as String); - if (fetchedAt == null) return null; - final age = DateTime.now().toUtc().difference(fetchedAt); - return ManifestState( - manifest: manifest, - fetchedAt: fetchedAt, - expired: age > _cacheMaxAge, - ); - } catch (e) { - AppLogger.warn('ReleaseManifest cache read failed: $e'); - return null; - } - } - - static Future _writeCache(ReleaseManifest manifest) async { - final dir = _cacheDir; - if (dir == null) return; - try { - await Directory(dir).create(recursive: true); - final json = jsonEncode({ - 'schema': manifest.schema, - 'latest': manifest.latest, - 'minimumSupported': manifest.minimumSupported, - 'blockedVersions': manifest.blockedVersions, - 'channels': manifest.channels.map( - (k, v) => MapEntry(k, { - if (v.url != null) 'url': v.url, - if (v.command != null) 'command': v.command, - }), - ), - 'releaseNotes': manifest.notes.map( - (k, v) => MapEntry(k, { - 'summary': v.summary, - if (v.url != null) 'url': v.url, - }), - ), - 'severity': switch (manifest.severity) { - ManifestSeverity.critical => 'critical', - ManifestSeverity.major => 'major', - ManifestSeverity.minor => 'minor', - ManifestSeverity.patch => 'patch', - }, - }); - await File(p.join(dir, _cacheFileName)).writeAsString(json); - await File(p.join(dir, _cacheMetaFileName)).writeAsString( - jsonEncode({'fetchedAt': DateTime.now().toUtc().toIso8601String()}), - ); - } catch (e) { - AppLogger.warn('ReleaseManifest cache write failed: $e'); - } - } - - static int compareVersions(String a, String b) { - final aParts = a.split('-'); - final bParts = b.split('-'); - final aBase = aParts[0].split('.').map(int.tryParse).toList(); - final bBase = bParts[0].split('.').map(int.tryParse).toList(); - for (var i = 0; i < 3; i++) { - final av = i < aBase.length ? (aBase[i] ?? 0) : 0; - final bv = i < bBase.length ? (bBase[i] ?? 0) : 0; - if (av != bv) return av - bv; - } - final aPre = aParts.length > 1; - final bPre = bParts.length > 1; - if (aPre && !bPre) return -1; - if (!aPre && bPre) return 1; - return 0; - } - - static bool isBlocked({ - required String current, - required ManifestState? state, - }) { - if (state == null || state.expired) return false; - final m = state.manifest; - if (m.blockedVersions.contains(current)) return true; - if (m.severity == ManifestSeverity.critical && - compareVersions(current, m.minimumSupported) < 0) { - return true; - } - return false; - } - - static ManifestSeverity? badgeSeverity({ - required String current, - required ManifestState? state, - }) { - if (state == null) return null; - final m = state.manifest; - final cmp = compareVersions(current, m.latest); - if (cmp >= 0) return null; - return m.severity; - } - - static void dispose() { - _timer?.cancel(); - _timer = null; - } - - @visibleForTesting - static Future reset() async { - dispose(); - _current = null; - cacheDirOverride = null; - manifestUrlOverride = ''; - signatureUrlOverride = ''; - httpClientFactory = null; - } - - @visibleForTesting - static void setStateForTest(ManifestState? state) { - _current = state; - _controller.add(state); - } -} diff --git a/app/lib/shell/app_window.dart b/app/lib/shell/app_window.dart deleted file mode 100644 index 629a873f..00000000 --- a/app/lib/shell/app_window.dart +++ /dev/null @@ -1,746 +0,0 @@ -// 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'; - -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(Int64 pt, Uint32 dwFlags); -typedef _MonitorFromPointDart = int Function(int pt, int dwFlags); - -typedef _GetMonitorInfoWNative = Int32 Function(IntPtr hMonitor, Pointer lpmi); -typedef _GetMonitorInfoWDart = int Function(int hMonitor, Pointer lpmi); - -typedef _SetWindowPosNative = - Int32 Function( - IntPtr hWnd, - IntPtr hWndInsertAfter, - Int32 x, - Int32 y, - Int32 cx, - Int32 cy, - Uint32 uFlags, - ); -typedef _SetWindowPosDart = - int Function( - int hWnd, - int hWndInsertAfter, - int x, - int y, - int cx, - int cy, - int uFlags, - ); - -typedef _FindWindowWNative = - IntPtr Function(Pointer lpClassName, Pointer lpWindowName); -typedef _FindWindowWDart = - int Function(Pointer lpClassName, Pointer lpWindowName); - -typedef _GetWindowRectNative = - Int32 Function(IntPtr hWnd, Pointer lpRect); -typedef _GetWindowRectDart = int Function(int hWnd, Pointer lpRect); - -typedef _GetForegroundWindowNative = IntPtr Function(); -typedef _GetForegroundWindowDart = int Function(); - -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 _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); - -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', - ); - late final setWindowPosFunc = _u32 - .lookupFunction<_SetWindowPosNative, _SetWindowPosDart>('SetWindowPos'); - late final findWindowFunc = _u32 - .lookupFunction<_FindWindowWNative, _FindWindowWDart>('FindWindowW'); - late final getWindowRectFunc = _u32 - .lookupFunction<_GetWindowRectNative, _GetWindowRectDart>( - 'GetWindowRect', - ); - late final _k32 = DynamicLibrary.open('kernel32.dll'); - late final getForegroundWindowFunc = _u32 - .lookupFunction<_GetForegroundWindowNative, _GetForegroundWindowDart>( - 'GetForegroundWindow', - ); - late final setForegroundWindowFunc = _u32 - .lookupFunction<_SetForegroundWindowNative, _SetForegroundWindowDart>( - 'SetForegroundWindow', - ); - late final bringWindowToTopFunc = _u32 - .lookupFunction<_BringWindowToTopNative, _BringWindowToTopDart>( - 'BringWindowToTop', - ); - late final getWindowThreadProcessIdFunc = _u32 - .lookupFunction< - _GetWindowThreadProcessIdNative, - _GetWindowThreadProcessIdDart - >('GetWindowThreadProcessId'); - late final getCurrentThreadIdFunc = _k32 - .lookupFunction<_GetCurrentThreadIdNative, _GetCurrentThreadIdDart>( - 'GetCurrentThreadId', - ); - late final attachThreadInputFunc = _u32 - .lookupFunction<_AttachThreadInputNative, _AttachThreadInputDart>( - 'AttachThreadInput', - ); -} - -class AppWindow { - AppWindow({ - this.onVisibilityChanged, - double popupWidth = 360, - double popupHeight = 500, - this.rememberPositionEnabled, - this.savedPositionProvider, - this.onPositionPersist, - }) : _popupWidth = popupWidth, - _popupHeight = popupHeight; - - static const double _settingsWidth = 820; - static const double _settingsHeight = 680; - - final void Function(bool visible)? onVisibilityChanged; - final bool Function()? rememberPositionEnabled; - final (double, double)? Function()? savedPositionProvider; - final void Function(double x, double y)? onPositionPersist; - 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, ' - '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); - await windowManager.setSkipTaskbar(true); - if (Platform.isMacOS) { - // Without this, opening the panel over a full-screen app switches Space, - // and the animation blows past the paste focus budget. - await windowManager.setVisibleOnAllWorkspaces( - true, - visibleOnFullScreen: true, - ); - } - 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 { - 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.isMacOS) { - 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); - - if (Platform.isWindows) { - final ok = _setPositionWin32(x, y); - if (!ok) { - AppLogger.warn( - '_setPositionWin32 returned false, falling back to windowManager.setPosition', - ); - await windowManager.setPosition(Offset(x, y)); - } - } else { - await windowManager.setPosition(Offset(x, y)); - } - } - - static bool _setPositionWin32(double x, double y) { - try { - const swpNoSize = 0x0001; - const swpNoZOrder = 0x0004; - const swpNoActivate = 0x0010; - final w = _Win32Pos.instance; - final className = 'FLUTTER_RUNNER_WIN32_WINDOW'.toNativeUtf16(); - final windowName = 'CopyPaste'.toNativeUtf16(); - try { - final hwnd = w.findWindowFunc(className, windowName); - AppLogger.info('_setPositionWin32: hwnd=$hwnd target=($x,$y)'); - if (hwnd == 0) return false; - final result = w.setWindowPosFunc( - hwnd, - 0, - x.toInt(), - y.toInt(), - 0, - 0, - swpNoSize | swpNoZOrder | swpNoActivate, - ); - AppLogger.info('_setPositionWin32: SetWindowPos result=$result'); - return result != 0; - } finally { - calloc.free(className); - calloc.free(windowName); - } - } catch (e) { - AppLogger.warn('_setPositionWin32 failed: $e'); - return false; - } - } - - /// Last-resort activation for the panel's own window on Windows. - /// - /// Windows only lets a process take the foreground while it owns the input - /// that caused the change. The hotkey grants that right on WM_HOTKEY, but it - /// is spent long before we get here: the notification crosses to Dart and - /// then waits on several awaited platform calls (position restore, taskbar - /// flag, show). By the time `windowManager.focus()` runs, the right is gone - /// and SetForegroundWindow reports success while merely flashing the taskbar - /// button — the panel is visible but inactive, so the user's first click is - /// spent activating it instead of landing on a card. - /// - /// Attaching our input queue to the current foreground thread makes Windows - /// treat both as one, restoring the right for the duration of the call. Same - /// technique [WindowFocusManager] already uses to restore the paste target. - /// - /// Returns true when the window really ends up in the foreground; a false - /// only means the panel stays unfocused, never that it failed to show. - static bool _forceForegroundWin32() { - try { - final w = _Win32Pos.instance; - final className = 'FLUTTER_RUNNER_WIN32_WINDOW'.toNativeUtf16(); - final windowName = 'CopyPaste'.toNativeUtf16(); - final int hwnd; - try { - hwnd = w.findWindowFunc(className, windowName); - } finally { - calloc.free(className); - calloc.free(windowName); - } - if (hwnd == 0) return false; - - final foreground = w.getForegroundWindowFunc(); - // windowManager.focus() usually wins; skip the thread juggling when it did. - if (foreground == hwnd) return true; - // Logged so a "focus is broken" report can be told apart from a machine - // where plain focus() already sufficed and this path never ran. - AppLogger.info( - '_forceForegroundWin32: focus() left the window inactive, forcing it', - ); - - final currentThreadId = w.getCurrentThreadIdFunc(); - var foreignThreadId = 0; - if (foreground != 0) { - final pidPtr = calloc(); - try { - foreignThreadId = w.getWindowThreadProcessIdFunc(foreground, pidPtr); - } finally { - calloc.free(pidPtr); - } - } - - var attached = false; - if (foreignThreadId != 0 && foreignThreadId != currentThreadId) { - attached = - w.attachThreadInputFunc(currentThreadId, foreignThreadId, 1) != 0; - } - - try { - w.bringWindowToTopFunc(hwnd); - w.setForegroundWindowFunc(hwnd); - return w.getForegroundWindowFunc() == hwnd; - } finally { - if (attached) { - w.attachThreadInputFunc(currentThreadId, foreignThreadId, 0); - } - } - } catch (e) { - AppLogger.warn('_forceForegroundWin32 failed: $e'); - return false; - } - } - - 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 int _packPointWin32(int x, int y) => - ((y & 0xFFFFFFFF) << 32) | (x & 0xFFFFFFFF); - - static (double, double, double, double)? _getWorkAreaForPointWin32( - double x, - double y, - ) { - const monitorDefaultToNearest = 0x00000002; - final w = _Win32Pos.instance; - final hMonitor = w.monitorFromPointFunc( - _packPointWin32(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); - } - } - - static (double, double)? _getPositionWin32() { - try { - final w = _Win32Pos.instance; - final className = 'FLUTTER_RUNNER_WIN32_WINDOW'.toNativeUtf16(); - final windowName = 'CopyPaste'.toNativeUtf16(); - final rect = calloc(4); - try { - final hwnd = w.findWindowFunc(className, windowName); - if (hwnd == 0) return null; - final result = w.getWindowRectFunc(hwnd, rect); - if (result == 0) return null; - return (rect[0].toDouble(), rect[1].toDouble()); - } finally { - calloc.free(className); - calloc.free(windowName); - calloc.free(rect); - } - } catch (e) { - AppLogger.warn('_getPositionWin32 failed: $e'); - return null; - } - } - - static bool isPositionInSaneRange(double x, double y) { - if (!x.isFinite || !y.isFinite) return false; - if (x < -10000 || x > 50000) return false; - if (y < -10000 || y > 30000) return false; - return true; - } - - bool _isPositionVisible(double x, double y) { - if (!isPositionInSaneRange(x, y)) return false; - if (Platform.isWindows) { - try { - const monitorDefaultToNull = 0x00000000; - final w = _Win32Pos.instance; - final centerX = (x + _popupWidth / 2).toInt(); - final centerY = (y + _popupHeight / 2).toInt(); - final hMonitor = w.monitorFromPointFunc( - _packPointWin32(centerX, centerY), - monitorDefaultToNull, - ); - return hMonitor != 0; - } catch (e) { - AppLogger.warn('_isPositionVisible failed: $e'); - return false; - } - } - return true; - } - - Future _tryRestoreSavedPosition() async { - final enabled = rememberPositionEnabled?.call() == true; - AppLogger.info('_tryRestoreSavedPosition: enabled=$enabled'); - if (!enabled) return false; - final saved = savedPositionProvider?.call(); - AppLogger.info('_tryRestoreSavedPosition: saved=$saved'); - if (saved == null) return false; - final (x, y) = saved; - final visible = _isPositionVisible(x, y); - AppLogger.info('_tryRestoreSavedPosition: visible($x,$y)=$visible'); - if (!visible) return false; - if (Platform.isWindows) { - final ok = _setPositionWin32(x, y); - AppLogger.info('_tryRestoreSavedPosition: _setPositionWin32 ok=$ok'); - if (!ok) { - await windowManager.setPosition(Offset(x, y)); - } - } else { - await windowManager.setPosition(Offset(x, y)); - await Future.delayed(const Duration(milliseconds: 50)); - try { - final actual = await windowManager.getPosition(); - if ((actual.dx - x).abs() > 100 || (actual.dy - y).abs() > 100) { - AppLogger.info( - '_tryRestoreSavedPosition: actual=$actual rejected (>100px from target)', - ); - return false; - } - } catch (_) {} - } - return true; - } - - Future show() async { - AppLogger.info('AppWindow.show: starting'); - final restored = await _tryRestoreSavedPosition(); - AppLogger.info('AppWindow.show: restored=$restored'); - if (!restored) { - await _positionNearCursor(); - } - if (Platform.isWindows) { - await windowManager.setSkipTaskbar(false); - } - await windowManager.show(); - await windowManager.focus(); - if (Platform.isWindows) { - final focused = _forceForegroundWin32(); - if (!focused) { - AppLogger.warn( - 'AppWindow.show: window is visible but not in the foreground', - ); - } - final actual = _getPositionWin32(); - AppLogger.info( - 'AppWindow.show: window shown, actual position=$actual, ' - 'foreground=$focused', - ); - await applyEffect(); - } else { - AppLogger.info('AppWindow.show: window shown and focused'); - } - _visible = true; - onVisibilityChanged?.call(true); - } - - Future _captureCurrentPosition() async { - if (rememberPositionEnabled?.call() != true) return; - try { - double? x; - double? y; - if (Platform.isWindows) { - final pos = _getPositionWin32(); - if (pos != null) { - x = pos.$1; - y = pos.$2; - } - } else { - final pos = await windowManager.getPosition(); - x = pos.dx; - y = pos.dy; - } - if (x != null && y != null) { - onPositionPersist?.call(x, y); - } - } catch (e) { - AppLogger.warn('hide: failed to read window position: $e'); - } - } - - Future hide() async { - if (!_visible) return; - _visible = false; - await _captureCurrentPosition(); - 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 { - await _captureCurrentPosition(); - _settingsMode = true; - await windowManager.setResizable(true); - await windowManager.setMinimumSize( - const Size(_settingsWidth, _settingsHeight), - ); - await windowManager.setMaximumSize(const Size(1200, 900)); - await windowManager.setSize(const Size(_settingsWidth, _settingsHeight)); - await windowManager.center(); - if (!await windowManager.isVisible()) { - await windowManager.show(); - } - await windowManager.focus(); - _visible = true; - } - - Future exitSettingsMode() async { - _settingsMode = false; - await windowManager.setMinimumSize(Size(_popupWidth, 400)); - await windowManager.setMaximumSize(Size(_popupWidth, 900)); - await windowManager.setSize(Size(_popupWidth, _popupHeight)); - await windowManager.setResizable(false); - final restored = await _tryRestoreSavedPosition(); - if (!restored) { - 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'); - await _captureCurrentPosition(); - _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(true); - 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 deleted file mode 100644 index ba83fbc8..00000000 --- a/app/lib/shell/desktop_notifier.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:io'; - -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). -/// - 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._(); - - /// 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); - } - return false; - } -} diff --git a/app/lib/shell/focus_manager.dart b/app/lib/shell/focus_manager.dart deleted file mode 100644 index e16fcea8..00000000 --- a/app/lib/shell/focus_manager.dart +++ /dev/null @@ -1,416 +0,0 @@ -// coverage:ignore-file -import 'dart:ffi'; -import 'dart:io'; -import 'dart:math' as math; - -import 'package:ffi/ffi.dart'; -import 'package:core/core.dart'; -import 'package:listener/listener.dart'; - -import 'windows_hotkey_channel.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 _GetGUIThreadInfoNative = - Int32 Function(Uint32 idThread, Pointer lpgui); -typedef _GetGUIThreadInfoDart = - int Function(int idThread, Pointer lpgui); - -typedef _GetAncestorNative = IntPtr Function(IntPtr hWnd, Uint32 gaFlags); -typedef _GetAncestorDart = int Function(int hWnd, int gaFlags); - -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 gaRoot = 2; - - // GUITHREADINFO on 64-bit: cbSize+flags (8 bytes) then six HWNDs and a RECT. - // hwndFocus is the second handle. Flutter dropped 32-bit Windows, so the - // pointer width these offsets assume cannot change under us. - static const int guiThreadInfoSize = 72; - static const int guiThreadInfoFocusOffset = 16; - - 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 getGUIThreadInfo = _user32 - .lookupFunction<_GetGUIThreadInfoNative, _GetGUIThreadInfoDart>( - 'GetGUIThreadInfo', - ); - late final getAncestor = _user32 - .lookupFunction<_GetAncestorNative, _GetAncestorDart>('GetAncestor'); -} - -class WindowFocusManager { - int _previousWindow = 0; - int _previousThreadId = 0; - int _previousFocusWindow = 0; - String? _previousBundleId; - - /// Failures that leave the captured destination usable for a retry. Clearing - /// it would make every following attempt report `noPreviousWindow`, because - /// re-capturing is impossible once CopyPaste itself owns the foreground. - static const _recoverableErrors = { - 'restoreFailed', - 'focusTimeout', - 'noKeyboardFocus', - 'targetNotForeground', - 'sendInputFailed', - }; - - bool get hasDestination => - Platform.isWindows ? _previousWindow != 0 : _previousBundleId != null; - - Future capturePreviousWindow() async { - if (Platform.isWindows) { - return _capturePreviousWindows(); - } else if (Platform.isMacOS) { - _previousBundleId = await ClipboardWriter.captureFrontmostApp(); - AppLogger.info( - 'Focus session capture: platform=${Platform.operatingSystem}, ' - 'destination=${_previousBundleId ?? '-'}, ' - 'success=${_previousBundleId != null}', - ); - return _previousBundleId != null; - } - return false; - } - - Future restoreAndPaste({ - required int delayBeforeFocusMs, - required int maxFocusVerifyAttempts, - required int delayBeforePasteMs, - }) async { - if (Platform.isWindows && _previousWindow == 0) { - AppLogger.warn('Paste cancelled: no previous Windows destination'); - return const PasteResponse(success: false, errorCode: 'noPreviousWindow'); - } - if (Platform.isMacOS && _previousBundleId == null) { - AppLogger.warn('Paste cancelled: no previous application destination'); - return const PasteResponse(success: false, errorCode: 'noPreviousWindow'); - } - - PasteResponse? outcome; - try { - await Future.delayed(Duration(milliseconds: delayBeforeFocusMs)); - - if (Platform.isMacOS) { - final response = await ClipboardWriter.activateAndPaste( - bundleId: _previousBundleId!, - delayMs: delayBeforePasteMs, - focusTimeoutMs: math.max(maxFocusVerifyAttempts * 10, 250), - ); - AppLogger.info( - 'Paste destination result: platform=${Platform.operatingSystem}, ' - 'success=${response.success}, error=${response.errorCode ?? '-'}', - ); - return outcome = response; - } - - if (!_restorePreviousWindows()) { - AppLogger.warn( - 'Paste cancelled: Windows rejected destination restore ' - '(hwnd=$_previousWindow)', - ); - return outcome = const PasteResponse( - success: false, - errorCode: 'restoreFailed', - ); - } - - final focused = await _waitForFocusWindows(maxFocusVerifyAttempts); - if (!focused) { - AppLogger.warn( - 'Paste cancelled: Windows destination focus verification timed out ' - '(hwnd=$_previousWindow)', - ); - return outcome = const PasteResponse( - success: false, - errorCode: 'focusTimeout', - ); - } - - await Future.delayed(Duration(milliseconds: delayBeforePasteMs)); - final focusRoot = await _waitForKeyboardFocusWindows(); - if (focusRoot == 0) { - AppLogger.warn( - 'Paste cancelled: destination is active but nothing owns keyboard ' - 'focus (hwnd=$_previousWindow)', - ); - return outcome = const PasteResponse( - success: false, - errorCode: 'noKeyboardFocus', - ); - } - if (focusRoot != _previousWindow) { - AppLogger.warn( - 'Paste target is active but lacks keyboard focus: ' - 'expected=$_previousWindow, focused=$focusRoot', - ); - } - final inputResponse = await _simulatePasteWindows(); - if (!inputResponse.success) return outcome = inputResponse; - AppLogger.info( - 'Paste destination result: platform=windows, success=true', - ); - return outcome = const PasteResponse(success: true); - } finally { - if (!_recoverableErrors.contains(outcome?.errorCode)) clear(); - } - } - - void clear() { - _previousWindow = 0; - _previousThreadId = 0; - _previousFocusWindow = 0; - _previousBundleId = null; - } - - bool _capturePreviousWindows() { - final w = _Win32.instance; - final hwnd = w.getForegroundWindow(); - if (hwnd != 0 && w.isWindow(hwnd) != 0 && w.isWindowVisible(hwnd) != 0) { - final pidPtr = calloc(); - try { - final threadId = w.getWindowThreadProcessId(hwnd, pidPtr); - if (pidPtr.value == pid) { - clear(); - AppLogger.warn( - 'Focus session capture rejected CopyPaste itself (hwnd=$hwnd)', - ); - return false; - } - _previousWindow = hwnd; - _previousThreadId = threadId; - // Captured while the destination still owns the input queue: this is - // the only moment its inner focus target can be read reliably. - _previousFocusWindow = _focusWindowForThread(threadId); - AppLogger.info( - 'Focus session capture: platform=windows, hwnd=$hwnd, ' - 'pid=${pidPtr.value}, focus=$_previousFocusWindow, success=true', - ); - return true; - } finally { - calloc.free(pidPtr); - } - } else { - clear(); - AppLogger.warn('Focus session capture failed: no foreground window'); - return false; - } - } - - bool _restorePreviousWindows() { - if (_previousWindow == 0) return false; - final w = _Win32.instance; - if (w.isWindow(_previousWindow) == 0) { - _previousWindow = 0; - return false; - } - - // Hiding the panel already hands the foreground back in the common case. - // Attaching input queues when the destination owns it anyway is not free: - // detaching resets the keyboard focus Windows had just restored, so the - // window stays active but the synthetic Ctrl+V lands nowhere. AppWindow's - // own activation path skips the juggling for the same reason. - if (w.getForegroundWindow() == _previousWindow) { - AppLogger.info( - 'Focus restore: destination already in foreground ' - '(hwnd=$_previousWindow)', - ); - return true; - } - - 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); - final accepted = w.setForegroundWindow(_previousWindow) != 0; - return accepted || w.getForegroundWindow() == _previousWindow; - } 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) { - if (i > 0) { - AppLogger.info('Focus verify: destination active after $i retries'); - } - return true; - } - await Future.delayed(const Duration(milliseconds: 10)); - } - return false; - } - - /// Root window currently owning keyboard focus, or 0 when it cannot be read. - /// - /// [_waitForFocusWindows] only proves the destination is the active - /// top-level window. Chromium-based apps activate long before their render - /// process takes keyboard focus, and a stale input-queue attachment can - /// leave a window active with no focus at all — both swallow the Ctrl+V - /// while every call in the paste path still reports success. - int _keyboardFocusRoot() { - final focused = _focusWindowForThread(0); - if (focused == 0) return 0; - try { - return _Win32.instance.getAncestor(focused, _Win32.gaRoot); - } catch (e) { - AppLogger.warn('Keyboard focus probe failed: $e'); - return 0; - } - } - - /// Window owning keyboard focus inside [threadId], or 0 when unreadable. - /// A `threadId` of 0 means whichever thread currently owns the foreground. - int _focusWindowForThread(int threadId) { - final w = _Win32.instance; - final info = calloc(_Win32.guiThreadInfoSize); - try { - info.cast().value = _Win32.guiThreadInfoSize; - if (w.getGUIThreadInfo(threadId, info) == 0) return 0; - return (info + _Win32.guiThreadInfoFocusOffset).cast().value; - } catch (e) { - AppLogger.warn('Keyboard focus probe failed: $e'); - return 0; - } finally { - calloc.free(info); - } - } - - /// Chromium and XAML-island hosts install their inner focus a few frames - /// after they become active, so a single probe right after the fixed delay - /// samples a window that is still settling. - Future _waitForKeyboardFocusWindows() async { - var focusRoot = _keyboardFocusRoot(); - for (var i = 0; i < 5 && focusRoot != _previousWindow; i++) { - await Future.delayed(const Duration(milliseconds: 20)); - focusRoot = _keyboardFocusRoot(); - } - return focusRoot; - } - - Future _simulatePasteWindows() async { - try { - final response = await WindowsHotkeyChannel.sendPaste( - targetHwnd: _previousWindow, - targetFocusHwnd: _previousFocusWindow, - targetThreadId: _previousThreadId, - ); - if (response.success) { - if (response.focusRepaired) { - AppLogger.info( - 'Paste input: restored destination keyboard focus to ' - '$_previousFocusWindow (was ${response.focusBefore})', - ); - } - return const PasteResponse(success: true); - } - AppLogger.error( - 'Windows paste input rejected: sent=${response.sentInputs ?? 0}/' - '${response.expectedInputs ?? 0}, attached=${response.attached}, ' - 'error=${response.errorCode}, win32=${response.win32Error}', - ); - return PasteResponse( - success: false, - errorCode: response.errorCode ?? 'sendInputFailed', - ); - } catch (e) { - AppLogger.error('Windows SendInput platform call failed: $e'); - return const PasteResponse(success: false, errorCode: 'sendInputFailed'); - } - } -} diff --git a/app/lib/shell/hotkey_binding.dart b/app/lib/shell/hotkey_binding.dart deleted file mode 100644 index 0ced92f0..00000000 --- a/app/lib/shell/hotkey_binding.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:io' show Platform; - -import 'package:flutter/foundation.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 = []; - final mac = isMac || Platform.isMacOS; - if (mac) { - if (useCtrl) parts.add('Control'); - if (useAlt) parts.add('Option'); - if (useShift) parts.add('Shift'); - if (useWin) parts.add('Command'); - } else { - if (useCtrl) parts.add('Ctrl'); - if (useWin) parts.add('Win'); - if (useAlt) parts.add('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); -} - -@immutable -class HotkeyRegistrationResult { - const HotkeyRegistrationResult({ - required this.status, - required this.requestedBinding, - this.effectiveBinding, - }); - - final HotkeyRegistrationStatus status; - final HotkeyBinding requestedBinding; - final HotkeyBinding? effectiveBinding; -} diff --git a/app/lib/shell/hotkey_handler.dart b/app/lib/shell/hotkey_handler.dart deleted file mode 100644 index cc99f48c..00000000 --- a/app/lib/shell/hotkey_handler.dart +++ /dev/null @@ -1,344 +0,0 @@ -// coverage:ignore-file -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; -import 'package:hotkey_manager/hotkey_manager.dart'; - -import 'hotkey_binding.dart'; -import 'windows_hotkey_channel.dart'; - -class HotkeyHandler { - HotkeyHandler({ - required this.config, - required void Function() onHotkey, - required void Function() onPlainPasteHotkey, - }) : _onHotkey = onHotkey, - _onPlainPasteHotkey = onPlainPasteHotkey; - - final AppConfig config; - void Function()? _onHotkey; - void Function()? _onPlainPasteHotkey; - HotKey? _hotkey; - HotKey? _plainPasteHotkey; - WindowsHotkeyChannel? _windowsHotkeys; - bool? _plainPasteRegistrationSucceeded; - bool? get plainPasteRegistrationSucceeded => _plainPasteRegistrationSucceeded; - - HotkeyBinding get _requestedBinding => HotkeyBinding( - virtualKey: config.hotkeyVirtualKey, - keyName: config.hotkeyKeyName, - useCtrl: config.hotkeyUseCtrl, - useWin: config.hotkeyUseWin, - useAlt: config.hotkeyUseAlt, - useShift: config.hotkeyUseShift, - ); - - HotkeyBinding get _plainPasteBinding => HotkeyBinding( - virtualKey: config.plainPasteHotkeyVirtualKey, - keyName: config.plainPasteHotkeyKeyName, - useCtrl: config.plainPasteHotkeyUseCtrl, - useWin: config.plainPasteHotkeyUseWin, - useAlt: config.plainPasteHotkeyUseAlt, - useShift: config.plainPasteHotkeyUseShift, - ); - - Future _tryRegisterBinding( - HotkeyBinding binding, - void Function() callback, { - bool triggerOnKeyUp = false, - }) async { - final keyCode = _mapVirtualKey(binding.virtualKey); - if (keyCode == null) return null; - - final modifiers = []; - if (binding.useCtrl) modifiers.add(HotKeyModifier.control); - if (binding.useWin) modifiers.add(HotKeyModifier.meta); - if (binding.useAlt) modifiers.add(HotKeyModifier.alt); - if (binding.useShift) modifiers.add(HotKeyModifier.shift); - - final hotkey = HotKey( - key: keyCode, - modifiers: modifiers, - scope: HotKeyScope.system, - ); - - try { - await hotKeyManager.register( - hotkey, - keyDownHandler: triggerOnKeyUp ? null : (_) => callback(), - keyUpHandler: triggerOnKeyUp ? (_) => callback() : null, - ); - return hotkey; - } catch (e) { - AppLogger.error('Hotkey registration failed for ${binding.label()}: $e'); - return null; - } - } - - Future registerWithFallback() async { - _plainPasteRegistrationSucceeded = config.plainPasteHotkeyEnabled - ? false - : null; - if (_hotkey != null || - _plainPasteHotkey != null || - _windowsHotkeys != null) { - await unregister(); - } - - if (Platform.isWindows) { - return _registerWindowsHotkeys(); - } - - final requestedBinding = _requestedBinding; - _hotkey = await _tryRegisterBinding( - requestedBinding, - () => _onHotkey?.call(), - ); - if (_hotkey != null) { - await _registerPlainPasteBinding(); - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.registered, - requestedBinding: requestedBinding, - effectiveBinding: requestedBinding, - ); - } - - if (config.hotkeyUseWin) { - final fallbackBinding = HotkeyBinding( - virtualKey: requestedBinding.virtualKey, - keyName: requestedBinding.keyName, - useCtrl: true, - useWin: false, - useAlt: requestedBinding.useAlt, - useShift: requestedBinding.useShift, - ); - _hotkey = await _tryRegisterBinding( - fallbackBinding, - () => _onHotkey?.call(), - ); - if (_hotkey != null) { - await _registerPlainPasteBinding(); - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.fallbackRegistered, - requestedBinding: requestedBinding, - effectiveBinding: fallbackBinding, - ); - } - } - - await _registerPlainPasteBinding(); - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.failed, - requestedBinding: requestedBinding, - ); - } - - Future _registerWindowsHotkeys() async { - final requestedBinding = _requestedBinding; - final channel = WindowsHotkeyChannel(); - _windowsHotkeys = channel; - - try { - await channel.start((id) { - if (id == 'open') _onHotkey?.call(); - if (id == 'plainPaste') _onPlainPasteHotkey?.call(); - }); - } catch (e) { - AppLogger.error('Windows hotkey channel initialization failed: $e'); - _plainPasteRegistrationSucceeded = config.plainPasteHotkeyEnabled - ? false - : null; - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.failed, - requestedBinding: requestedBinding, - ); - } - - var effectiveBinding = requestedBinding; - var openResponse = await _registerWindowsBinding( - channel, - id: 'open', - binding: requestedBinding, - ); - var status = HotkeyRegistrationStatus.registered; - - if (!openResponse.success && config.hotkeyUseWin) { - final fallbackBinding = HotkeyBinding( - virtualKey: requestedBinding.virtualKey, - keyName: requestedBinding.keyName, - useCtrl: true, - useWin: false, - useAlt: requestedBinding.useAlt, - useShift: requestedBinding.useShift, - ); - openResponse = await _registerWindowsBinding( - channel, - id: 'open', - binding: fallbackBinding, - ); - if (openResponse.success) { - effectiveBinding = fallbackBinding; - status = HotkeyRegistrationStatus.fallbackRegistered; - } - } - - if (config.plainPasteHotkeyEnabled) { - final response = await _registerWindowsBinding( - channel, - id: 'plainPaste', - binding: _plainPasteBinding, - ); - _plainPasteRegistrationSucceeded = response.success; - } - - if (!openResponse.success) { - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.failed, - requestedBinding: requestedBinding, - ); - } - - AppLogger.info( - 'Windows open hotkey registered: ${effectiveBinding.label()}', - ); - return HotkeyRegistrationResult( - status: status, - requestedBinding: requestedBinding, - effectiveBinding: effectiveBinding, - ); - } - - Future _registerWindowsBinding( - WindowsHotkeyChannel channel, { - required String id, - required HotkeyBinding binding, - }) async { - try { - final response = await channel.register( - id: id, - virtualKey: binding.virtualKey, - useCtrl: binding.useCtrl, - useWin: binding.useWin, - useAlt: binding.useAlt, - useShift: binding.useShift, - ); - if (!response.success) { - AppLogger.error( - 'Windows hotkey registration failed: id=$id, ' - 'binding=${binding.label()}, error=${response.errorCode}, ' - 'win32=${response.win32Error}', - ); - } else if (id == 'plainPaste') { - AppLogger.info( - 'Windows plain-paste hotkey registered: ${binding.label()}', - ); - } - return response; - } catch (e) { - AppLogger.error( - 'Windows hotkey registration call failed: id=$id, ' - 'binding=${binding.label()}, error=$e', - ); - return const WindowsHotkeyRegistrationResponse( - success: false, - errorCode: 'platformCallFailed', - ); - } - } - - Future _registerPlainPasteBinding() async { - if (!config.plainPasteHotkeyEnabled) return; - _plainPasteHotkey = await _tryRegisterBinding( - _plainPasteBinding, - () => _onPlainPasteHotkey?.call(), - triggerOnKeyUp: true, - ); - _plainPasteRegistrationSucceeded = _plainPasteHotkey != null; - } - - Future unregister({bool releaseCallbacks = false}) async { - if (releaseCallbacks) { - // Break references to the owning State before platform calls. Some - // hotkey backends can fail during teardown; stale package callbacks then - // remain harmless and cannot retain the widget tree. - _onHotkey = null; - _onPlainPasteHotkey = null; - } - if (Platform.isWindows) { - final channel = _windowsHotkeys; - _windowsHotkeys = null; - _hotkey = null; - _plainPasteHotkey = null; - _plainPasteRegistrationSucceeded = null; - if (channel != null) { - try { - await channel.dispose(); - } catch (e) { - AppLogger.error('Windows hotkey cleanup failed: $e'); - } - } - return; - } - final registered = []; - if (_hotkey != null) registered.add(_hotkey!); - if (_plainPasteHotkey != null) registered.add(_plainPasteHotkey!); - _hotkey = null; - _plainPasteHotkey = null; - _plainPasteRegistrationSucceeded = null; - - var individualFailure = false; - for (final hotkey in registered) { - try { - await hotKeyManager.unregister(hotkey); - } catch (e) { - individualFailure = true; - AppLogger.error('Hotkey unregistration failed: $e'); - } - } - // hotkey_manager only removes its callback maps after the platform call - // succeeds. Clear the singleton as a fallback so a failed unregister does - // not retain this State object through an old callback. - if (individualFailure) { - try { - await hotKeyManager.unregisterAll(); - } catch (e) { - AppLogger.error('Fallback hotkey cleanup failed: $e'); - } - } - } - - Future dispose() => unregister(releaseCallbacks: true); - - static PhysicalKeyboardKey? _mapVirtualKey(int vk) { - const map = { - 0x41: PhysicalKeyboardKey.keyA, - 0x42: PhysicalKeyboardKey.keyB, - 0x43: PhysicalKeyboardKey.keyC, - 0x44: PhysicalKeyboardKey.keyD, - 0x45: PhysicalKeyboardKey.keyE, - 0x46: PhysicalKeyboardKey.keyF, - 0x47: PhysicalKeyboardKey.keyG, - 0x48: PhysicalKeyboardKey.keyH, - 0x49: PhysicalKeyboardKey.keyI, - 0x4A: PhysicalKeyboardKey.keyJ, - 0x4B: PhysicalKeyboardKey.keyK, - 0x4C: PhysicalKeyboardKey.keyL, - 0x4D: PhysicalKeyboardKey.keyM, - 0x4E: PhysicalKeyboardKey.keyN, - 0x4F: PhysicalKeyboardKey.keyO, - 0x50: PhysicalKeyboardKey.keyP, - 0x51: PhysicalKeyboardKey.keyQ, - 0x52: PhysicalKeyboardKey.keyR, - 0x53: PhysicalKeyboardKey.keyS, - 0x54: PhysicalKeyboardKey.keyT, - 0x55: PhysicalKeyboardKey.keyU, - 0x56: PhysicalKeyboardKey.keyV, - 0x57: PhysicalKeyboardKey.keyW, - 0x58: PhysicalKeyboardKey.keyX, - 0x59: PhysicalKeyboardKey.keyY, - 0x5A: PhysicalKeyboardKey.keyZ, - }; - return map[vk]; - } -} diff --git a/app/lib/shell/msix_startup_task.dart b/app/lib/shell/msix_startup_task.dart deleted file mode 100644 index 39c1291f..00000000 --- a/app/lib/shell/msix_startup_task.dart +++ /dev/null @@ -1,77 +0,0 @@ -// coverage:ignore-file -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; - -enum MsixStartupTaskState { - unknown, - disabled, - disabledByUser, - disabledByPolicy, - enabled, - enabledByPolicy, -} - -MsixStartupTaskState _parseState(Object? raw) { - switch (raw) { - case 'disabled': - return MsixStartupTaskState.disabled; - case 'disabledByUser': - return MsixStartupTaskState.disabledByUser; - case 'disabledByPolicy': - return MsixStartupTaskState.disabledByPolicy; - case 'enabled': - return MsixStartupTaskState.enabled; - case 'enabledByPolicy': - return MsixStartupTaskState.enabledByPolicy; - default: - return MsixStartupTaskState.unknown; - } -} - -class MsixStartupTask { - MsixStartupTask._(); - - static const _channel = MethodChannel('copypaste/startup_task'); - - static Future getState(String taskId) async { - try { - final raw = await _channel.invokeMethod('getState', { - 'taskId': taskId, - }); - return _parseState(raw); - } on PlatformException catch (e) { - AppLogger.error( - 'MsixStartupTask.getState failed: ${e.code} ${e.message} — ${e.details}', - ); - return null; - } - } - - static Future enable(String taskId) async { - try { - final raw = await _channel.invokeMethod('enable', { - 'taskId': taskId, - }); - return _parseState(raw); - } on PlatformException catch (e) { - AppLogger.error( - 'MsixStartupTask.enable failed: ${e.code} ${e.message} — ${e.details}', - ); - return null; - } - } - - static Future disable(String taskId) async { - try { - final raw = await _channel.invokeMethod('disable', { - 'taskId': taskId, - }); - return _parseState(raw); - } on PlatformException catch (e) { - AppLogger.error( - 'MsixStartupTask.disable failed: ${e.code} ${e.message} — ${e.details}', - ); - return null; - } - } -} diff --git a/app/lib/shell/single_instance.dart b/app/lib/shell/single_instance.dart deleted file mode 100644 index 306fbb35..00000000 --- a/app/lib/shell/single_instance.dart +++ /dev/null @@ -1,525 +0,0 @@ -// coverage:ignore-file -import 'dart:async'; -import 'dart:ffi'; -import 'dart:io'; -import 'dart:isolate'; - -import 'package:ffi/ffi.dart'; - -typedef _CreateMutexWNative = - IntPtr Function( - Pointer lpMutexAttributes, - Int32 bInitialOwner, - Pointer lpName, - ); -typedef _CreateMutexWDart = - int Function( - Pointer lpMutexAttributes, - int bInitialOwner, - Pointer lpName, - ); - -typedef _CloseHandleNative = Int32 Function(IntPtr hObject); -typedef _CloseHandleDart = int Function(int hObject); - -// Used by the pipe server isolate (cannot access _Win32 singleton). -typedef _GetLastErrorNative = Uint32 Function(); -typedef _GetLastErrorDart = int Function(); - -typedef _ReleaseMutexNative = Int32 Function(IntPtr hMutex); -typedef _ReleaseMutexDart = int Function(int hMutex); - -typedef _AllowSetForegroundWindowNative = Int32 Function(Uint32 dwProcessId); -typedef _AllowSetForegroundWindowDart = int Function(int dwProcessId); - -typedef _WaitForSingleObjectNative = - Uint32 Function(IntPtr hHandle, Uint32 dwMilliseconds); -typedef _WaitForSingleObjectDart = - int Function(int hHandle, int dwMilliseconds); - -// Named pipe FFI types -typedef _CreateNamedPipeWNative = - IntPtr Function( - Pointer lpName, - Uint32 dwOpenMode, - Uint32 dwPipeMode, - Uint32 nMaxInstances, - Uint32 nOutBufferSize, - Uint32 nInBufferSize, - Uint32 nDefaultTimeOut, - Pointer lpSecurityAttributes, - ); -typedef _CreateNamedPipeWDart = - int Function( - Pointer lpName, - int dwOpenMode, - int dwPipeMode, - int nMaxInstances, - int nOutBufferSize, - int nInBufferSize, - int nDefaultTimeOut, - Pointer lpSecurityAttributes, - ); - -typedef _ConnectNamedPipeNative = - Int32 Function(IntPtr hNamedPipe, Pointer lpOverlapped); -typedef _ConnectNamedPipeDart = - int Function(int hNamedPipe, Pointer lpOverlapped); - -typedef _DisconnectNamedPipeNative = Int32 Function(IntPtr hNamedPipe); -typedef _DisconnectNamedPipeDart = int Function(int hNamedPipe); - -typedef _CreateFileWNative = - IntPtr Function( - Pointer lpFileName, - Uint32 dwDesiredAccess, - Uint32 dwShareMode, - Pointer lpSecurityAttributes, - Uint32 dwCreationDisposition, - Uint32 dwFlagsAndAttributes, - IntPtr hTemplateFile, - ); -typedef _CreateFileWDart = - int Function( - Pointer lpFileName, - int dwDesiredAccess, - int dwShareMode, - Pointer lpSecurityAttributes, - int dwCreationDisposition, - int dwFlagsAndAttributes, - int hTemplateFile, - ); - -typedef _WriteFileNative = - Int32 Function( - IntPtr hFile, - Pointer lpBuffer, - Uint32 nNumberOfBytesToWrite, - Pointer lpNumberOfBytesWritten, - Pointer lpOverlapped, - ); -typedef _WriteFileDart = - int Function( - int hFile, - Pointer lpBuffer, - int nNumberOfBytesToWrite, - Pointer lpNumberOfBytesWritten, - Pointer lpOverlapped, - ); - -typedef _ReadFileNative = - Int32 Function( - IntPtr hFile, - Pointer lpBuffer, - Uint32 nNumberOfBytesToRead, - Pointer lpNumberOfBytesRead, - Pointer lpOverlapped, - ); -typedef _ReadFileDart = - int Function( - int hFile, - Pointer lpBuffer, - int nNumberOfBytesToRead, - Pointer lpNumberOfBytesRead, - Pointer lpOverlapped, - ); - -class _Win32 { - _Win32._() { - assert(Platform.isWindows, '_Win32 requires Windows'); - } - static _Win32? _instance; - static _Win32 get instance => _instance ??= _Win32._(); - - late final _kernel32 = DynamicLibrary.open('kernel32.dll'); - late final createMutex = _kernel32 - .lookupFunction<_CreateMutexWNative, _CreateMutexWDart>('CreateMutexW'); - late final closeHandle = _kernel32 - .lookupFunction<_CloseHandleNative, _CloseHandleDart>('CloseHandle'); - late final releaseMutex = _kernel32 - .lookupFunction<_ReleaseMutexNative, _ReleaseMutexDart>('ReleaseMutex'); - late final createNamedPipe = _kernel32 - .lookupFunction<_CreateNamedPipeWNative, _CreateNamedPipeWDart>( - 'CreateNamedPipeW', - ); - late final connectNamedPipe = _kernel32 - .lookupFunction<_ConnectNamedPipeNative, _ConnectNamedPipeDart>( - 'ConnectNamedPipe', - ); - late final disconnectNamedPipe = _kernel32 - .lookupFunction<_DisconnectNamedPipeNative, _DisconnectNamedPipeDart>( - 'DisconnectNamedPipe', - ); - late final createFile = _kernel32 - .lookupFunction<_CreateFileWNative, _CreateFileWDart>('CreateFileW'); - late final writeFile = _kernel32 - .lookupFunction<_WriteFileNative, _WriteFileDart>('WriteFile'); - late final readFile = _kernel32 - .lookupFunction<_ReadFileNative, _ReadFileDart>('ReadFile'); - late final waitForSingleObject = _kernel32 - .lookupFunction<_WaitForSingleObjectNative, _WaitForSingleObjectDart>( - 'WaitForSingleObject', - ); - - late final _user32 = DynamicLibrary.open('user32.dll'); - late final allowSetForegroundWindow = _user32 - .lookupFunction< - _AllowSetForegroundWindowNative, - _AllowSetForegroundWindowDart - >('AllowSetForegroundWindow'); -} - -// Named pipe constants -const int _pipeAccessInbound = 1; -const int _pipeTypeByte = 0; -const int _pipeWait = 0; -const int _pipeUnlimitedInstances = 255; -const int _genericWrite = 0x40000000; -const int _openExisting = 3; -const int _invalidHandleValue = -1; - -const String _pipeNameBase = r'\\.\pipe\CopyPasteSingleInstance'; - -class SingleInstance { - static const String _mutexNameBase = r'Local\CopyPaste_SingleInstance_Mutex'; - static const String _wakeupFileNameBase = 'copypaste.wakeup'; - - /// Suffix for every OS-global name this class owns. - /// - /// Tests must set it. The production names are shared with any CopyPaste - /// already running on the machine, which holds the mutex and drains the - /// wakeup signals the suite asserts on — so leaving it empty makes the - /// tests fail on exactly the developer machines that use the app. - static String namespace = ''; - - static String get _mutexName => '$_mutexNameBase$namespace'; - static String get _wakeupFileName => '$_wakeupFileNameBase$namespace'; - static String get _pipeName => '$_pipeNameBase$namespace'; - - static int _mutexHandle = 0; - static RandomAccessFile? _lockFile; - static StreamSubscription? _wakeupSubscription; - static Isolate? _pipeIsolate; - static ReceivePort? _pipeReceivePort; - static DateTime? _lastWakeup; - - static void _callWakeup(void Function() onWakeup) { - final now = DateTime.now(); - if (_lastWakeup != null && - now.difference(_lastWakeup!).inMilliseconds < 2000) { - return; - } - _lastWakeup = now; - onWakeup(); - } - - static bool acquire() { - final acquired = Platform.isWindows ? _acquireWindows() : _acquireUnix(); - if (!acquired) signalWakeup(); - return acquired; - } - - static void release() { - if (Platform.isWindows) { - _releaseWindows(); - } else { - _releaseUnix(); - } - } - - /// Writes a wakeup signal so the running instance can show its window. - /// On Windows uses a named pipe; falls back to file on other platforms. - /// Also grants foreground permission so SetForegroundWindow works. - static void signalWakeup() { - if (Platform.isWindows) { - _signalWakeupPipe(); - } else { - _signalWakeupFile(); - } - } - - static void _signalWakeupPipe() { - try { - final w = _Win32.instance; - // Grant foreground permission before connecting - w.allowSetForegroundWindow(0xFFFFFFFF); - - final name = _pipeName.toNativeUtf16(); - try { - final hPipe = w.createFile( - name, - _genericWrite, - 0, - nullptr, - _openExisting, - 0, - 0, - ); - if (hPipe == _invalidHandleValue) { - // Pipe not available, fall back to file - _signalWakeupFile(); - return; - } - final msg = 'wakeup'.codeUnits; - final buf = calloc(msg.length); - final written = calloc(1); - try { - for (var i = 0; i < msg.length; i++) { - buf[i] = msg[i]; - } - w.writeFile(hPipe, buf, msg.length, written, nullptr); - } finally { - calloc.free(buf); - calloc.free(written); - w.closeHandle(hPipe); - } - } finally { - calloc.free(name); - } - } catch (_) { - _signalWakeupFile(); - } - } - - static void _signalWakeupFile() { - try { - File(_wakeupFilePath()).writeAsStringSync('wakeup'); - if (Platform.isWindows) { - _Win32.instance.allowSetForegroundWindow(0xFFFFFFFF); - } - } catch (_) {} - } - - /// Starts listening for wakeup signals. On Windows uses a named pipe server - /// running in a separate isolate; on other platforms polls a file. - static void listenForWakeup(void Function() onWakeup) { - _wakeupSubscription?.cancel(); - if (Platform.isWindows) { - _listenForWakeupPipe(onWakeup); - } else { - _listenForWakeupFile(onWakeup); - } - } - - static void _listenForWakeupPipe(void Function() onWakeup) { - _pipeReceivePort?.close(); - _pipeIsolate?.kill(priority: Isolate.immediate); - - _pipeReceivePort = ReceivePort(); - _pipeReceivePort!.listen((message) { - if (message == 'wakeup') _callWakeup(onWakeup); - }); - - // Also keep file-based polling as safety net - _listenForWakeupFile(onWakeup); - - Isolate.spawn(_pipeServerLoop, (_pipeReceivePort!.sendPort, _pipeName)) - .then((isolate) { - _pipeIsolate = isolate; - }) - .catchError((_) { - // Isolate spawn failed; file-based fallback is already running - }); - } - - /// Runs in a dedicated isolate. Blocks on ConnectNamedPipe waiting for - /// second-instance clients, then reads their message and forwards it. - static void _pipeServerLoop((SendPort, String) args) { - final (sendPort, pipeName) = args; - final kernel32 = DynamicLibrary.open('kernel32.dll'); - final createNamedPipe = kernel32 - .lookupFunction<_CreateNamedPipeWNative, _CreateNamedPipeWDart>( - 'CreateNamedPipeW', - ); - final connectNamedPipe = kernel32 - .lookupFunction<_ConnectNamedPipeNative, _ConnectNamedPipeDart>( - 'ConnectNamedPipe', - ); - final disconnectNamedPipe = kernel32 - .lookupFunction<_DisconnectNamedPipeNative, _DisconnectNamedPipeDart>( - 'DisconnectNamedPipe', - ); - final readFile = kernel32.lookupFunction<_ReadFileNative, _ReadFileDart>( - 'ReadFile', - ); - final closeHandle = kernel32 - .lookupFunction<_CloseHandleNative, _CloseHandleDart>('CloseHandle'); - final getLastError = kernel32 - .lookupFunction<_GetLastErrorNative, _GetLastErrorDart>('GetLastError'); - - while (true) { - // Statics do not cross isolate boundaries, so the name travels as an - // argument instead of being read from `namespace` again. - final name = pipeName.toNativeUtf16(); - final hPipe = createNamedPipe( - name, - _pipeAccessInbound, - _pipeTypeByte | _pipeWait, - _pipeUnlimitedInstances, - 512, - 512, - 5000, - nullptr, - ); - calloc.free(name); - - if (hPipe == _invalidHandleValue) { - // Cannot create pipe; wait and retry - sleep(const Duration(seconds: 2)); - continue; - } - - final connected = connectNamedPipe(hPipe, nullptr); - if (connected == 0) { - const errorPipeConnected = 535; - if (getLastError() != errorPipeConnected) { - disconnectNamedPipe(hPipe); - closeHandle(hPipe); - continue; - } - } - - final buf = calloc(512); - final bytesRead = calloc(1); - try { - final ok = readFile(hPipe, buf, 512, bytesRead, nullptr); - if (ok != 0 && bytesRead.value > 0) { - final data = List.generate(bytesRead.value, (i) => buf[i]); - final msg = String.fromCharCodes(data); - if (msg.contains('wakeup')) { - sendPort.send('wakeup'); - } - } - } finally { - calloc.free(buf); - calloc.free(bytesRead); - } - - disconnectNamedPipe(hPipe); - closeHandle(hPipe); - } - } - - static void _listenForWakeupFile(void Function() onWakeup) { - try { - final stale = File(_wakeupFilePath()); - if (stale.existsSync()) { - final age = DateTime.now().difference(stale.lastModifiedSync()); - if (age.inSeconds > 30) stale.deleteSync(); - } - } catch (_) {} - _wakeupSubscription = - Stream.periodic(const Duration(milliseconds: 500)).listen((_) { - final f = File(_wakeupFilePath()); - if (f.existsSync()) { - try { - f.deleteSync(); - } catch (_) {} - _callWakeup(onWakeup); - } - }); - } - - /// Stops listening for wakeup signals. - static void stopListening() { - _wakeupSubscription?.cancel(); - _wakeupSubscription = null; - _pipeReceivePort?.close(); - _pipeReceivePort = null; - _pipeIsolate?.kill(priority: Isolate.immediate); - _pipeIsolate = null; - _lastWakeup = null; - } - - static String _wakeupFilePath() => - '${Directory.systemTemp.path}/$_wakeupFileName'; - - static bool _acquireWindows() { - if (_mutexHandle != 0) return false; - final w = _Win32.instance; - final name = _mutexName.toNativeUtf16(); - try { - final handle = w.createMutex(nullptr, 0, name); - if (handle == 0) return false; - - // WaitForSingleObject is reliable regardless of GetLastError state; - // the Dart FFI trampoline can clobber the thread-local error between - // consecutive calls, making GetLastError-based checks unreliable. - const waitObject0 = 0; - const waitAbandoned = 0x80; - final result = w.waitForSingleObject(handle, 0); - if (result == waitObject0 || result == waitAbandoned) { - _mutexHandle = handle; - return true; - } - w.closeHandle(handle); - return false; - } finally { - calloc.free(name); - } - } - - static void _releaseWindows() { - stopListening(); - if (_mutexHandle != 0) { - final w = _Win32.instance; - w.releaseMutex(_mutexHandle); - w.closeHandle(_mutexHandle); - _mutexHandle = 0; - } - } - - static bool _acquireUnix() { - final lockPath = _lockFilePath(); - try { - _lockFile = File(lockPath).openSync(mode: FileMode.write); - _lockFile!.lockSync(FileLock.exclusive); - _lockFile!.writeStringSync('$pid\n'); - _lockFile!.flushSync(); - return true; - } catch (_) { - _lockFile = null; - if (_isLockStale(lockPath)) { - try { - File(lockPath).deleteSync(); - } catch (_) {} - try { - _lockFile = File(lockPath).openSync(mode: FileMode.write); - _lockFile!.lockSync(FileLock.exclusive); - _lockFile!.writeStringSync('$pid\n'); - _lockFile!.flushSync(); - return true; - } catch (_) { - _lockFile = null; - } - } - return false; - } - } - - static bool _isLockStale(String lockPath) { - try { - final content = File(lockPath).readAsStringSync().trim(); - final existingPid = int.tryParse(content); - if (existingPid == null) return true; - final result = Process.runSync('kill', ['-0', '$existingPid']); - return result.exitCode != 0; - } catch (_) { - return true; - } - } - - static void _releaseUnix() { - try { - _lockFile?.unlockSync(); - _lockFile?.closeSync(); - File(_lockFilePath()).deleteSync(); - } catch (_) {} - _lockFile = null; - } - - static String _lockFilePath() { - final tmpDir = Directory.systemTemp.path; - return '$tmpDir/copypaste.lock'; - } -} diff --git a/app/lib/shell/startup_helper.dart b/app/lib/shell/startup_helper.dart deleted file mode 100644 index 78d18da8..00000000 --- a/app/lib/shell/startup_helper.dart +++ /dev/null @@ -1,311 +0,0 @@ -// 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 '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(stableExecutablePath(Platform.resolvedExecutable)); - } else { - _removeRegistryValue(); - } - } - } else if (Platform.isMacOS) { - if (runOnStartup) { - _installLaunchAgent(); - } else { - _removeLaunchAgent(); - } - } - } - - 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'); - } - } - - // Writing a build-folder path to HKCU\...\Run leaves an entry Windows renders - // with a generic icon once the folder is cleaned. - @visibleForTesting - static bool isDevBuildPath(String exePath) { - final normalized = exePath.replaceAll('/', r'\').toLowerCase(); - return normalized.contains(r'\build\windows\'); - } - - static final RegExp _versionedAppDir = RegExp( - r'^(.*[\\/]apps[\\/][^\\/]+[\\/])[^\\/]+([\\/].*)$', - caseSensitive: false, - ); - - /// `Platform.resolvedExecutable` reports the versioned target behind Scoop's - /// `current` junction, and that path dies on the next `scoop cleanup`. - @visibleForTesting - static String stableExecutablePath(String exePath) { - final match = _versionedAppDir.firstMatch(exePath); - if (match == null) return exePath; - final candidate = '${match.group(1)}current${match.group(2)}'; - if (candidate == exePath || !File(candidate).existsSync()) return exePath; - return candidate; - } - - 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'); - } - } -} diff --git a/app/lib/shell/tray_icon.dart b/app/lib/shell/tray_icon.dart deleted file mode 100644 index e1011e88..00000000 --- a/app/lib/shell/tray_icon.dart +++ /dev/null @@ -1,70 +0,0 @@ -// coverage:ignore-file -import 'dart:io'; - -import 'package:tray_manager/tray_manager.dart'; - -class TrayIcon with TrayListener { - TrayIcon({required this.onToggle, required this.onExit}); - - final void Function() onToggle; - final Future Function() onExit; - - static String get _iconPath { - if (Platform.isMacOS) return 'assets/icons/icon_mac_tray.png'; - return 'assets/icons/icon_tray.ico'; - } - - Future init() async { - 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 { - 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 { - trayManager.removeListener(this); - await trayManager.destroy(); - } -} diff --git a/app/lib/shell/win_known_folders.dart b/app/lib/shell/win_known_folders.dart deleted file mode 100644 index e8baa850..00000000 --- a/app/lib/shell/win_known_folders.dart +++ /dev/null @@ -1,85 +0,0 @@ -// coverage:ignore-file -import 'dart:ffi'; -import 'dart:io'; - -import 'package:ffi/ffi.dart'; - -final class _Guid extends Struct { - @Uint32() - external int data1; - @Uint16() - external int data2; - @Uint16() - external int data3; - @Array(8) - external Array data4; -} - -typedef _SHGetKnownFolderPathNative = - Int32 Function( - Pointer<_Guid> rfid, - Uint32 dwFlags, - IntPtr hToken, - Pointer> ppszPath, - ); -typedef _SHGetKnownFolderPathDart = - int Function( - Pointer<_Guid> rfid, - int dwFlags, - int hToken, - Pointer> ppszPath, - ); - -typedef _CoTaskMemFreeNative = Void Function(Pointer pv); -typedef _CoTaskMemFreeDart = void Function(Pointer pv); - -class WinKnownFolders { - WinKnownFolders._(); - - static String? localAppData() => _resolve(_folderIdLocalAppData); - - static const _folderIdLocalAppData = ( - 0xF1B32785, - 0x6FBA, - 0x4FCF, - [0x9D, 0x55, 0x7B, 0x8E, 0x7F, 0x15, 0x70, 0x91], - ); - - static String? _resolve((int, int, int, List) guidParts) { - if (!Platform.isWindows) return null; - try { - final shell32 = DynamicLibrary.open('shell32.dll'); - final ole32 = DynamicLibrary.open('ole32.dll'); - final shGetKnownFolderPath = shell32 - .lookupFunction< - _SHGetKnownFolderPathNative, - _SHGetKnownFolderPathDart - >('SHGetKnownFolderPath'); - final coTaskMemFree = ole32 - .lookupFunction<_CoTaskMemFreeNative, _CoTaskMemFreeDart>( - 'CoTaskMemFree', - ); - - final guid = calloc<_Guid>(); - final outPtr = calloc>(); - try { - guid.ref.data1 = guidParts.$1; - guid.ref.data2 = guidParts.$2; - guid.ref.data3 = guidParts.$3; - for (var i = 0; i < 8; i++) { - guid.ref.data4[i] = guidParts.$4[i]; - } - final hr = shGetKnownFolderPath(guid, 0, 0, outPtr); - if (hr != 0) return null; - final path = outPtr.value.toDartString(); - coTaskMemFree(outPtr.value.cast()); - return path; - } finally { - calloc.free(guid); - calloc.free(outPtr); - } - } catch (_) { - return null; - } - } -} diff --git a/app/lib/shell/win_package_context.dart b/app/lib/shell/win_package_context.dart deleted file mode 100644 index ff781603..00000000 --- a/app/lib/shell/win_package_context.dart +++ /dev/null @@ -1,93 +0,0 @@ -// coverage:ignore-file -import 'dart:ffi'; -import 'dart:io'; - -import 'package:ffi/ffi.dart'; - -typedef _GetCurrentPackageFullNameNative = - Int32 Function( - Pointer packageFullNameLength, - Pointer packageFullName, - ); -typedef _GetCurrentPackageFullNameDart = - int Function( - Pointer packageFullNameLength, - Pointer packageFullName, - ); - -class WinPackageContext { - WinPackageContext._(); - - static const int _appmodelErrorNoPackage = 15700; - static const int _errorInsufficientBuffer = 122; - - static bool? _cachedIsMsix; - static String? _cachedPackageFullName; - - static bool get isMsix { - if (!Platform.isWindows) return false; - return _cachedIsMsix ??= _detect().$1; - } - - static String? get packageFullName { - if (!Platform.isWindows) return null; - if (_cachedIsMsix == null) _detect(); - return _cachedPackageFullName; - } - - static (bool, String?) _detect() { - try { - final kernel32 = DynamicLibrary.open('kernel32.dll'); - final getCurrentPackageFullName = kernel32 - .lookupFunction< - _GetCurrentPackageFullNameNative, - _GetCurrentPackageFullNameDart - >('GetCurrentPackageFullName'); - - final lenPtr = calloc(); - try { - lenPtr.value = 0; - final probe = getCurrentPackageFullName(lenPtr, nullptr); - if (probe == _appmodelErrorNoPackage) { - _cachedIsMsix = false; - _cachedPackageFullName = null; - return (false, null); - } - if (probe != _errorInsufficientBuffer && probe != 0) { - _cachedIsMsix = false; - _cachedPackageFullName = null; - return (false, null); - } - - final bufLen = lenPtr.value; - if (bufLen == 0) { - _cachedIsMsix = true; - _cachedPackageFullName = null; - return (true, null); - } - - final namePtr = calloc(bufLen).cast(); - try { - final result = getCurrentPackageFullName(lenPtr, namePtr); - if (result != 0) { - _cachedIsMsix = false; - _cachedPackageFullName = null; - return (false, null); - } - final name = namePtr.toDartString(); - _cachedIsMsix = true; - _cachedPackageFullName = name; - return (true, name); - } finally { - calloc.free(namePtr); - } - } finally { - calloc.free(lenPtr); - } - } catch (_) { - _cachedIsMsix = false; - _cachedPackageFullName = null; - return (false, null); - } - } -} diff --git a/app/lib/shell/windows_balloon.dart b/app/lib/shell/windows_balloon.dart deleted file mode 100644 index 879d2de1..00000000 --- a/app/lib/shell/windows_balloon.dart +++ /dev/null @@ -1,164 +0,0 @@ -// coverage:ignore-file -import 'dart:async'; -import 'dart:ffi'; -import 'dart:io'; - -import 'package:ffi/ffi.dart'; - -// Shell_NotifyIconW message codes -const _nimAdd = 0; -const _nimDelete = 2; - -// NIF flags -const _nifIcon = 0x02; -const _nifInfo = 0x10; - -// NIIF flags (balloon icon + silence) -const _niifUser = 0x04; // use hBalloonIcon from the extended struct -const _niifNosound = 0x10; - -// NOTIFYICONDATAW struct size (Vista+ with GUID + hBalloonIcon on x64) -const _nidSize = 976; - -// NOTIFYICONDATAW field offsets on x64 -const _offCbsize = 0; // DWORD (+0) -const _offHwnd = 8; // HWND (+8, pointer-aligned) -const _offUid = 16; // UINT (+16) -const _offUflags = 20; // UINT (+20) -const _offIcon = 24; // HICON (+24, pointer-aligned) -const _offSzinfo = 304; // WCHAR[256] (+304) -const _offSzinfotitle = 820; // WCHAR[64] (+820) -const _offDwinfoflags = 948; // DWORD (+948) -// guidItem [952..967] GUID (16 bytes) -const _offHBalloonIcon = 968; // HICON (+968, pointer-aligned) - -typedef _ShellNotifyNative = - Int32 Function(Uint32 dwMessage, Pointer lpData); -typedef _ShellNotifyDart = int Function(int dwMessage, Pointer lpData); - -typedef _FindWindowNative = - IntPtr Function(Pointer lpClassName, Pointer lpWindowName); -typedef _FindWindowDart = - int Function(Pointer lpClassName, Pointer lpWindowName); - -typedef _ExtractIconNative = - IntPtr Function( - IntPtr hInst, - Pointer pszExeFileName, - Uint32 nIconIndex, - ); -typedef _ExtractIconDart = - int Function(int hInst, Pointer pszExeFileName, int nIconIndex); - -/// Shows a Windows balloon notification near the system tray. -/// -/// Design rules: -/// - Static: no sound, standard Windows fade animation only. -/// - Non-intrusive: app icon, auto-dismisses, never blocks input. -/// - Informative: shows app name + current hotkey so user knows how to open it. -/// -/// Safe to call on any platform — no-op on non-Windows. -/// Use [unawaited] at the call site; this method awaits the cleanup timer. -class WindowsBalloon { - WindowsBalloon._(); - - static const _balloonUid = 0x4350; // 'CP' — avoids conflict with tray_manager - static const _cleanupDelayMs = - 7000; // balloon auto-dismisses around 5-15s on Win11 - - static _ShellNotifyDart? _shellNotify; - static _FindWindowDart? _findWindow; - static _ExtractIconDart? _extractIcon; - - static void _ensureLoaded() { - if (_shellNotify != null) return; - final shell32 = DynamicLibrary.open('shell32.dll'); - final user32 = DynamicLibrary.open('user32.dll'); - _shellNotify = shell32.lookupFunction<_ShellNotifyNative, _ShellNotifyDart>( - 'Shell_NotifyIconW', - ); - _findWindow = user32.lookupFunction<_FindWindowNative, _FindWindowDart>( - 'FindWindowW', - ); - _extractIcon = shell32.lookupFunction<_ExtractIconNative, _ExtractIconDart>( - 'ExtractIconW', - ); - } - - static void _writeUint32(Pointer p, int offset, int value) => - (p + offset).cast().value = value; - - static void _writeUint64(Pointer p, int offset, int value) => - (p + offset).cast().value = value; - - static void _writeWString( - Pointer p, - int offset, - String text, - int maxChars, - ) { - final units = text.codeUnits; - final len = units.length < maxChars - 1 ? units.length : maxChars - 1; - for (var i = 0; i < len; i++) { - (p + offset + i * 2).cast().value = units[i]; - } - // null-terminate - (p + offset + len * 2).cast().value = 0; - } - - /// Shows a balloon notification with [title] and [body]. - /// - /// Returns true if the notification was shown successfully, false otherwise. - /// Callers can use a false return to trigger an in-app fallback. - static Future show({ - required String title, - required String body, - }) async { - if (!Platform.isWindows) return false; - try { - _ensureLoaded(); - - final className = 'FLUTTER_RUNNER_WIN32_WINDOW'.toNativeUtf16(); - final hwnd = _findWindow!(className, nullptr); - calloc.free(className); - if (hwnd == 0) return false; - - // Extract the app's own icon from the running executable. - final exePath = Platform.resolvedExecutable.toNativeUtf16(); - final hIcon = _extractIcon!(0, exePath, 0); - calloc.free(exePath); - - // calloc zero-initialises all bytes. - final nid = calloc(_nidSize); - try { - _writeUint32(nid, _offCbsize, _nidSize); - _writeUint64(nid, _offHwnd, hwnd); - _writeUint32(nid, _offUid, _balloonUid); - _writeUint32(nid, _offUflags, _nifIcon | _nifInfo); - if (hIcon != 0) { - _writeUint64(nid, _offIcon, hIcon); - } - _writeWString(nid, _offSzinfo, body, 256); - _writeWString(nid, _offSzinfotitle, title, 64); - final iconFlags = (hIcon != 0 ? _niifUser : 0) | _niifNosound; - _writeUint32(nid, _offDwinfoflags, iconFlags); - if (hIcon != 0) { - _writeUint64(nid, _offHBalloonIcon, hIcon); - } - - final result = _shellNotify!(_nimAdd, nid); - if (result == 0) return false; - await Future.delayed( - const Duration(milliseconds: _cleanupDelayMs), - ); - return true; - } finally { - _shellNotify!(_nimDelete, nid); - calloc.free(nid); - } - } catch (_) { - // Balloon is best-effort — a failure must never affect app startup. - return false; - } - } -} diff --git a/app/lib/shell/windows_hotkey_channel.dart b/app/lib/shell/windows_hotkey_channel.dart deleted file mode 100644 index 766a2c69..00000000 --- a/app/lib/shell/windows_hotkey_channel.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; - -class WindowsHotkeyRegistrationResponse { - const WindowsHotkeyRegistrationResponse({ - required this.success, - this.errorCode, - this.win32Error, - }); - - final bool success; - final String? errorCode; - final int? win32Error; -} - -class WindowsPasteInputResponse { - const WindowsPasteInputResponse({ - required this.success, - this.sentInputs, - this.expectedInputs, - this.errorCode, - this.win32Error, - this.attached = false, - this.focusRepaired = false, - this.focusBefore, - }); - - final bool success; - final int? sentInputs; - final int? expectedInputs; - final String? errorCode; - final int? win32Error; - final bool attached; - final bool focusRepaired; - final int? focusBefore; -} - -/// Owns the Windows runner channel backed by RegisterHotKey/WM_HOTKEY. -/// -/// hotkey_manager's key-up callback is macOS-only. Keeping the Windows path -/// here makes registration results explicit and avoids retaining callbacks in -/// the plugin singleton after settings changes or application shutdown. -class WindowsHotkeyChannel { - WindowsHotkeyChannel({ - MethodChannel channel = const MethodChannel(_channelName), - }) : _channel = channel; - - static const _channelName = 'copypaste/windows_hotkeys'; - - final MethodChannel _channel; - void Function(String id)? _onPressed; - - Future start(void Function(String id) onPressed) async { - _onPressed = onPressed; - _channel.setMethodCallHandler(_handleNativeCall); - } - - Future _handleNativeCall(MethodCall call) async { - if (call.method != 'hotkeyPressed') return null; - final id = call.arguments; - if (id is! String) { - AppLogger.warn('Ignored Windows hotkey event with an invalid id'); - return null; - } - AppLogger.info('Windows hotkey invoked: id=$id'); - _onPressed?.call(id); - return null; - } - - Future register({ - required String id, - required int virtualKey, - required bool useCtrl, - required bool useWin, - required bool useAlt, - required bool useShift, - }) async { - final response = await _channel.invokeMethod('register', { - 'id': id, - 'virtualKey': virtualKey, - 'useCtrl': useCtrl, - 'useWin': useWin, - 'useAlt': useAlt, - 'useShift': useShift, - }); - if (response is! Map) { - return const WindowsHotkeyRegistrationResponse( - success: false, - errorCode: 'invalidNativeResponse', - ); - } - return WindowsHotkeyRegistrationResponse( - success: response['success'] == true, - errorCode: response['errorCode'] as String?, - win32Error: response['win32Error'] as int?, - ); - } - - Future dispose() async { - // Break the owner reference before crossing the platform boundary. Even if - // teardown fails, the channel can no longer retain the app State callback. - _onPressed = null; - try { - await _channel.invokeMethod('unregisterAll'); - } finally { - _channel.setMethodCallHandler(null); - } - } - - static Future sendPaste({ - int targetHwnd = 0, - int targetFocusHwnd = 0, - int targetThreadId = 0, - MethodChannel channel = const MethodChannel(_channelName), - }) async { - final response = await channel.invokeMethod('sendPaste', { - 'targetHwnd': targetHwnd, - 'targetFocusHwnd': targetFocusHwnd, - 'targetThreadId': targetThreadId, - }); - if (response is! Map) { - return const WindowsPasteInputResponse( - success: false, - errorCode: 'invalidNativeResponse', - ); - } - return WindowsPasteInputResponse( - success: response['success'] == true, - sentInputs: response['sentInputs'] as int?, - expectedInputs: response['expectedInputs'] as int?, - errorCode: response['errorCode'] as String?, - win32Error: response['win32Error'] as int?, - attached: response['attached'] == true, - focusRepaired: response['focusRepaired'] == true, - focusBefore: response['focusBefore'] as int?, - ); - } -} diff --git a/app/lib/theme/app_theme_data.dart b/app/lib/theme/app_theme_data.dart deleted file mode 100644 index 904943c9..00000000 --- a/app/lib/theme/app_theme_data.dart +++ /dev/null @@ -1,347 +0,0 @@ -import 'package:flutter/material.dart'; - -abstract class AppThemeData { - String get id; - String get name; - - AppThemeColorScheme get light; - AppThemeColorScheme get dark; - - AppThemeTypography get typography; - AppThemeSpacing get spacing; - AppThemeRadii get radii; - AppThemeSizing get sizing; - AppThemeIcons get icons; - AppThemeCardStyle get cardStyle; - AppThemeFilterStyle get filterStyle; - AppThemeSearchStyle get searchStyle; - AppThemeToolbarStyle get toolbarStyle; -} - -class AppThemeColorScheme { - const AppThemeColorScheme({ - required this.surface, - required this.surfaceVariant, - required this.background, - required this.onSurface, - required this.onSurfaceVariant, - required this.onSurfaceMuted, - required this.onSurfaceSubtle, - required this.primary, - required this.onPrimary, - required this.cardBackground, - required this.cardBorder, - required this.searchBackground, - required this.searchBorder, - required this.divider, - required this.danger, - required this.warning, - required this.accentRed, - required this.accentGreen, - required this.accentPurple, - required this.accentYellow, - required this.accentBlue, - required this.accentOrange, - }); - - final Color surface; - final Color surfaceVariant; - final Color background; - final Color onSurface; - final Color onSurfaceVariant; - final Color onSurfaceMuted; - final Color onSurfaceSubtle; - final Color primary; - final Color onPrimary; - final Color cardBackground; - final Color cardBorder; - final Color searchBackground; - final Color searchBorder; - final Color divider; - final Color danger; - final Color warning; - final Color accentRed; - final Color accentGreen; - final Color accentPurple; - final Color accentYellow; - final Color accentBlue; - final Color accentOrange; - - Color accentForIndex(int index) => switch (index) { - 1 => accentRed, - 2 => accentGreen, - 3 => accentPurple, - 4 => accentYellow, - 5 => accentBlue, - 6 => accentOrange, - _ => Colors.transparent, - }; -} - -class AppThemeTypography { - const AppThemeTypography({ - required this.fontFamily, - required this.cardContent, - required this.cardHeader, - required this.cardLabel, - required this.cardFooter, - required this.cardTimestamp, - required this.searchInput, - required this.filterChip, - required this.filterTabChip, - required this.toolbarButton, - required this.tabLabel, - required this.emptyState, - required this.emptyStateIcon, - required this.branding, - }); - - final String fontFamily; - final TextStyle cardContent; - final TextStyle cardHeader; - final TextStyle cardLabel; - final TextStyle cardFooter; - final TextStyle cardTimestamp; - final TextStyle searchInput; - final TextStyle filterChip; - final TextStyle filterTabChip; - final TextStyle toolbarButton; - final TextStyle tabLabel; - final TextStyle emptyState; - final TextStyle emptyStateIcon; - final TextStyle branding; -} - -class AppThemeSpacing { - const AppThemeSpacing({ - required this.xs, - required this.sm, - required this.md, - required this.lg, - required this.xl, - required this.cardPadding, - required this.cardGap, - required this.listPadding, - required this.searchBarPadding, - required this.titleBarHeight, - required this.bottomBarHeight, - required this.filterTabBarPadding, - required this.filterTabBarHeight, - }); - - final double xs; - final double sm; - final double md; - final double lg; - final double xl; - final EdgeInsets cardPadding; - final double cardGap; - final EdgeInsets listPadding; - final EdgeInsets searchBarPadding; - final double titleBarHeight; - final double bottomBarHeight; - final EdgeInsets filterTabBarPadding; - final double filterTabBarHeight; -} - -class AppThemeRadii { - const AppThemeRadii({ - required this.xs, - required this.sm, - required this.md, - required this.lg, - required this.card, - required this.chip, - required this.searchBox, - required this.button, - required this.thumbnail, - }); - - final double xs; - final double sm; - final double md; - final double lg; - final double card; - final double chip; - final double searchBox; - final double button; - final double thumbnail; -} - -class AppThemeSizing { - const AppThemeSizing({ - required this.cardMinHeight, - required this.cardImageHeight, - required this.cardMinLines, - required this.cardMaxLines, - required this.chipHeight, - required this.iconSizeXs, - required this.iconSizeSm, - required this.iconSizeMd, - required this.iconSizeLg, - required this.titleBarIconSize, - required this.toolbarIconSize, - required this.colorIndicatorWidth, - required this.colorDotSize, - required this.searchBoxHeight, - required this.cardTypeIconContainerSize, - }); - - final double cardMinHeight; - final double cardImageHeight; - final int cardMinLines; - final int cardMaxLines; - final double chipHeight; - final double iconSizeXs; - final double iconSizeSm; - final double iconSizeMd; - final double iconSizeLg; - final double titleBarIconSize; - final double toolbarIconSize; - final double colorIndicatorWidth; - final double colorDotSize; - final double searchBoxHeight; - final double cardTypeIconContainerSize; -} - -class AppThemeIcons { - const AppThemeIcons({ - required this.text, - required this.textRich, - required this.image, - required this.link, - required this.file, - required this.folder, - required this.audio, - required this.video, - required this.unknown, - required this.pin, - required this.pinFilled, - required this.delete, - required this.edit, - required this.copy, - required this.paste, - required this.search, - required this.filter, - required this.close, - required this.settings, - required this.help, - required this.recent, - required this.clear, - required this.warning, - required this.colorLabel, - }); - - final IconData text; - - final IconData textRich; - final IconData image; - final IconData link; - final IconData file; - final IconData folder; - final IconData audio; - final IconData video; - final IconData unknown; - final IconData pin; - final IconData pinFilled; - final IconData delete; - final IconData edit; - final IconData copy; - final IconData paste; - final IconData search; - final IconData filter; - final IconData close; - final IconData settings; - final IconData help; - final IconData recent; - final IconData clear; - final IconData warning; - final IconData colorLabel; - - IconData forContentType(int typeValue) => switch (typeValue) { - 0 => text, - 1 => image, - 2 => file, - 3 => folder, - 4 => link, - 5 => audio, - 6 => video, - 7 => Icons.alternate_email_rounded, - 8 => Icons.phone_outlined, - 9 => Icons.palette_outlined, - 10 => Icons.dns_outlined, - 11 => Icons.fingerprint_rounded, - 12 => Icons.data_object_rounded, - _ => unknown, - }; -} - -class AppThemeCardStyle { - const AppThemeCardStyle({ - required this.elevation, - required this.hoverElevation, - required this.borderWidth, - required this.colorIndicatorBorderRadius, - required this.contentLineHeight, - required this.headerOpacity, - required this.footerOpacity, - required this.timestampOpacity, - required this.contentOpacity, - required this.hoverActionOpacity, - required this.appSourceOpacity, - }); - - final double elevation; - final double hoverElevation; - final double borderWidth; - final BorderRadius colorIndicatorBorderRadius; - final double contentLineHeight; - final double headerOpacity; - final double footerOpacity; - final double timestampOpacity; - final double contentOpacity; - final double hoverActionOpacity; - final double appSourceOpacity; -} - -class AppThemeFilterStyle { - const AppThemeFilterStyle({ - required this.chipSpacing, - required this.chipPadding, - required this.selectedOpacity, - required this.unselectedOpacity, - required this.animationDuration, - }); - - final double chipSpacing; - final EdgeInsets chipPadding; - final double selectedOpacity; - final double unselectedOpacity; - final Duration animationDuration; -} - -class AppThemeSearchStyle { - const AppThemeSearchStyle({ - required this.debounceDuration, - required this.padding, - required this.iconOpacity, - }); - - final Duration debounceDuration; - final EdgeInsets padding; - final double iconOpacity; -} - -class AppThemeToolbarStyle { - const AppThemeToolbarStyle({ - required this.buttonSpacing, - required this.buttonPadding, - required this.iconOpacity, - required this.hoverOpacity, - }); - - final double buttonSpacing; - final EdgeInsets buttonPadding; - final double iconOpacity; - final double hoverOpacity; -} diff --git a/app/lib/theme/compact_theme.dart b/app/lib/theme/compact_theme.dart deleted file mode 100644 index 63fc0a10..00000000 --- a/app/lib/theme/compact_theme.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'app_theme_data.dart'; -import 'dark_theme.dart'; -import 'light_theme.dart'; - -class CompactTheme extends AppThemeData { - @override - String get id => 'compact'; - - @override - String get name => 'Compact'; - - @override - AppThemeColorScheme get light => lightColorScheme; - - @override - AppThemeColorScheme get dark => darkColorScheme; - - @override - AppThemeTypography get typography => const AppThemeTypography( - fontFamily: 'Inter', - cardContent: TextStyle( - fontSize: 13, - height: 1.5, - fontWeight: FontWeight.w400, - ), - cardHeader: TextStyle(fontSize: 10.5, fontWeight: FontWeight.w700), - cardLabel: TextStyle( - fontSize: 10.5, - fontWeight: FontWeight.w700, - letterSpacing: 0.06, - ), - cardFooter: TextStyle(fontSize: 10), - cardTimestamp: TextStyle(fontSize: 10), - searchInput: TextStyle(fontSize: 13, fontWeight: FontWeight.w400), - filterChip: TextStyle(fontSize: 11, fontWeight: FontWeight.w500), - filterTabChip: TextStyle(fontSize: 11, fontWeight: FontWeight.w500), - toolbarButton: TextStyle(fontSize: 11), - tabLabel: TextStyle(fontSize: 12, fontWeight: FontWeight.w500), - emptyState: TextStyle(fontSize: 12.5), - emptyStateIcon: TextStyle(fontSize: 32), - branding: TextStyle(fontSize: 11, fontWeight: FontWeight.w500), - ); - - @override - AppThemeSpacing get spacing => const AppThemeSpacing( - xs: 2, - sm: 4, - md: 8, - lg: 12, - xl: 16, - cardPadding: EdgeInsets.symmetric(horizontal: 13, vertical: 11), - cardGap: 5, - listPadding: EdgeInsets.symmetric(horizontal: 8), - searchBarPadding: EdgeInsets.fromLTRB(12, 12, 12, 10), - titleBarHeight: 56, - bottomBarHeight: 34, - filterTabBarPadding: EdgeInsets.fromLTRB(12, 0, 12, 10), - filterTabBarHeight: 34, - ); - - @override - AppThemeRadii get radii => const AppThemeRadii( - xs: 4, - sm: 6, - md: 8, - lg: 12, - card: 9, - chip: 999, - searchBox: 999, - button: 6, - thumbnail: 6, - ); - - @override - AppThemeSizing get sizing => const AppThemeSizing( - cardMinHeight: 52, - cardImageHeight: 110, - cardMinLines: 2, - cardMaxLines: 5, - chipHeight: 28, - iconSizeXs: 9, - iconSizeSm: 10, - iconSizeMd: 14, - iconSizeLg: 18, - titleBarIconSize: 16, - toolbarIconSize: 14, - colorIndicatorWidth: 3, - colorDotSize: 12, - searchBoxHeight: 34, - cardTypeIconContainerSize: 34, - ); - - @override - AppThemeIcons get icons => const AppThemeIcons( - text: Icons.text_snippet_outlined, - textRich: Icons.text_format_rounded, - image: Icons.image_outlined, - link: Icons.link_rounded, - file: Icons.insert_drive_file_outlined, - folder: Icons.folder_outlined, - audio: Icons.music_note_rounded, - video: Icons.videocam_outlined, - unknown: Icons.help_outline_rounded, - pin: Icons.push_pin_outlined, - pinFilled: Icons.push_pin_rounded, - delete: Icons.delete_outline_rounded, - edit: Icons.edit_outlined, - copy: Icons.content_copy_rounded, - paste: Icons.content_paste_rounded, - search: Icons.search_rounded, - filter: Icons.tune_rounded, - close: Icons.close_rounded, - settings: Icons.settings_outlined, - help: Icons.help_outline_rounded, - recent: Icons.access_time_rounded, - clear: Icons.clear_all_rounded, - warning: Icons.warning_amber_rounded, - colorLabel: Icons.circle, - ); - - @override - AppThemeCardStyle get cardStyle => const AppThemeCardStyle( - elevation: 0, - hoverElevation: 0, - borderWidth: 1.0, - colorIndicatorBorderRadius: BorderRadius.only( - topLeft: Radius.circular(9), - bottomLeft: Radius.circular(9), - ), - contentLineHeight: 1.45, - headerOpacity: 0.7, - footerOpacity: 0.4, - timestampOpacity: 0.38, - contentOpacity: 0.82, - hoverActionOpacity: 0.55, - appSourceOpacity: 0.45, - ); - - @override - AppThemeFilterStyle get filterStyle => const AppThemeFilterStyle( - chipSpacing: 6, - chipPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), - selectedOpacity: 1.0, - unselectedOpacity: 0.5, - animationDuration: Duration(milliseconds: 200), - ); - - @override - AppThemeSearchStyle get searchStyle => const AppThemeSearchStyle( - debounceDuration: Duration(milliseconds: 300), - padding: EdgeInsets.symmetric(horizontal: 14, vertical: 8), - iconOpacity: 0.4, - ); - - @override - AppThemeToolbarStyle get toolbarStyle => const AppThemeToolbarStyle( - buttonSpacing: 2, - buttonPadding: EdgeInsets.all(4), - iconOpacity: 0.6, - hoverOpacity: 0.9, - ); -} diff --git a/app/lib/theme/dark_theme.dart b/app/lib/theme/dark_theme.dart deleted file mode 100644 index c8d0463d..00000000 --- a/app/lib/theme/dark_theme.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'app_theme_data.dart'; - -const darkColorScheme = AppThemeColorScheme( - surface: Color(0xFF1A1D2E), - surfaceVariant: Color(0xFF232637), - background: Color(0xFF1A1D2E), - onSurface: Color(0xFFFFFFFF), - onSurfaceVariant: Color(0x8CFFFFFF), - onSurfaceMuted: Color(0x66FFFFFF), - onSurfaceSubtle: Color(0x1AFFFFFF), - primary: Color(0xFF818CF8), - onPrimary: Color(0xFFFFFFFF), - cardBackground: Color(0xFF1E2132), - cardBorder: Color(0xFF2A2D40), - searchBackground: Color(0xFF1F2234), - searchBorder: Color(0xFF2C2F42), - divider: Color(0xFF2A2D3E), - danger: Color(0xFFFCA5A5), - warning: Color(0xFFFDE047), - accentRed: Color(0xFFFCA5A5), - accentGreen: Color(0xFF86EFAC), - accentPurple: Color(0xFFA5B4FC), - accentYellow: Color(0xFFFDE047), - accentBlue: Color(0xFFA5B4FC), - accentOrange: Color(0xFFFDBA74), -); diff --git a/app/lib/theme/light_theme.dart b/app/lib/theme/light_theme.dart deleted file mode 100644 index 25bd1296..00000000 --- a/app/lib/theme/light_theme.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'app_theme_data.dart'; - -const lightColorScheme = AppThemeColorScheme( - surface: Color(0xFFEBEBF0), - surfaceVariant: Color(0xFFE4E4EA), - background: Color(0xFFEBEBF0), - onSurface: Color(0xFF000000), - onSurfaceVariant: Color(0xFF6E6E73), - onSurfaceMuted: Color(0x80000000), - onSurfaceSubtle: Color(0x1A000000), - primary: Color(0xFF4F46E5), - onPrimary: Color(0xFFFFFFFF), - cardBackground: Color(0xFFFFFFFF), - cardBorder: Color(0x12000000), - searchBackground: Color(0x8CFFFFFF), - searchBorder: Color(0x14000000), - divider: Color(0x12000000), - danger: Color(0xFFB91C1C), - warning: Color(0xFF92400E), - accentRed: Color(0xFFDC2626), - accentGreen: Color(0xFF166534), - accentPurple: Color(0xFF3730A3), - accentYellow: Color(0xFF92400E), - accentBlue: Color(0xFF3730A3), - accentOrange: Color(0xFFC2410C), -); diff --git a/app/lib/theme/theme_provider.dart b/app/lib/theme/theme_provider.dart deleted file mode 100644 index b450104a..00000000 --- a/app/lib/theme/theme_provider.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'app_theme_data.dart'; - -class CopyPasteTheme extends InheritedWidget { - const CopyPasteTheme({ - required this.themeData, - required super.child, - super.key, - }); - - final AppThemeData themeData; - - static AppThemeData of(BuildContext context) { - final widget = context.dependOnInheritedWidgetOfExactType(); - if (widget == null) { - throw FlutterError('No CopyPasteTheme found in context'); - } - return widget.themeData; - } - - static AppThemeColorScheme colorsOf(BuildContext context) { - final theme = of(context); - final brightness = Theme.of(context).brightness; - return brightness == Brightness.dark ? theme.dark : theme.light; - } - - @override - bool updateShouldNotify(CopyPasteTheme oldWidget) => - themeData.id != oldWidget.themeData.id; -} diff --git a/app/lib/widgets/accessibility_dialog.dart b/app/lib/widgets/accessibility_dialog.dart deleted file mode 100644 index 005c3fba..00000000 --- a/app/lib/widgets/accessibility_dialog.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:listener/listener.dart'; - -import '../l10n/app_localizations.dart'; - -/// Phases of the permission dialog, driving which message and actions are shown. -enum _DialogPhase { - /// First-time request — standard "you need to grant permission" message. - initial, - - /// Polling timed out — suggest restarting the app. - retryNeeded, -} - -class AccessibilityDialog extends StatefulWidget { - const AccessibilityDialog({required this.previouslyGranted, super.key}); - - /// Whether we know the user had granted permission before (stored in config). - /// When true, the dialog shows Gatekeeper-specific instructions (remove + - /// re-add in Accessibility settings) instead of the standard first-time msg. - final bool previouslyGranted; - - /// Checks accessibility status and shows the dialog if not granted. - /// - /// Returns `true` if permission is (or became) granted, `false` otherwise. - /// - /// Uses [ClipboardWriter.checkAccessibility] (read-only) to avoid triggering - /// the macOS system prompt before the user reads the explanation dialog. - /// - /// [previouslyGranted] drives which message variant is shown — see - /// [_DialogPhase] and [previouslyGranted] for details. - static Future checkAndShow( - BuildContext context, { - bool previouslyGranted = false, - }) async { - final granted = await ClipboardWriter.checkAccessibility(); - if (granted || !context.mounted) return granted; - await showDialog( - context: context, - barrierDismissible: false, - barrierColor: Colors.black26, - builder: (_) => Theme( - data: Theme.of(context), - child: AccessibilityDialog(previouslyGranted: previouslyGranted), - ), - ); - // Return final state after dialog dismissed. - return ClipboardWriter.checkAccessibility(); - } - - @override - State createState() => _AccessibilityDialogState(); -} - -class _AccessibilityDialogState extends State { - Timer? _pollTimer; - int _pollCount = 0; - _DialogPhase _phase = _DialogPhase.initial; - bool _checking = false; - - /// After this many 1-second polls without success, switch to - /// [_DialogPhase.retryNeeded] to suggest restarting the app. - static const _maxPollsBeforeRetry = 30; - - @override - void initState() { - super.initState(); - _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) async { - _pollCount++; - final granted = await ClipboardWriter.checkAccessibility(); - if (granted && mounted) { - _pollTimer?.cancel(); - Navigator.of(context).pop(); - return; - } - if (_pollCount >= _maxPollsBeforeRetry && - _phase == _DialogPhase.initial && - mounted) { - setState(() => _phase = _DialogPhase.retryNeeded); - } - }); - } - - /// Manual "Check Again" action — uses [requestAccessibility] which calls - /// `AXIsProcessTrustedWithOptions(prompt: true)` to give the OS another - /// chance to recognise the current process identity. - Future _manualCheck() async { - if (_checking) return; - setState(() => _checking = true); - - final granted = await ClipboardWriter.requestAccessibility(); - - if (granted && mounted) { - _pollTimer?.cancel(); - Navigator.of(context).pop(); - } else if (mounted) { - setState(() { - _checking = false; - _phase = _DialogPhase.retryNeeded; - }); - } - } - - @override - void dispose() { - _pollTimer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - final cs = Theme.of(context).colorScheme; - final isStale = widget.previouslyGranted; - - return AlertDialog( - backgroundColor: cs.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - icon: Icon( - isStale ? Icons.warning_amber_rounded : Icons.security, - size: 40, - color: isStale ? Colors.red : Colors.orange, - ), - title: Text( - isStale ? l.permissionsResetTitle : l.permissionsTitle, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: cs.onSurface, - ), - textAlign: TextAlign.center, - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - isStale - ? l.permissionsResetMessage - : (_phase == _DialogPhase.retryNeeded - ? l.permissionsRestartMessage - : l.permissionsMessage), - style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant), - textAlign: TextAlign.center, - ), - ], - ), - actionsAlignment: MainAxisAlignment.center, - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - l.permissionsDismiss, - style: TextStyle(color: cs.onSurfaceVariant), - ), - ), - if (_phase == _DialogPhase.retryNeeded || isStale) ...[ - const SizedBox(width: 4), - OutlinedButton( - onPressed: _checking ? null : _manualCheck, - child: Text(_checking ? '...' : l.permissionsCheckAgain), - ), - ], - const SizedBox(width: 4), - FilledButton( - onPressed: () => ClipboardWriter.openAccessibilitySettings(), - child: Text(l.permissionsOpenSettings), - ), - ], - ); - } -} diff --git a/app/lib/widgets/clipboard_card.dart b/app/lib/widgets/clipboard_card.dart deleted file mode 100644 index 8dc87e78..00000000 --- a/app/lib/widgets/clipboard_card.dart +++ /dev/null @@ -1,1769 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:listener/listener.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.onCopy, - this.onExpandToggle, - this.onOpen, - this.onSelect, - this.onHoverChanged, - 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? onCopy; - final VoidCallback? onExpandToggle; - final VoidCallback? onOpen; - final VoidCallback? onSelect; - final ValueChanged? onHoverChanged; - 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; - Map? _cachedMetadata; - String _cachedExt = ''; - String _displayContent = ''; - bool _sourceAvailable = true; - bool _isRichText = false; - bool _hasFormatting = false; - - static const _doubleTapTimeout = Duration(milliseconds: 300); - - // Clipboard items can hold multi-MB blobs (logs, base64, minified JSON). - // Laying out the full string blocks the UI thread, so the card measures and - // paints only a bounded preview; the full content stays in the model for - // pasting and search. - static const _maxDisplayChars = 2000; - - 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(); - _recomputeDerived(); - _resolveImagePath(); - _resolveSourceAvailability(); - } - - @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; - _recomputeDerived(); - _resolveImagePath(); - _resolveSourceAvailability(); - } - } - - void _recomputeDerived() { - final item = widget.item; - _cachedMetadata = _parseMetadata(item); - // Both parse the metadata JSON, so they are resolved here rather than on - // every build, alongside the other derived values. - _isRichText = item.hasRichText; - _hasFormatting = item.hasFormatting; - _cachedExt = _getExtForItem(item); - _displayContent = item.content.length <= _maxDisplayChars - ? item.content - : item.content.substring(0, _maxDisplayChars); - } - - // Resolves whether the underlying source file(s) still exist off the build - // path: existsSync on every card build is blocking I/O that janks the list, - // especially for multi-path file items. The result feeds the "open" action - // and the "not found" badge. - Future _resolveSourceAvailability() async { - final item = widget.item; - bool available; - if (item.type == ClipboardContentType.image) { - final path = item.content.trim(); - available = path.isNotEmpty && await File(path).exists(); - } else if (item.isFileBasedType) { - final paths = item.content - .split('\n') - .where((s) => s.isNotEmpty) - .toList(); - available = paths.isNotEmpty; - for (final path in paths) { - if (!await File(path).exists() && !await Directory(path).exists()) { - available = false; - break; - } - } - } else { - available = true; - } - if (!mounted || available == _sourceAvailable) return; - setState(() => _sourceAvailable = available); - } - - 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 && _sourceAvailable, - ClipboardContentType.file || - ClipboardContentType.folder || - ClipboardContentType.audio || - ClipboardContentType.video => _sourceAvailable, - 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; - }); - } - - Future _editLabelColor(BuildContext context) async { - if (!mounted) return; - final result = await LabelColorDialog.show( - context, - currentLabel: widget.item.label, - currentColor: widget.item.cardColor, - ); - if (result != null && mounted) { - widget.onLabelColor(result.label, result.color); - } - } - - // Offered only when there is formatting to strip: on a clip the OS never - // gave styles to, "paste as plain text" is identical to a normal paste and - // the button is just noise. - bool get _isPlainPasteable => - _hasFormatting && - (widget.item.type == ClipboardContentType.text || - widget.item.type == ClipboardContentType.link); - - // Real on-disk paths backing this item, for drag-out. Image content is a - // single file; file/folder/audio/video may carry several paths joined by - // newlines. Other types are not file-backed and yield no paths. - List get _draggablePaths { - final item = widget.item; - switch (item.type) { - case ClipboardContentType.image: - final p = item.content.trim(); - return p.isEmpty ? const [] : [p]; - case ClipboardContentType.file: - case ClipboardContentType.folder: - case ClipboardContentType.audio: - case ClipboardContentType.video: - return item.content - .split('\n') - .map((s) => s.trim()) - .where((s) => s.isNotEmpty) - .toList(); - default: - return const []; - } - } - - // Draggable only once the backing file(s) are known to exist on disk. - bool get _canDrag => _sourceAvailable && _draggablePaths.isNotEmpty; - - // Hands the original file(s) to a native OLE drag so a drop target (e.g. a - // browser upload zone) gets their unique names instead of Chromium's fixed - // "image.png". Fire-and-forget: the native call blocks its own thread and - // must run after this gesture callback returns. - void _startDrag() { - final paths = _draggablePaths; - if (paths.isEmpty) return; - unawaited(ClipboardWriter.startFileDrag(paths)); - } - - Widget _wrapDraggable(Widget child) { - if (!_canDrag) return child; - return MouseRegion( - cursor: SystemMouseCursors.grab, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onPanStart: (_) => _startDrag(), - child: child, - ), - ); - } - - 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, - ), - ), - if (widget.onCopy != null) - PopupMenuItem( - value: _ContextAction.copy, - height: 32, - child: _ContextMenuItem( - icon: Icons.copy_rounded, - label: l.menuCopy, - 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.copy: - widget.onCopy?.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); - widget.onHoverChanged?.call(true); - }, - onExit: (_) { - setState(() => _hovering = false); - widget.onHoverChanged?.call(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( - // Rich text only swaps the glyph, never the color: the tint - // stays the type's own. Restricted to plain text because for - // a link or JSON the type itself is the more useful signal. - _isRichText && item.type == ClipboardContentType.text - ? theme.icons.textRich - : 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), - ], - if (widget.onCopy != null) ...[ - _CardActionButton( - icon: theme.icons.copy, - tooltip: l.menuCopy, - onTap: widget.onCopy!, - ), - 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, - ) { - final content = switch (item.type) { - ClipboardContentType.image => _buildImageContent(theme, colors, item), - ClipboardContentType.audio || - ClipboardContentType.video => _buildMediaContent(theme, colors, item), - ClipboardContentType.file || - ClipboardContentType.folder => _buildFileContent(theme, colors, item), - ClipboardContentType.link => _buildLinkContent(theme, colors, item), - ClipboardContentType.text || - ClipboardContentType.unknown || - ClipboardContentType.email || - ClipboardContentType.phone || - ClipboardContentType.ip || - ClipboardContentType.uuid || - ClipboardContentType.json => _buildTextContent(theme, colors, item), - ClipboardContentType.color => _buildColorContent(theme, colors, item), - }; - // File-backed items (image/file/folder/audio/video) become drag sources so - // a drop target gets the real, unique filename; _wrapDraggable is a no-op - // for everything else. - return _wrapDraggable(content); - } - - 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: _displayContent, 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( - _displayContent, - 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 - : (!_sourceAvailable - ? '${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 (!_sourceAvailable) ...[ - 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 = _sourceAvailable; - 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 = !_sourceAvailable; - - 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 (_cachedExt.isNotEmpty) return true; - final meta = _cachedMetadata; - 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 = _cachedMetadata; - 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 = _cachedExt; - 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, copy, 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/lib/widgets/empty_state.dart b/app/lib/widgets/empty_state.dart deleted file mode 100644 index 18d11ecf..00000000 --- a/app/lib/widgets/empty_state.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../l10n/app_localizations.dart'; -import '../theme/theme_provider.dart'; - -class EmptyState extends StatelessWidget { - const EmptyState({super.key}); - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - - return Center( - child: Padding( - padding: const EdgeInsets.only(bottom: 40), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: colors.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(16), - ), - child: Icon( - Icons.content_paste_rounded, - size: 28, - color: colors.primary.withValues(alpha: 0.4), - ), - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of(context).emptyState, - style: theme.typography.emptyState.copyWith( - color: colors.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 6), - Text( - AppLocalizations.of(context).emptyStateSubtitle, - style: theme.typography.cardFooter.copyWith( - color: colors.onSurfaceMuted, - ), - ), - ], - ), - ), - ); - } -} diff --git a/app/lib/widgets/filter_bar.dart b/app/lib/widgets/filter_bar.dart deleted file mode 100644 index 63a5fe3e..00000000 --- a/app/lib/widgets/filter_bar.dart +++ /dev/null @@ -1,247 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; - -import '../l10n/app_localizations.dart'; -import '../theme/app_theme_data.dart'; -import '../theme/theme_provider.dart'; - -class FilterBar extends StatefulWidget { - const FilterBar({ - required this.selectedTypes, - required this.selectedColors, - required this.onTypesChanged, - required this.onColorsChanged, - this.colorLabels = const {}, - this.onClear, - super.key, - }); - - final List selectedTypes; - final List selectedColors; - final void Function(List) onTypesChanged; - final void Function(List) onColorsChanged; - final Map colorLabels; - final VoidCallback? onClear; - - @override - State createState() => FilterBarState(); -} - -class FilterBarState extends State { - void openMenu() => _showFilterMenu(context); - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final hasFilters = widget.selectedColors.isNotEmpty; - - return _FilterButton( - icon: theme.icons.filter, - isActive: hasFilters, - badge: hasFilters ? widget.selectedColors.length : 0, - onTap: () => _showFilterMenu(context), - ); - } - - void _showFilterMenu(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - final l = AppLocalizations.of(context); - final renderBox = context.findRenderObject()! as RenderBox; - final offset = renderBox.localToGlobal(Offset.zero); - - showMenu( - context: context, - popUpAnimationStyle: AnimationStyle.noAnimation, - position: RelativeRect.fromLTRB( - offset.dx, - offset.dy + renderBox.size.height + 4, - offset.dx + renderBox.size.width, - 0, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(theme.radii.md), - ), - color: colors.cardBackground, - items: [ - if (widget.selectedColors.isNotEmpty) - PopupMenuItem( - height: 32, - onTap: widget.onClear, - child: Row( - children: [ - Icon( - theme.icons.clear, - size: theme.sizing.iconSizeSm, - color: colors.danger, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - l.clearAllFilters, - style: theme.typography.filterChip.copyWith( - color: colors.danger, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - if (widget.selectedColors.isNotEmpty) const PopupMenuDivider(height: 8), - PopupMenuItem( - enabled: false, - height: 28, - child: Text( - l.colorSectionLabel, - style: theme.typography.filterChip.copyWith( - color: colors.onSurfaceMuted, - fontSize: 10, - letterSpacing: 0.8, - ), - ), - ), - ..._getColorEntries(context).map( - (entry) => - _buildColorItem(context, entry.$1, entry.$2, theme, colors), - ), - ], - ); - } - - List<(CardColor, String)> _getColorEntries(BuildContext context) { - final l = AppLocalizations.of(context); - final cl = widget.colorLabels; - return [ - (CardColor.red, cl['Red'] ?? l.colorRed), - (CardColor.green, cl['Green'] ?? l.colorGreen), - (CardColor.purple, cl['Purple'] ?? l.colorPurple), - (CardColor.yellow, cl['Yellow'] ?? l.colorYellow), - (CardColor.blue, cl['Blue'] ?? l.colorBlue), - (CardColor.orange, cl['Orange'] ?? l.colorOrange), - ]; - } - - PopupMenuItem _buildColorItem( - BuildContext context, - CardColor cardColor, - String label, - AppThemeData theme, - AppThemeColorScheme colors, - ) { - final isSelected = widget.selectedColors.contains(cardColor); - final dotColor = colors.accentForIndex(cardColor.value); - - return PopupMenuItem( - height: 32, - onTap: () { - final updated = List.from(widget.selectedColors); - if (isSelected) { - updated.remove(cardColor); - } else { - updated.add(cardColor); - } - widget.onColorsChanged(updated); - }, - child: Row( - children: [ - Container( - width: theme.sizing.colorDotSize, - height: theme.sizing.colorDotSize, - decoration: BoxDecoration( - color: dotColor, - shape: BoxShape.circle, - border: Border.all(color: dotColor.withValues(alpha: 0.5)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - label, - style: theme.typography.filterChip.copyWith( - color: isSelected ? colors.onSurface : colors.onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (isSelected) Icon(Icons.check, size: 14, color: colors.primary), - ], - ), - ); - } -} - -class _FilterButton extends StatelessWidget { - const _FilterButton({ - required this.icon, - required this.isActive, - required this.badge, - required this.onTap, - }); - - final IconData icon; - final bool isActive; - final int badge; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - - return Padding( - padding: const EdgeInsets.only(right: 3), - child: SizedBox( - width: 30, - height: 30, - child: Material( - color: isActive - ? colors.primary.withValues(alpha: 0.1) - : Colors.transparent, - borderRadius: BorderRadius.circular(6), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(6), - hoverColor: colors.onSurface.withValues(alpha: 0.08), - splashColor: colors.primary.withValues(alpha: 0.15), - child: Stack( - alignment: Alignment.center, - children: [ - Icon( - icon, - size: theme.sizing.iconSizeMd, - color: isActive - ? colors.primary - : colors.onSurface.withValues(alpha: 0.5), - ), - if (badge > 0) - Positioned( - top: 2, - right: 2, - child: Container( - width: 14, - height: 14, - decoration: BoxDecoration( - color: colors.primary, - shape: BoxShape.circle, - ), - child: Center( - child: Text( - '$badge', - style: TextStyle( - color: colors.onPrimary, - fontSize: 8, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/app/lib/widgets/filter_tab_bar.dart b/app/lib/widgets/filter_tab_bar.dart deleted file mode 100644 index 0811444a..00000000 --- a/app/lib/widgets/filter_tab_bar.dart +++ /dev/null @@ -1,267 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; - -import '../l10n/app_localizations.dart'; -import '../theme/theme_provider.dart'; - -class FilterTabBar extends StatefulWidget { - const FilterTabBar({ - required this.selectedTypes, - required this.onTypesChanged, - required this.isPinnedMode, - required this.onPinnedModeChanged, - super.key, - }); - - final List selectedTypes; - final void Function(List) onTypesChanged; - final bool isPinnedMode; - final void Function(bool) onPinnedModeChanged; - - @override - State createState() => _FilterTabBarState(); -} - -class _FilterTabBarState extends State { - final ScrollController _scrollController = ScrollController(); - bool _isDragging = false; - double _dragStartX = 0; - double _scrollStartOffset = 0; - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final l = AppLocalizations.of(context); - - final isAllSelected = widget.selectedTypes.isEmpty && !widget.isPinnedMode; - - final tabs = <_TabDef>[ - _TabDef(label: l.filterAll, types: const [], isPinned: false), - _TabDef(label: l.filterPinned, types: const [], isPinned: true), - _TabDef( - label: l.typeText, - types: const [ - ClipboardContentType.text, - ClipboardContentType.ip, - ClipboardContentType.uuid, - ClipboardContentType.json, - ], - isPinned: false, - ), - _TabDef( - label: l.typeImage, - types: const [ClipboardContentType.image], - isPinned: false, - ), - _TabDef( - label: l.typeFile, - types: const [ClipboardContentType.file], - isPinned: false, - ), - _TabDef( - label: l.typeFolder, - types: const [ClipboardContentType.folder], - isPinned: false, - ), - _TabDef( - label: l.typeLink, - types: const [ClipboardContentType.link], - isPinned: false, - ), - _TabDef( - label: l.typeAudio, - types: const [ClipboardContentType.audio], - isPinned: false, - ), - _TabDef( - label: l.typeVideo, - types: const [ClipboardContentType.video], - isPinned: false, - ), - _TabDef( - label: l.typeEmail, - types: const [ClipboardContentType.email], - isPinned: false, - ), - _TabDef( - label: l.typePhone, - types: const [ClipboardContentType.phone], - isPinned: false, - ), - _TabDef( - label: l.typeColor, - types: const [ClipboardContentType.color], - isPinned: false, - ), - ]; - - return SizedBox( - height: theme.spacing.filterTabBarHeight, - child: ShaderMask( - shaderCallback: (bounds) => const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [Colors.white, Colors.white, Colors.transparent], - stops: [0.0, 0.92, 1.0], - ).createShader(bounds), - blendMode: BlendMode.dstIn, - child: Padding( - padding: theme.spacing.filterTabBarPadding.copyWith(right: 0), - child: MouseRegion( - cursor: _isDragging - ? SystemMouseCursors.grabbing - : SystemMouseCursors.grab, - child: Listener( - onPointerDown: (e) { - setState(() { - _isDragging = true; - _dragStartX = e.position.dx; - _scrollStartOffset = _scrollController.offset; - }); - }, - onPointerMove: (e) { - if (!_isDragging) return; - final delta = _dragStartX - e.position.dx; - _scrollController.jumpTo( - (_scrollStartOffset + delta).clamp( - 0.0, - _scrollController.position.maxScrollExtent, - ), - ); - }, - onPointerUp: (_) => setState(() => _isDragging = false), - onPointerCancel: (_) => setState(() => _isDragging = false), - child: ListView.separated( - controller: _scrollController, - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.only(right: 24), - itemCount: tabs.length, - separatorBuilder: (context, i) => const SizedBox(width: 4), - itemBuilder: (context, index) { - final tab = tabs[index]; - final tabTypes = tab.types; - final isActive = tab.isPinned - ? widget.isPinnedMode - : tabTypes.isEmpty - ? isAllSelected - : !widget.isPinnedMode && - widget.selectedTypes.length == tabTypes.length && - widget.selectedTypes.toSet().containsAll(tabTypes); - - return _FilterTab( - label: tab.label, - isActive: isActive, - onTap: () { - if (tab.isPinned) { - widget.onPinnedModeChanged(!widget.isPinnedMode); - if (!widget.isPinnedMode) widget.onTypesChanged([]); - } else if (tabTypes.isEmpty) { - widget.onPinnedModeChanged(false); - widget.onTypesChanged([]); - } else { - widget.onPinnedModeChanged(false); - final wasActive = - !widget.isPinnedMode && - widget.selectedTypes.length == tabTypes.length && - widget.selectedTypes.toSet().containsAll(tabTypes); - widget.onTypesChanged(wasActive ? [] : tabTypes); - } - }, - ); - }, - ), - ), - ), - ), - ), - ); - } -} - -class _TabDef { - const _TabDef({ - required this.label, - required this.types, - required this.isPinned, - }); - final String label; - final List types; - final bool isPinned; -} - -class _FilterTab extends StatefulWidget { - const _FilterTab({ - required this.label, - required this.isActive, - required this.onTap, - }); - - final String label; - final bool isActive; - final VoidCallback onTap; - - @override - State<_FilterTab> createState() => _FilterTabState(); -} - -class _FilterTabState extends State<_FilterTab> { - bool _hovering = false; - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - - final Color bg; - final Color borderColor; - final Color textColor; - final FontWeight weight; - - if (widget.isActive) { - bg = colors.primary.withValues(alpha: 0.13); - borderColor = colors.primary.withValues(alpha: 0.4); - textColor = colors.accentPurple; - weight = FontWeight.w600; - } else { - bg = _hovering ? colors.cardBackground : colors.searchBackground; - borderColor = _hovering - ? colors.onSurface.withValues(alpha: 0.18) - : colors.onSurface.withValues(alpha: 0.12); - textColor = colors.onSurface.withValues(alpha: 0.5); - weight = FontWeight.w500; - } - - return MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 4), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(theme.radii.chip), - border: Border.all(color: borderColor), - ), - child: Center( - child: Text( - widget.label, - style: theme.typography.filterTabChip.copyWith( - color: textColor, - fontWeight: weight, - ), - ), - ), - ), - ), - ); - } -} diff --git a/app/lib/widgets/label_color_dialog.dart b/app/lib/widgets/label_color_dialog.dart deleted file mode 100644 index 3aae83cb..00000000 --- a/app/lib/widgets/label_color_dialog.dart +++ /dev/null @@ -1,304 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../l10n/app_localizations.dart'; - -import '../theme/app_theme_data.dart'; -import '../theme/theme_provider.dart'; - -class LabelColorResult { - const LabelColorResult({required this.label, required this.color}); - final String? label; - final CardColor color; -} - -class LabelColorDialog extends StatefulWidget { - const LabelColorDialog({ - required this.currentLabel, - required this.currentColor, - super.key, - }); - - final String? currentLabel; - final CardColor currentColor; - - static Future show( - BuildContext context, { - String? currentLabel, - CardColor currentColor = CardColor.none, - }) { - final theme = CopyPasteTheme.of(context); - return showDialog( - context: context, - barrierColor: Colors.black26, - builder: (_) => CopyPasteTheme( - themeData: theme, - child: Theme( - data: Theme.of(context), - child: LabelColorDialog( - currentLabel: currentLabel, - currentColor: currentColor, - ), - ), - ), - ); - } - - @override - State createState() => _LabelColorDialogState(); -} - -class _LabelColorDialogState extends State { - late final TextEditingController _labelController; - late CardColor _selectedColor; - - @override - void initState() { - super.initState(); - _labelController = TextEditingController(text: widget.currentLabel ?? ''); - _selectedColor = widget.currentColor; - } - - @override - void dispose() { - _labelController.dispose(); - super.dispose(); - } - - void _submit() { - final label = _labelController.text.trim(); - Navigator.of(context).pop( - LabelColorResult( - label: label.isEmpty ? null : label, - color: _selectedColor, - ), - ); - } - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - final l = AppLocalizations.of(context); - - return Dialog( - backgroundColor: colors.cardBackground, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(theme.radii.lg), - ), - elevation: 8, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.editDialogTitle, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: colors.onSurface, - ), - ), - const SizedBox(height: 16), - SizedBox( - height: 36, - child: TextField( - controller: _labelController, - autofocus: true, - maxLength: ClipboardItem.maxLabelLength, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - style: theme.typography.searchInput.copyWith( - color: colors.onSurface, - ), - decoration: InputDecoration( - hintText: l.editDialogHint, - hintStyle: theme.typography.searchInput.copyWith( - color: colors.onSurfaceMuted, - ), - counterText: '', - contentPadding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - filled: true, - fillColor: colors.searchBackground, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(theme.radii.sm), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(theme.radii.sm), - borderSide: BorderSide( - color: colors.primary.withValues(alpha: 0.5), - width: 1.5, - ), - ), - isDense: true, - ), - onSubmitted: (_) => _submit(), - ), - ), - const SizedBox(height: 16), - Text( - l.editColorLabel, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: colors.onSurfaceMuted, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 10), - _buildColorGrid(colors, theme), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - _DialogButton( - label: l.buttonCancel, - onTap: () => Navigator.of(context).pop(), - colors: colors, - theme: theme, - ), - const SizedBox(width: 8), - _DialogButton( - label: l.buttonSave, - onTap: _submit, - colors: colors, - theme: theme, - isPrimary: true, - ), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _buildColorGrid(AppThemeColorScheme colors, AppThemeData theme) { - final l = AppLocalizations.of(context); - final entries = [ - (CardColor.none, l.colorNone), - (CardColor.red, l.colorRed), - (CardColor.green, l.colorGreen), - (CardColor.purple, l.colorPurple), - (CardColor.yellow, l.colorYellow), - (CardColor.blue, l.colorBlue), - (CardColor.orange, l.colorOrange), - ]; - - return Wrap( - spacing: 8, - runSpacing: 8, - children: entries.map((e) { - final isSelected = _selectedColor == e.$1; - final dotColor = e.$1 == CardColor.none - ? colors.onSurfaceSubtle - : colors.accentForIndex(e.$1.value); - - return GestureDetector( - onTap: () => setState(() => _selectedColor = e.$1), - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - width: 28, - height: 28, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: isSelected ? colors.primary : Colors.transparent, - width: 2, - ), - ), - child: Center( - child: Container( - width: 18, - height: 18, - decoration: BoxDecoration( - color: e.$1 == CardColor.none ? Colors.transparent : dotColor, - shape: BoxShape.circle, - border: e.$1 == CardColor.none - ? Border.all(color: colors.onSurfaceSubtle, width: 1.5) - : null, - ), - child: e.$1 == CardColor.none - ? Center( - child: Icon( - Icons.close_rounded, - size: 10, - color: colors.onSurfaceSubtle, - ), - ) - : null, - ), - ), - ), - ); - }).toList(), - ); - } -} - -class _DialogButton extends StatefulWidget { - const _DialogButton({ - required this.label, - required this.onTap, - required this.colors, - required this.theme, - this.isPrimary = false, - }); - - final String label; - final VoidCallback onTap; - final AppThemeColorScheme colors; - final AppThemeData theme; - final bool isPrimary; - - @override - State<_DialogButton> createState() => _DialogButtonState(); -} - -class _DialogButtonState extends State<_DialogButton> { - bool _hovering = false; - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 100), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), - decoration: BoxDecoration( - color: widget.isPrimary - ? (_hovering - ? widget.colors.primary.withValues(alpha: 0.9) - : widget.colors.primary) - : (_hovering - ? widget.colors.onSurface.withValues(alpha: 0.08) - : Colors.transparent), - borderRadius: BorderRadius.circular(widget.theme.radii.button), - ), - child: Text( - widget.label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: widget.isPrimary - ? widget.colors.onPrimary - : widget.colors.onSurface, - ), - ), - ), - ), - ); - } -} diff --git a/app/lib/widgets/title_bar.dart b/app/lib/widgets/title_bar.dart deleted file mode 100644 index 789ec085..00000000 --- a/app/lib/widgets/title_bar.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:window_manager/window_manager.dart'; - -import '../l10n/app_localizations.dart'; -import '../theme/theme_provider.dart'; - -class TitleBar extends StatelessWidget { - const TitleBar({ - required this.searchController, - required this.searchFocusNode, - required this.onSearchChanged, - required this.trailing, - super.key, - }); - - final TextEditingController searchController; - final FocusNode searchFocusNode; - final void Function(String) onSearchChanged; - final Widget? trailing; - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - - return DragToMoveArea( - child: Padding( - padding: theme.spacing.searchBarPadding, - child: _SearchBar( - controller: searchController, - focusNode: searchFocusNode, - onChanged: onSearchChanged, - trailing: trailing, - ), - ), - ); - } -} - -class _SearchBar extends StatefulWidget { - const _SearchBar({ - required this.controller, - required this.focusNode, - required this.onChanged, - this.trailing, - }); - - final TextEditingController controller; - final FocusNode focusNode; - final void Function(String) onChanged; - final Widget? trailing; - - @override - State<_SearchBar> createState() => _SearchBarState(); -} - -class _SearchBarState extends State<_SearchBar> { - Timer? _debounce; - bool _focused = false; - - @override - void initState() { - super.initState(); - widget.focusNode.addListener(_onFocusChange); - } - - @override - void dispose() { - _debounce?.cancel(); - widget.focusNode.removeListener(_onFocusChange); - super.dispose(); - } - - void _onFocusChange() { - setState(() => _focused = widget.focusNode.hasFocus); - } - - void _onChanged(String value) { - _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 300), () { - widget.onChanged(value); - }); - } - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - - return Container( - height: theme.sizing.searchBoxHeight, - decoration: BoxDecoration( - color: _focused ? colors.cardBackground : colors.searchBackground, - borderRadius: BorderRadius.circular(theme.radii.searchBox), - border: Border.all( - color: _focused - ? colors.primary.withValues(alpha: 0.5) - : colors.searchBorder, - ), - boxShadow: [ - if (_focused) - BoxShadow( - color: colors.primary.withValues(alpha: 0.1), - blurRadius: 8, - spreadRadius: 2, - ) - else - BoxShadow( - color: colors.onSurface.withValues(alpha: 0.06), - blurRadius: 3, - offset: const Offset(0, 1), - ), - ], - ), - child: TextField( - controller: widget.controller, - focusNode: widget.focusNode, - onChanged: _onChanged, - textAlignVertical: TextAlignVertical.center, - style: theme.typography.searchInput.copyWith( - color: colors.onSurface.withValues(alpha: 0.8), - ), - cursorColor: colors.primary, - cursorWidth: 1.2, - decoration: InputDecoration( - hintText: AppLocalizations.of(context).searchPlaceholder, - hintStyle: theme.typography.searchInput.copyWith( - color: colors.onSurface.withValues(alpha: 0.35), - ), - prefixIcon: Padding( - padding: const EdgeInsets.only(left: 14, right: 8), - child: Icon( - theme.icons.search, - size: 14, - color: colors.onSurface.withValues( - alpha: theme.searchStyle.iconOpacity, - ), - ), - ), - prefixIconConstraints: const BoxConstraints( - minWidth: 0, - minHeight: 0, - ), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.controller.text.isNotEmpty) - GestureDetector( - onTap: () { - widget.controller.clear(); - widget.onChanged(''); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Icon( - Icons.close_rounded, - size: 12, - color: colors.onSurfaceMuted, - ), - ), - ), - if (widget.trailing != null) - Padding( - padding: const EdgeInsets.only(right: 8), - child: widget.trailing!, - ), - ], - ), - suffixIconConstraints: const BoxConstraints( - minWidth: 0, - minHeight: 0, - ), - contentPadding: EdgeInsets.symmetric( - horizontal: theme.searchStyle.padding.left, - vertical: 0, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - ), - ), - ); - } -} diff --git a/app/macos/.gitignore b/app/macos/.gitignore deleted file mode 100644 index 746adbb6..00000000 --- a/app/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/app/macos/Flutter/Flutter-Debug.xcconfig b/app/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index 4b81f9b2..00000000 --- a/app/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/Flutter-Release.xcconfig b/app/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index 5caa9d15..00000000 --- a/app/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 413c0807..00000000 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import file_picker -import hotkey_manager_macos -import listener -import macos_window_utils -import screen_retriever_macos -import tray_manager -import window_manager - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) - HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) - ListenerPlugin.register(with: registry.registrar(forPlugin: "ListenerPlugin")) - MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) - ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) - TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) - WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) -} diff --git a/app/macos/Podfile b/app/macos/Podfile deleted file mode 100644 index ff5ddb3b..00000000 --- a/app/macos/Podfile +++ /dev/null @@ -1,42 +0,0 @@ -platform :osx, '10.15' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -target 'Runner' do - use_frameworks! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/app/macos/Podfile.lock b/app/macos/Podfile.lock deleted file mode 100644 index 0e0d59a1..00000000 --- a/app/macos/Podfile.lock +++ /dev/null @@ -1,65 +0,0 @@ -PODS: - - file_picker (0.0.1): - - FlutterMacOS - - FlutterMacOS (1.0.0) - - HotKey (0.2.1) - - hotkey_manager_macos (0.0.1): - - FlutterMacOS - - HotKey - - listener (0.0.1): - - FlutterMacOS - - macos_window_utils (1.0.0): - - FlutterMacOS - - screen_retriever_macos (0.0.1): - - FlutterMacOS - - tray_manager (0.0.1): - - FlutterMacOS - - window_manager (0.5.0): - - FlutterMacOS - -DEPENDENCIES: - - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - - FlutterMacOS (from `Flutter/ephemeral`) - - hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`) - - listener (from `Flutter/ephemeral/.symlinks/plugins/listener/macos`) - - macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`) - - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) - -SPEC REPOS: - trunk: - - HotKey - -EXTERNAL SOURCES: - file_picker: - :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos - FlutterMacOS: - :path: Flutter/ephemeral - hotkey_manager_macos: - :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos - listener: - :path: Flutter/ephemeral/.symlinks/plugins/listener/macos - macos_window_utils: - :path: Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos - screen_retriever_macos: - :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos - tray_manager: - :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos - window_manager: - :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos - -SPEC CHECKSUMS: - file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a - FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe - listener: cccbc07a6a40a6acf872fd26216410100fd83104 - macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d - screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f - tray_manager: a104b5c81b578d83f3c3d0f40a997c8b10810166 - window_manager: b729e31d38fb04905235df9ea896128991cad99e - -PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 - -COCOAPODS: 1.16.2 diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 918713f6..00000000 --- a/app/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,801 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 27F294F2FC1A5540792290B7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0A9396268EE0A613996FE54A /* Pods_Runner.framework */; }; - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - EFCA9B4EC829F7F788F5D3F2 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2F43A31374C6150BCB5F34B5 /* Pods_RunnerTests.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC10EC2044A3C60003C045; - remoteInfo = Runner; - }; - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 0A9396268EE0A613996FE54A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 268B456E2F3D22A3957F4964 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 2F43A31374C6150BCB5F34B5 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = app.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 3415E35F8C28102C6C7460E5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 5C19DFA2EF3C89A724BE20B7 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 8D9EA168AA0DDFE3C8B7AFFB /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 90E70C152C504027D1E8A250 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - D6A360C0D76EE65A50035EC4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 331C80D2294CF70F00263BE5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - EFCA9B4EC829F7F788F5D3F2 /* Pods_RunnerTests.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 27F294F2FC1A5540792290B7 /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C80D6294CF71000263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C80D7294CF71000263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 331C80D6294CF71000263BE5 /* RunnerTests */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - F6AC285569FDB2BB4C045EA0 /* Pods */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* app.app */, - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 0A9396268EE0A613996FE54A /* Pods_Runner.framework */, - 2F43A31374C6150BCB5F34B5 /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; - F6AC285569FDB2BB4C045EA0 /* Pods */ = { - isa = PBXGroup; - children = ( - 268B456E2F3D22A3957F4964 /* Pods-Runner.debug.xcconfig */, - 3415E35F8C28102C6C7460E5 /* Pods-Runner.release.xcconfig */, - D6A360C0D76EE65A50035EC4 /* Pods-Runner.profile.xcconfig */, - 5C19DFA2EF3C89A724BE20B7 /* Pods-RunnerTests.debug.xcconfig */, - 8D9EA168AA0DDFE3C8B7AFFB /* Pods-RunnerTests.release.xcconfig */, - 90E70C152C504027D1E8A250 /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C80D4294CF70F00263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - C6D2A2BBFE2539BE7F142199 /* [CP] Check Pods Manifest.lock */, - 331C80D1294CF70F00263BE5 /* Sources */, - 331C80D2294CF70F00263BE5 /* Frameworks */, - 331C80D3294CF70F00263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C80DA294CF71000263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 38CD4E7D606ED39C7AC1AA93 /* [CP] Check Pods Manifest.lock */, - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - 9FCA2631710007368872476D /* [CP] Embed Pods Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* app.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C80D4294CF70F00263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 33CC10EC2044A3C60003C045; - }; - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 331C80D4294CF70F00263BE5 /* RunnerTests */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C80D3294CF70F00263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; - 38CD4E7D606ED39C7AC1AA93 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 9FCA2631710007368872476D /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - C6D2A2BBFE2539BE7F142199 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C80D1294CF70F00263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC10EC2044A3C60003C045 /* Runner */; - targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; - }; - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 331C80DB294CF71000263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 5C19DFA2EF3C89A724BE20B7 /* Pods-RunnerTests.debug.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/app"; - }; - name = Debug; - }; - 331C80DC294CF71000263BE5 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 8D9EA168AA0DDFE3C8B7AFFB /* Pods-RunnerTests.release.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/app"; - }; - name = Release; - }; - 331C80DD294CF71000263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 90E70C152C504027D1E8A250 /* Pods-RunnerTests.profile.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/app"; - }; - name = Profile; - }; - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C80DB294CF71000263BE5 /* Debug */, - 331C80DC294CF71000263BE5 /* Release */, - 331C80DD294CF71000263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index e8559da2..00000000 --- a/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/macos/Runner.xcworkspace/contents.xcworkspacedata b/app/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 21a3cc14..00000000 --- a/app/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift deleted file mode 100644 index af5aedd0..00000000 --- a/app/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Cocoa -import FlutterMacOS - -@main -class AppDelegate: FlutterAppDelegate { - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return false - } - - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true - } - - override func applicationDidFinishLaunching(_ notification: Notification) { - super.applicationDidFinishLaunching(notification) - stripMenuBar() - } - - override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - let wakeupPath = (NSTemporaryDirectory() as NSString).appendingPathComponent("copypaste.wakeup") - try? "wakeup".write(toFile: wakeupPath, atomically: true, encoding: .utf8) - return true - } - - private func stripMenuBar() { - guard let menu = NSApp.mainMenu else { return } - - // Keep only App (index 0) and Edit (index 1) menus - while menu.items.count > 2 { - menu.removeItem(at: menu.items.count - 1) - } - - // Remove keyboard shortcuts that bypass Dart cleanup - if let appMenu = menu.items.first?.submenu { - for item in appMenu.items where - item.action == #selector(NSApplication.terminate(_:)) || - item.action == #selector(NSApplication.hide(_:)) || - item.action == #selector(NSApplication.hideOtherApplications(_:)) { - item.keyEquivalent = "" - item.keyEquivalentModifierMask = [] - } - } - } -} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a2ec33f1..00000000 --- a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 24279bd1..00000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index e43f5f16..00000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index eb4101bc..00000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index 15bdd592..00000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index 86af0c4d..00000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index 98298e16..00000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index f82dc91f..00000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/app/macos/Runner/Base.lproj/MainMenu.xib b/app/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index be628213..00000000 --- a/app/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/macos/Runner/Configs/AppInfo.xcconfig b/app/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index b53798f9..00000000 --- a/app/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = CopyPaste - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.rgdevment.copypaste - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 rgdevment. All rights reserved. diff --git a/app/macos/Runner/Configs/Debug.xcconfig b/app/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd94..00000000 --- a/app/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Release.xcconfig b/app/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f495..00000000 --- a/app/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Warnings.xcconfig b/app/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf47..00000000 --- a/app/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/app/macos/Runner/DebugProfile.entitlements b/app/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index 4fc43ef1..00000000 --- a/app/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,16 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - com.apple.security.network.client - - com.apple.security.files.user-selected.read-write - - - diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist deleted file mode 100644 index c1e73733..00000000 --- a/app/macos/Runner/Info.plist +++ /dev/null @@ -1,36 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - LSUIElement - - LSMultipleInstancesProhibited - - - diff --git a/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 3cc05eb2..00000000 --- a/app/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Cocoa -import FlutterMacOS - -class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/app/macos/Runner/Release.entitlements b/app/macos/Runner/Release.entitlements deleted file mode 100644 index 04315f36..00000000 --- a/app/macos/Runner/Release.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.network.client - - com.apple.security.files.user-selected.read-write - - - diff --git a/app/macos/RunnerTests/RunnerTests.swift b/app/macos/RunnerTests/RunnerTests.swift deleted file mode 100644 index 61f3bd1f..00000000 --- a/app/macos/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Cocoa -import FlutterMacOS -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/app/pubspec.yaml b/app/pubspec.yaml deleted file mode 100644 index dae1174a..00000000 --- a/app/pubspec.yaml +++ /dev/null @@ -1,54 +0,0 @@ -name: copypaste -description: "CopyPaste — Flutter desktop clipboard manager." -publish_to: "none" -version: 0.0.0-dev -resolution: workspace - -environment: - sdk: ^3.11.1 - -dependencies: - flutter: - sdk: flutter - flutter_localizations: - sdk: flutter - intl: any - core: - path: ../core - listener: - path: ../listener - tray_manager: ^0.5.2 - hotkey_manager: ^0.2.0 - window_manager: ^0.5.1 - ffi: ^2.2.0 - file_picker: ^11.0.0 - flutter_acrylic: ^1.1.4 - cryptography: ^2.7.0 - path: ^1.9.0 -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - msix: ^3.16.8 - -msix_config: - startup_task: - task_id: CopyPasteStartup - enabled: true - -flutter: - uses-material-design: true - - assets: - - assets/icons/icon_app_256.png - - assets/icons/icon_tray_32.png - - assets/icons/icon_tray_64.png - - assets/icons/icon_notification.png - - assets/icons/icon_tray.ico - - assets/icons/icon_mac_tray.png - - assets/icons/icon_mac_tray@2x.png - - assets/icons/icon_linkunbound.png - - assets/keys/release_pubkey.txt - - generate: true - diff --git a/app/test/helpers/test_wrapper.dart b/app/test/helpers/test_wrapper.dart deleted file mode 100644 index d4d45c52..00000000 --- a/app/test/helpers/test_wrapper.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:copypaste/l10n/app_localizations.dart'; -import 'package:copypaste/theme/compact_theme.dart'; -import 'package:copypaste/theme/theme_provider.dart'; - -Widget wrapWidget(Widget child, {Brightness brightness = Brightness.light}) { - return MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData(brightness: brightness), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold(body: child), - ), - ); -} diff --git a/app/test/helpers/url_helper_test.dart b/app/test/helpers/url_helper_test.dart deleted file mode 100644 index ed75ba37..00000000 --- a/app/test/helpers/url_helper_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/helpers/url_helper.dart'; - -void main() { - tearDown(() => UrlHelper.platformOverride = null); - - group('UrlHelper.open', () { - test('completes on current platform without throwing', () async { - try { - await UrlHelper.open(''); - } catch (_) {} - }); - - test('takes windows branch when platformOverride=windows', () async { - UrlHelper.platformOverride = 'windows'; - try { - await UrlHelper.open('about:blank'); - } catch (_) {} - }); - - test('takes macos branch when platformOverride=macos', () async { - UrlHelper.platformOverride = 'macos'; - try { - await UrlHelper.open('about:blank'); - } catch (_) {} - }); - - test('takes no-op branch when platformOverride=other', () async { - UrlHelper.platformOverride = 'other'; - await UrlHelper.open('about:blank'); - }); - }); -} diff --git a/app/test/screens/blocked_version_screen_test.dart b/app/test/screens/blocked_version_screen_test.dart deleted file mode 100644 index 992768a3..00000000 --- a/app/test/screens/blocked_version_screen_test.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'package:flutter/material.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/blocked_version_screen.dart'; -import 'package:copypaste/services/install_channel.dart'; -import 'package:copypaste/services/release_manifest_service.dart'; -import 'package:copypaste/theme/compact_theme.dart'; -import 'package:copypaste/theme/theme_provider.dart'; - -Widget _wrap(Widget child) => MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.light(), - home: CopyPasteTheme(themeData: CompactTheme(), child: child), -); - -ReleaseManifest _manifest({ - String? githubWindowsUrl, - String? homebrewCommand, - String? msStoreUrl, - String? scoopCommand, -}) { - return ReleaseManifest( - schema: 1, - latest: '2.3.0', - minimumSupported: '2.3.0', - blockedVersions: const ['2.2.6'], - severity: ManifestSeverity.critical, - channels: { - if (githubWindowsUrl != null) - 'github_windows': ChannelInfo(url: githubWindowsUrl), - if (homebrewCommand != null) - 'homebrew': ChannelInfo(command: homebrewCommand), - if (msStoreUrl != null) 'msstore': ChannelInfo(url: msStoreUrl), - if (scoopCommand != null) 'scoop': ChannelInfo(command: scoopCommand), - if (githubWindowsUrl != null) - 'github_macos': ChannelInfo(url: githubWindowsUrl), - }, - notes: const {'en': ReleaseNotes(summary: 'Critical security fix.')}, - ); -} - -void main() { - tearDown(() { - UrlHelper.platformOverride = null; - InstallChannelDetector.platformOverride = null; - InstallChannelDetector.channelOverride = null; - }); - - group('BlockedVersionScreen', () { - testWidgets('renders title and current version', (tester) async { - await tester.pumpWidget( - _wrap( - BlockedVersionScreen( - currentVersion: '2.2.6', - manifest: _manifest(githubWindowsUrl: 'https://example.com'), - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.textContaining('2.2.6'), findsWidgets); - expect(find.textContaining('2.3.0'), findsWidgets); - }); - - testWidgets('shows release notes summary', (tester) async { - await tester.pumpWidget( - _wrap( - BlockedVersionScreen( - currentVersion: '2.2.6', - manifest: _manifest(githubWindowsUrl: 'https://example.com'), - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.textContaining('Critical security fix'), findsOneWidget); - }); - - testWidgets('shows Download button for github_windows channel', ( - tester, - ) async { - InstallChannelDetector.platformOverride = HostPlatform.windows; - UrlHelper.platformOverride = 'other'; - await tester.pumpWidget( - _wrap( - BlockedVersionScreen( - currentVersion: '2.2.6', - manifest: _manifest(githubWindowsUrl: 'https://example.com/latest'), - ), - ), - ); - await tester.pumpAndSettle(); - final l = await AppLocalizations.delegate.load(const Locale('en')); - expect(find.text(l.updateActionDownload), findsOneWidget); - }); - - testWidgets('shows Copy command button for homebrew channel', ( - tester, - ) async { - InstallChannelDetector.channelOverride = InstallChannel.homebrew; - await tester.pumpWidget( - _wrap( - BlockedVersionScreen( - currentVersion: '2.2.6', - manifest: _manifest(homebrewCommand: 'brew upgrade copypaste'), - ), - ), - ); - await tester.pumpAndSettle(); - final l = await AppLocalizations.delegate.load(const Locale('en')); - expect(find.text(l.updateActionCopyCommand('brew')), findsOneWidget); - }); - - testWidgets('shows Copy command button for scoop channel', (tester) async { - InstallChannelDetector.channelOverride = InstallChannel.scoop; - await tester.pumpWidget( - _wrap( - BlockedVersionScreen( - currentVersion: '2.2.6', - manifest: _manifest(scoopCommand: 'scoop update copypaste'), - ), - ), - ); - await tester.pumpAndSettle(); - final l = await AppLocalizations.delegate.load(const Locale('en')); - expect(find.text(l.updateActionCopyCommand('scoop')), findsOneWidget); - }); - - testWidgets('shows fallback hint when channel has no info', (tester) async { - InstallChannelDetector.platformOverride = HostPlatform.windows; - await tester.pumpWidget( - _wrap( - BlockedVersionScreen( - currentVersion: '2.2.6', - manifest: ReleaseManifest( - schema: 1, - latest: '2.3.0', - minimumSupported: '2.3.0', - blockedVersions: const [], - channels: const {}, - notes: const {}, - severity: ManifestSeverity.critical, - ), - ), - ), - ); - await tester.pumpAndSettle(); - final l = await AppLocalizations.delegate.load(const Locale('en')); - expect(find.text(l.blockedFallbackHint), findsOneWidget); - }); - - testWidgets('shows generic reason when notes is empty', (tester) async { - InstallChannelDetector.platformOverride = HostPlatform.windows; - await tester.pumpWidget( - _wrap( - BlockedVersionScreen( - currentVersion: '2.2.6', - manifest: ReleaseManifest( - schema: 1, - latest: '2.3.0', - minimumSupported: '2.3.0', - blockedVersions: const [], - channels: const { - 'github_windows': ChannelInfo(url: 'https://example.com'), - }, - notes: const {}, - severity: ManifestSeverity.critical, - ), - ), - ), - ); - await tester.pumpAndSettle(); - final l = await AppLocalizations.delegate.load(const Locale('en')); - expect(find.text(l.blockedReasonGeneric), findsOneWidget); - }); - - testWidgets('Quit button is visible', (tester) async { - InstallChannelDetector.platformOverride = HostPlatform.windows; - await tester.pumpWidget( - _wrap( - BlockedVersionScreen( - currentVersion: '2.2.6', - manifest: _manifest(githubWindowsUrl: 'https://example.com'), - ), - ), - ); - await tester.pumpAndSettle(); - final l = await AppLocalizations.delegate.load(const Locale('en')); - expect(find.text(l.blockedQuit), findsOneWidget); - }); - - testWidgets('tapping Download button invokes UrlHelper', (tester) async { - InstallChannelDetector.platformOverride = HostPlatform.windows; - UrlHelper.platformOverride = 'other'; - await tester.pumpWidget( - _wrap( - BlockedVersionScreen( - currentVersion: '2.2.6', - manifest: _manifest(githubWindowsUrl: 'https://example.com/latest'), - ), - ), - ); - await tester.pumpAndSettle(); - final l = await AppLocalizations.delegate.load(const Locale('en')); - await tester.tap(find.text(l.updateActionDownload)); - await tester.pumpAndSettle(); - }); - }); -} diff --git a/app/test/screens/desktop_onboarding_screen_test.dart b/app/test/screens/desktop_onboarding_screen_test.dart deleted file mode 100644 index 44cd0c11..00000000 --- a/app/test/screens/desktop_onboarding_screen_test.dart +++ /dev/null @@ -1,210 +0,0 @@ -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/main_screen_test.dart b/app/test/screens/main_screen_test.dart deleted file mode 100644 index eb932557..00000000 --- a/app/test/screens/main_screen_test.dart +++ /dev/null @@ -1,1922 +0,0 @@ -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/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'; -import 'package:copypaste/widgets/empty_state.dart'; -import 'package:copypaste/widgets/filter_bar.dart'; - -Widget _buildApp({ - required ClipboardService service, - required void Function(ClipboardItem) onPaste, - void Function(ClipboardItem)? onPastePlain, - VoidCallback? onPlainPasteUnavailable, - VoidCallback? onExit, - VoidCallback? onSettings, - bool resetScrollOnShow = true, - bool resetSearchOnShow = true, - bool resetFiltersOnShow = true, - bool showHint = false, - VoidCallback? onDismissHint, - String? updateVersion, - ManifestSeverity? updateSeverity, - Key? key, -}) { - return MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.light(), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: MainScreen( - key: key, - clipboardService: service, - onPaste: onPaste, - onPastePlain: onPastePlain ?? (_) {}, - onPlainPasteUnavailable: onPlainPasteUnavailable, - onExit: onExit ?? () {}, - onSettings: onSettings ?? () {}, - resetScrollOnShow: resetScrollOnShow, - resetSearchOnShow: resetSearchOnShow, - resetFiltersOnShow: resetFiltersOnShow, - showHint: showHint, - onDismissHint: onDismissHint, - updateVersion: updateVersion, - updateSeverity: updateSeverity, - ), - ), - ), - ); -} - -void main() { - late SqliteRepository repo; - late ClipboardService service; - - setUp(() { - repo = SqliteRepository.inMemory(); - service = ClipboardService(repo); - }); - - tearDown(() async { - await service.dispose(); - await repo.close(); - }); - - group('MainScreen', () { - testWidgets('renders without error', (tester) async { - await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); - await tester.pumpAndSettle(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('shows EmptyState and reports unavailable plain paste', ( - tester, - ) async { - var unavailableCount = 0; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - onPlainPasteUnavailable: () => unavailableCount++, - key: key, - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(EmptyState), findsOneWidget); - expect(key.currentState!.pasteSelectedPlainOrFirst(), isFalse); - expect(unavailableCount, 1); - }); - - testWidgets('shows ClipboardCard after items are loaded', (tester) async { - await repo.save( - ClipboardItem( - content: 'Hello clipboard', - type: ClipboardContentType.text, - ), - ); - - await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - expect(find.text('Hello clipboard'), findsOneWidget); - }); - - testWidgets('multiple items render multiple cards', (tester) async { - for (var i = 0; i < 3; i++) { - await repo.save( - ClipboardItem(content: 'Item $i', type: ClipboardContentType.text), - ); - } - - await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsNWidgets(3)); - }); - - testWidgets('bounds displayed text for very large content', (tester) async { - final big = 'A' * 5000; - await repo.save( - ClipboardItem(content: big, type: ClipboardContentType.text), - ); - - await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); - await tester.pumpAndSettle(); - - expect(find.text(big), findsNothing); - final preview = tester - .widgetList(find.byType(Text)) - .firstWhere((t) => (t.data ?? '').startsWith('A')); - expect(preview.data!.length, lessThanOrEqualTo(2000)); - expect(preview.data!.length, greaterThan(0)); - }); - - testWidgets('ArrowDown from search selects first item', (tester) async { - await repo.save( - ClipboardItem(content: 'Select me', 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.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - // Card is present (selection changes visual rendering) - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('Enter fires onPaste with selected item', (tester) async { - await repo.save( - ClipboardItem(content: 'Paste me', type: ClipboardContentType.text), - ); - - ClipboardItem? pasted; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (item) => pasted = item, key: key), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pump(); - - // ArrowDown to select first item - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - // Enter to paste - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); - - expect(pasted, isNotNull); - expect(pasted!.content, equals('Paste me')); - }); - - testWidgets('Escape fires onExit when no active filters', (tester) async { - await repo.save( - ClipboardItem(content: 'Test item', type: ClipboardContentType.text), - ); - - var exitFired = false; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - onExit: () => exitFired = true, - key: key, - ), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pump(); - - // Move focus to list - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pump(); - - expect(exitFired, isTrue); - }); - - testWidgets('Ctrl+1 switches to recent tab', (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.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.digit1); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(); - - // Recent tab is active, no crash - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('Ctrl+2 switches to pinned tab', (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.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.digit2); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pumpAndSettle(); - - // Pinned tab has no items → EmptyState - expect(find.byType(EmptyState), findsOneWidget); - }); - - testWidgets('onWindowHide resets selected index and state', (tester) async { - await repo.save( - ClipboardItem(content: 'Test', 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.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - key.currentState!.onWindowHide(); - await tester.pump(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('ArrowUp from first item returns focus to search', ( - tester, - ) async { - await repo.save( - ClipboardItem(content: 'Item', type: ClipboardContentType.text), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (_) {}, key: key), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pump(); - - // ArrowDown to select first item - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - // ArrowUp from first item → selection cleared, search focused - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - - // Screen still renders - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('search text change filters items', (tester) async { - await repo.save( - ClipboardItem(content: 'apple pie', type: ClipboardContentType.text), - ); - await repo.save( - ClipboardItem(content: 'banana split', type: ClipboardContentType.text), - ); - - await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); - await tester.pumpAndSettle(); - - // Type in the search field - await tester.enterText(find.byType(TextField).first, 'apple'); - // Wait for debounce - await tester.pump(const Duration(milliseconds: 400)); - await tester.pumpAndSettle(); - - expect(find.text('apple pie'), findsOneWidget); - }); - - testWidgets('Delete key deletes selected item', (tester) async { - await repo.save( - ClipboardItem(content: 'Delete me', 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.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - await tester.sendKeyEvent(LogicalKeyboardKey.delete); - await tester.pumpAndSettle(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('P key pins selected item', (tester) async { - await repo.save( - ClipboardItem(content: 'Pin me', 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.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - await tester.sendKeyEvent(LogicalKeyboardKey.keyP); - await tester.pumpAndSettle(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('ArrowRight expands selected item', (tester) async { - await repo.save( - ClipboardItem(content: 'Expand me', 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.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.pump(); - - // ArrowRight again collapses - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.pump(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('Alt+C focuses search field', (tester) async { - await repo.save( - ClipboardItem(content: 'Item', type: ClipboardContentType.text), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (_) {}, key: key), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pump(); - - // Move focus to list - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - // Alt+C should return focus to search - await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyC); - await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); - await tester.pump(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('Escape with active search query clears search', ( - tester, - ) async { - await repo.save( - ClipboardItem(content: 'Item', type: ClipboardContentType.text), - ); - - var exitFired = false; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - onExit: () => exitFired = true, - key: key, - ), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pump(); - - // Enter text in search AFTER onWindowShow so it's not cleared - await tester.enterText(find.byType(TextField).first, 'search text'); - // Wait for 300ms debounce in TitleBar._SearchBarState - await tester.pump(const Duration(milliseconds: 400)); - - // Escape should clear search, not exit - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pump(); - - // Exit should NOT have fired because search was active - expect(exitFired, isFalse); - }); - - testWidgets('showHint renders hint banner', (tester) async { - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.light(), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: MainScreen( - clipboardService: service, - onPaste: (_) {}, - onPastePlain: (_) {}, - onExit: () {}, - onSettings: () {}, - showHint: true, - onDismissHint: () {}, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(MainScreen), findsOneWidget); - // Hint banner icon visible - expect(find.byIcon(Icons.lightbulb_outline_rounded), findsOneWidget); - }); - - testWidgets('hint banner dismiss button calls onDismissHint', ( - tester, - ) async { - var dismissed = false; - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.light(), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: MainScreen( - clipboardService: service, - onPaste: (_) {}, - onPastePlain: (_) {}, - onExit: () {}, - onSettings: () {}, - showHint: true, - onDismissHint: () => dismissed = true, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byIcon(Icons.close_rounded)); - await tester.pump(); - - expect(dismissed, isTrue); - }); - - testWidgets('hint banner settings link dismisses hint and opens settings', ( - tester, - ) async { - var dismissed = false; - var settingsOpened = false; - - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - onSettings: () => settingsOpened = true, - showHint: true, - onDismissHint: () => dismissed = true, - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Settings')); - await tester.pumpAndSettle(); - - expect(dismissed, isTrue); - expect(settingsOpened, isTrue); - }); - - testWidgets('settings button triggers onSettings', (tester) async { - var settingsFired = false; - - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.light(), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: MainScreen( - clipboardService: service, - onPaste: (_) {}, - onPastePlain: (_) {}, - onExit: () {}, - onSettings: () => settingsFired = true, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - // Find and tap settings icon - final settingsIcon = find.byIcon(Icons.settings_outlined); - if (settingsIcon.evaluate().isNotEmpty) { - await tester.tap(settingsIcon.first); - await tester.pump(); - expect(settingsFired, isTrue); - } - }); - - testWidgets('ArrowDown from selected item moves to next', (tester) async { - for (var i = 0; i < 3; 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(); - - // Move to first - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - // Move to second - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - expect(find.byType(ClipboardCard), findsWidgets); - }); - - testWidgets('Shift+Tab returns focus from list to search', (tester) async { - await repo.save( - ClipboardItem(content: 'Item', 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.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.pump(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('onWindowHide trims items list when large', (tester) async { - // Add more than pageSize items - 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!.onWindowHide(); - await tester.pump(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('item addition reloads list via stream', (tester) async { - await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); - await tester.pumpAndSettle(); - - expect(find.byType(EmptyState), findsOneWidget); - - // Add via service (triggers stream) - await service.processText('Stream item', ClipboardContentType.text); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsAtLeastNWidgets(1)); - }); - - testWidgets('E key on selected item shows edit dialog', (tester) async { - await repo.save( - ClipboardItem(content: 'Edit me', 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.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - // E key → shows edit dialog - await tester.sendKeyEvent(LogicalKeyboardKey.keyE); - await tester.pumpAndSettle(); - - // Label & Color dialog should appear - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('onWindowShow with resetScrollOnShow=false does not scroll', ( - tester, - ) async { - for (var i = 0; i < 5; i++) { - await repo.save( - ClipboardItem(content: 'Item $i', type: ClipboardContentType.text), - ); - } - - final key = GlobalKey(); - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.light(), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: MainScreen( - key: key, - clipboardService: service, - onPaste: (_) {}, - onPastePlain: (_) {}, - onExit: () {}, - onSettings: () {}, - resetScrollOnShow: false, - resetSearchOnShow: false, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pumpAndSettle(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets( - 'onWindowShow keeps search text when resetSearchOnShow is false', - (tester) async { - await repo.save( - ClipboardItem( - content: 'Kept search', - type: ClipboardContentType.text, - ), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - key: key, - resetSearchOnShow: false, - ), - ); - await tester.pumpAndSettle(); - - final searchField = find.byType(TextField).first; - await tester.enterText(searchField, 'keep me'); - await tester.pump(const Duration(milliseconds: 400)); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pumpAndSettle(); - - expect(find.text('keep me'), findsOneWidget); - }, - ); - - testWidgets('keyboard navigation at bottom of list does not crash', ( - tester, - ) async { - for (var i = 0; i < 3; 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(); - - // Navigate to last item - for (var i = 0; i < 5; i++) { - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - } - - expect(find.byType(ClipboardCard), findsWidgets); - }); - - testWidgets('pinned tab shows only pinned items', (tester) async { - await repo.save( - ClipboardItem(content: 'Normal item', type: ClipboardContentType.text), - ); - await repo.save( - ClipboardItem( - content: 'Pinned item', - type: ClipboardContentType.text, - isPinned: true, - ), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (_) {}, key: key), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pump(); - - // Switch to pinned tab - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.digit2); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pumpAndSettle(); - - expect(find.text('Pinned item'), findsOneWidget); - }); - - testWidgets('hint banner settings link calls onSettings', (tester) async { - var settingsFired = false; - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.light(), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: MainScreen( - clipboardService: service, - onPaste: (_) {}, - onPastePlain: (_) {}, - onExit: () {}, - onSettings: () => settingsFired = true, - showHint: true, - onDismissHint: () {}, - ), - ), - ), - ), - ); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - - // Find the "Settings" link text in hint banner and tap it - final settingsLinks = find.byType(GestureDetector); - // The hint banner has a GestureDetector for the settings link - if (settingsLinks.evaluate().isNotEmpty) { - await tester.tap(settingsLinks.first); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - } - // Just verify the screen doesn't crash - expect(settingsFired || !settingsFired, isTrue); - }); - - testWidgets('tapping type filter chip calls onTypeFilterChanged', ( - tester, - ) async { - await repo.save( - ClipboardItem(content: 'Hello', type: ClipboardContentType.text), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (_) {}, key: key), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pump(); - - // Tap the "Text" chip in FilterTabBar to set type filter - final textChip = find.text('Text'); - if (textChip.evaluate().isNotEmpty) { - await tester.tap(textChip.first); - await tester.pumpAndSettle(); - } - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('Escape with type filter active clears filters', ( - tester, - ) async { - await repo.save( - ClipboardItem(content: 'Item 1', type: ClipboardContentType.text), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (_) {}, key: key), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pump(); - - // Set a type filter by tapping "Text" chip - final textChip = find.text('Text'); - if (textChip.evaluate().isNotEmpty) { - await tester.tap(textChip.first); - await tester.pumpAndSettle(); - } - - // Move focus to list and send Escape to clear filters - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('Alt+G opens filter bar menu', (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.keyG); - await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); - await tester.pumpAndSettle(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('ArrowUp from selected item moves selection up', ( - tester, - ) async { - for (var i = 0; i < 3; 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(); - - // Navigate down twice - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - // Then navigate up - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - - // Navigate up past first item → should return to search - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - - expect(find.byType(ClipboardCard), findsWidgets); - }); - - testWidgets('Enter key on selected item triggers onPaste', (tester) async { - await repo.save( - ClipboardItem(content: 'Paste me', type: ClipboardContentType.text), - ); - - ClipboardItem? pasted; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (item) => pasted = item, key: key), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pump(); - - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); - - expect(pasted, isNotNull); - expect(pasted!.content, 'Paste me'); - }); - - testWidgets('Ctrl+Shift+V never pastes a CopyPaste history item', ( - tester, - ) async { - final now = DateTime.now().toUtc(); - await repo.save( - ClipboardItem( - content: 'Older', - type: ClipboardContentType.text, - createdAt: now.subtract(const Duration(seconds: 1)), - modifiedAt: now.subtract(const Duration(seconds: 1)), - ), - ); - await repo.save( - ClipboardItem( - content: 'Newest', - type: ClipboardContentType.text, - createdAt: now, - modifiedAt: now, - ), - ); - - ClipboardItem? pastedPlain; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - onPastePlain: (item) => pastedPlain = item, - key: key, - ), - ); - await tester.pumpAndSettle(); - key.currentState!.onWindowShow(); - await tester.pump(); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyV); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(); - - expect(pastedPlain, isNull); - }); - - testWidgets('Ctrl+V never pastes a CopyPaste history item', (tester) async { - await repo.save( - ClipboardItem(content: 'History item', type: ClipboardContentType.text), - ); - - ClipboardItem? pasted; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (item) => pasted = item, key: key), - ); - await tester.pumpAndSettle(); - key.currentState!.onWindowShow(); - await tester.pump(); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyV); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(); - - expect(pasted, isNull); - }); - - testWidgets('Enter pastes first visible item without selection', ( - tester, - ) async { - final now = DateTime.now().toUtc(); - await repo.save( - ClipboardItem( - content: 'Older', - type: ClipboardContentType.text, - createdAt: now.subtract(const Duration(seconds: 1)), - modifiedAt: now.subtract(const Duration(seconds: 1)), - ), - ); - await repo.save( - ClipboardItem( - content: 'Newest', - type: ClipboardContentType.text, - createdAt: now, - modifiedAt: now, - ), - ); - - ClipboardItem? pasted; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (item) => pasted = item, key: key), - ); - await tester.pumpAndSettle(); - key.currentState!.onWindowShow(); - await tester.pump(); - - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); - - expect(pasted?.content, 'Newest'); - }); - - testWidgets('normal paste prioritizes the item under the mouse', ( - tester, - ) async { - final now = DateTime.now().toUtc(); - await repo.save( - ClipboardItem( - content: 'Hovered older item', - type: ClipboardContentType.text, - createdAt: now.subtract(const Duration(seconds: 1)), - modifiedAt: now.subtract(const Duration(seconds: 1)), - ), - ); - await repo.save( - ClipboardItem( - content: 'Newest first item', - type: ClipboardContentType.text, - createdAt: now, - modifiedAt: now, - ), - ); - - ClipboardItem? pasted; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (item) => pasted = item, key: key), - ); - await tester.pumpAndSettle(); - - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - await mouse.addPointer(location: Offset.zero); - addTearDown(mouse.removePointer); - final hoveredCard = find.ancestor( - of: find.text('Hovered older item'), - matching: find.byType(ClipboardCard), - ); - await mouse.moveTo(tester.getCenter(hoveredCard)); - await tester.pump(); - - expect(key.currentState!.pasteSelectedOrFirst(), isTrue); - expect(pasted?.content, 'Hovered older item'); - }); - - testWidgets('normal paste falls back after the pointer leaves a card', ( - tester, - ) async { - final now = DateTime.now().toUtc(); - await repo.save( - ClipboardItem( - content: 'Hovered older item', - type: ClipboardContentType.text, - createdAt: now.subtract(const Duration(seconds: 1)), - modifiedAt: now.subtract(const Duration(seconds: 1)), - ), - ); - await repo.save( - ClipboardItem( - content: 'Newest first item', - type: ClipboardContentType.text, - createdAt: now, - modifiedAt: now, - ), - ); - - ClipboardItem? pasted; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (item) => pasted = item, key: key), - ); - await tester.pumpAndSettle(); - - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - await mouse.addPointer(location: Offset.zero); - addTearDown(mouse.removePointer); - final hoveredCard = find.ancestor( - of: find.text('Hovered older item'), - matching: find.byType(ClipboardCard), - ); - await mouse.moveTo(tester.getCenter(hoveredCard)); - await tester.pump(); - await mouse.moveTo(Offset.zero); - await tester.pump(); - - expect(key.currentState!.pasteSelectedOrFirst(), isTrue); - expect(pasted?.content, 'Newest first item'); - }); - - testWidgets('plain paste reports unsupported history item', (tester) async { - await repo.save( - ClipboardItem( - content: 'C:/example/image.png', - type: ClipboardContentType.image, - ), - ); - - var unavailableCount = 0; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - onPlainPasteUnavailable: () => unavailableCount++, - key: key, - ), - ); - await tester.pumpAndSettle(); - - expect(key.currentState!.pasteSelectedPlainOrFirst(), isFalse); - expect(unavailableCount, 1); - }); - - testWidgets('plain paste prioritizes the item under the mouse', ( - tester, - ) async { - final now = DateTime.now().toUtc(); - await repo.save( - ClipboardItem( - content: 'Hovered older item', - type: ClipboardContentType.text, - createdAt: now.subtract(const Duration(seconds: 1)), - modifiedAt: now.subtract(const Duration(seconds: 1)), - ), - ); - await repo.save( - ClipboardItem( - content: 'Newest first item', - type: ClipboardContentType.text, - createdAt: now, - modifiedAt: now, - ), - ); - - ClipboardItem? pastedPlain; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - onPastePlain: (item) => pastedPlain = item, - key: key, - ), - ); - await tester.pumpAndSettle(); - - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - await mouse.addPointer(location: Offset.zero); - addTearDown(mouse.removePointer); - final hoveredCard = find.ancestor( - of: find.text('Hovered older item'), - matching: find.byType(ClipboardCard), - ); - await mouse.moveTo(tester.getCenter(hoveredCard)); - await tester.pump(); - - expect(key.currentState!.pasteSelectedPlainOrFirst(), isTrue); - expect(pastedPlain?.content, 'Hovered older item'); - }); - - testWidgets('Shift+Enter pastes selected item as plain text', ( - tester, - ) async { - await repo.save( - ClipboardItem(content: 'Paste plain', type: ClipboardContentType.text), - ); - - ClipboardItem? pastedPlain; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - onPastePlain: (item) => pastedPlain = item, - key: key, - ), - ); - await tester.pumpAndSettle(); - key.currentState!.onWindowShow(); - await tester.pump(); - - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.pump(); - - expect(pastedPlain, isNotNull); - expect(pastedPlain!.content, 'Paste plain'); - }); - - testWidgets('Shift+Enter pastes first visible item without selection', ( - tester, - ) async { - final now = DateTime.now().toUtc(); - await repo.save( - ClipboardItem( - content: 'Older plain', - type: ClipboardContentType.text, - createdAt: now.subtract(const Duration(seconds: 1)), - modifiedAt: now.subtract(const Duration(seconds: 1)), - ), - ); - await repo.save( - ClipboardItem( - content: 'Newest plain', - type: ClipboardContentType.text, - createdAt: now, - modifiedAt: now, - ), - ); - - ClipboardItem? pastedPlain; - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - onPastePlain: (item) => pastedPlain = item, - key: key, - ), - ); - await tester.pumpAndSettle(); - key.currentState!.onWindowShow(); - await tester.pump(); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.pump(); - - expect(pastedPlain?.content, 'Newest plain'); - }); - - testWidgets('item reactivated via stream triggers reload', (tester) async { - await repo.save( - ClipboardItem(content: 'Reactivated', type: ClipboardContentType.text), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (_) {}, key: key), - ); - await tester.pumpAndSettle(); - - // processText with same content twice triggers reactivation - await service.processText('Reactivated', ClipboardContentType.text); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsAtLeastNWidgets(1)); - }); - - testWidgets('search clear button clears text field', (tester) async { - await repo.save( - ClipboardItem(content: 'Clear test', type: ClipboardContentType.text), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (_) {}, key: key), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pump(); - - // Enter text in search - final searchField = find.byType(TextField).first; - await tester.enterText(searchField, 'hello'); - // Wait for 300ms debounce → triggers _onSearchChanged → _reload → setState → rebuild shows clear button - await tester.pump(const Duration(milliseconds: 400)); - await tester.pumpAndSettle(); - - // Close/clear icon should appear in the search suffix - final closeIcons = find.byIcon(Icons.close_rounded); - expect(closeIcons, findsAtLeastNWidgets(1)); - await tester.tap(closeIcons.first); - await tester.pump(const Duration(milliseconds: 400)); - await tester.pumpAndSettle(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('renders correctly in Spanish locale', (tester) async { - await tester.pumpWidget( - MaterialApp( - locale: const Locale('es'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.light(), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: MainScreen( - clipboardService: service, - onPaste: (_) {}, - onPastePlain: (_) {}, - onExit: () {}, - onSettings: () {}, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('color filter changes reload the list', (tester) async { - await repo.save( - ClipboardItem( - content: 'Red item', - type: ClipboardContentType.text, - cardColor: CardColor.red, - ), - ); - await repo.save( - ClipboardItem(content: 'Plain item', type: ClipboardContentType.text), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (_) {}, key: key), - ); - await tester.pumpAndSettle(); - - // Activate a color filter via the state directly - key.currentState!.onWindowShow(); - await tester.pump(); - - // Trigger color filter change via FilterBar - final filterBarKey = find.byType(FilterBar); - if (filterBarKey.evaluate().isNotEmpty) { - // We have a filter bar – verify screen still renders - expect(find.byType(MainScreen), findsOneWidget); - } - }); - - testWidgets( - 'clear filters via keyboard Escape removes active type filter', - (tester) async { - await repo.save( - ClipboardItem(content: 'Item', type: ClipboardContentType.text), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (_) {}, key: key), - ); - await tester.pumpAndSettle(); - key.currentState!.onWindowShow(); - await tester.pump(); - - // First set a type filter (Text chip) - final textChip = find.text('Text'); - if (textChip.evaluate().isNotEmpty) { - await tester.tap(textChip.first); - await tester.pumpAndSettle(); - - // Now Escape should clear filters - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - } - expect(find.byType(MainScreen), findsOneWidget); - }, - ); - - testWidgets('staggered animation renders cards on first load', ( - tester, - ) async { - for (var i = 0; i < 3; i++) { - await repo.save( - ClipboardItem( - content: 'Animated $i', - type: ClipboardContentType.text, - ), - ); - } - await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); - await tester.pumpAndSettle(); - expect(find.byType(ClipboardCard), findsWidgets); - }); - - testWidgets('bug report button in bottom bar is tappable', (tester) async { - await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); - await tester.pumpAndSettle(); - - final bugIcon = find.byIcon(Icons.bug_report_outlined); - if (bugIcon.evaluate().isNotEmpty) { - // Just verify it renders; tapping would open a URL - expect(bugIcon, findsOneWidget); - } - expect(find.byType(MainScreen), findsOneWidget); - }); - - testWidgets('update badge opens dialog and can be dismissed', ( - tester, - ) async { - await tester.pumpWidget( - _buildApp(service: service, onPaste: (_) {}, updateVersion: '2.9.9'), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('v2.9.9 is available, please update')); - await tester.pumpAndSettle(); - - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text('Update Available'), findsOneWidget); - - await tester.tap(find.text('Later')); - await tester.pumpAndSettle(); - - expect(find.byType(AlertDialog), findsNothing); - }); - - testWidgets('_loadItems logs error gracefully when service throws', ( - tester, - ) async { - // Close the repo before creating the service so that every query throws. - // This triggers the catch block in _loadItems, covering - // AppLogger.error('Failed to load items: $e') and - // setState(() => _loading = false). - final closedRepo = SqliteRepository.inMemory(); - await closedRepo.close(); - final failService = ClipboardService(closedRepo); - - await tester.pumpWidget(_buildApp(service: failService, onPaste: (_) {})); - await tester.pumpAndSettle(); - - // No exception propagated — error is handled internally. - expect(find.byType(MainScreen), findsOneWidget); - - await failService.dispose(); - }); - - testWidgets( - 'color filter change via FilterBar menu calls _onColorFilterChanged', - (tester) async { - await repo.save( - ClipboardItem( - content: 'Red item', - type: ClipboardContentType.text, - cardColor: CardColor.red, - ), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp(service: service, onPaste: (_) {}, key: key), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pump(); - - // Open the filter bar popup via Alt+G. - await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyG); - await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); - await tester.pumpAndSettle(); - - // Tap the "Red" color chip inside the popup menu. - final redOption = find.text('Red'); - if (redOption.evaluate().isNotEmpty) { - await tester.tap(redOption.first); - await tester.pumpAndSettle(); - } - - // Screen renders correctly after applying color filter. - expect(find.byType(MainScreen), findsOneWidget); - }, - ); - - testWidgets('resetFiltersOnShow=true resets to all-items view on show', ( - tester, - ) async { - await repo.save( - ClipboardItem(content: 'Item A', type: ClipboardContentType.text), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - resetFiltersOnShow: true, - key: key, - ), - ); - await tester.pumpAndSettle(); - - // Show — should load without crash and render the item. - key.currentState!.onWindowShow(); - await tester.pumpAndSettle(); - - expect(find.byType(MainScreen), findsOneWidget); - expect(find.byType(FilterBar), findsOneWidget); - }); - - testWidgets('resetFiltersOnShow=false keeps current state on show', ( - tester, - ) async { - await repo.save( - ClipboardItem(content: 'Item B', type: ClipboardContentType.text), - ); - - final key = GlobalKey(); - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - resetFiltersOnShow: false, - key: key, - ), - ); - await tester.pumpAndSettle(); - - key.currentState!.onWindowShow(); - await tester.pumpAndSettle(); - - // Screen still renders correctly. - expect(find.byType(MainScreen), findsOneWidget); - }); - - 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/screens/permission_gate_screen_test.dart b/app/test/screens/permission_gate_screen_test.dart deleted file mode 100644 index 46af4e2e..00000000 --- a/app/test/screens/permission_gate_screen_test.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/l10n/app_localizations.dart'; -import 'package:copypaste/screens/permission_gate_screen.dart'; -import 'package:copypaste/theme/compact_theme.dart'; -import 'package:copypaste/theme/theme_provider.dart'; - -void _setMockHandler( - MethodChannel channel, - Future Function(MethodCall) handler, -) { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, handler); -} - -void _clearMockHandler(MethodChannel channel) { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); -} - -Widget _wrap(Widget child) { - return MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: CopyPasteTheme(themeData: CompactTheme(), child: child), - ); -} - -void main() { - const channel = MethodChannel('copypaste/clipboard_writer'); - - setUp(() { - _setMockHandler(channel, (call) async { - switch (call.method) { - case 'requestAccessibility': - return false; - case 'checkAccessibility': - return false; - case 'openAccessibilitySettings': - return null; - default: - return null; - } - }); - }); - - tearDown(() => _clearMockHandler(channel)); - - group('PermissionGateScreen', () { - testWidgets('renders app icon, title, and action buttons', (tester) async { - await tester.pumpWidget( - _wrap(PermissionGateScreen(previouslyGranted: false, onGranted: () {})), - ); - await tester.pump(); - - expect(find.text('CopyPaste'), findsOneWidget); - expect(find.byType(FilledButton), findsOneWidget); - expect(find.byIcon(Icons.lock_outline_rounded), findsOneWidget); - }); - - testWidgets('shows stale icon when previouslyGranted is true', ( - tester, - ) async { - await tester.pumpWidget( - _wrap(PermissionGateScreen(previouslyGranted: true, onGranted: () {})), - ); - await tester.pump(); - - expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); - }); - - testWidgets('open settings button calls openAccessibilitySettings', ( - tester, - ) async { - var opened = false; - - _setMockHandler(channel, (call) async { - if (call.method == 'openAccessibilitySettings') opened = true; - if (call.method == 'checkAccessibility') return false; - return null; - }); - - await tester.pumpWidget( - _wrap(PermissionGateScreen(previouslyGranted: false, onGranted: () {})), - ); - await tester.pump(); - - await tester.tap(find.byType(FilledButton)); - await tester.pump(); - - expect(opened, isTrue); - }); - - testWidgets('poll timer calls onGranted when permission is detected', ( - tester, - ) async { - var checkCount = 0; - var granted = false; - - _setMockHandler(channel, (call) async { - if (call.method == 'checkAccessibility') { - checkCount++; - return checkCount >= 3; - } - return null; - }); - - await tester.pumpWidget( - _wrap( - PermissionGateScreen( - previouslyGranted: false, - onGranted: () => granted = true, - ), - ), - ); - await tester.pump(); - - // Advance past 3 poll ticks (1s each) - for (var i = 0; i < 4; i++) { - await tester.pump(const Duration(seconds: 1)); - } - - expect(granted, isTrue); - expect(checkCount, greaterThanOrEqualTo(3)); - }); - - testWidgets('shows check-again button after timeout', (tester) async { - await tester.pumpWidget( - _wrap(PermissionGateScreen(previouslyGranted: false, onGranted: () {})), - ); - await tester.pump(); - - // Should not have OutlinedButton initially - expect(find.byType(OutlinedButton), findsNothing); - - // Advance past _maxPollsBeforeHint (30s) one tick at a time - for (var i = 0; i < 31; i++) { - await tester.pump(const Duration(seconds: 1)); - } - - expect(find.byType(OutlinedButton), findsOneWidget); - }); - - testWidgets('shows check-again button immediately when stale', ( - tester, - ) async { - await tester.pumpWidget( - _wrap(PermissionGateScreen(previouslyGranted: true, onGranted: () {})), - ); - await tester.pump(); - - expect(find.byType(OutlinedButton), findsOneWidget); - }); - - testWidgets('dispose cancels timer without errors', (tester) async { - await tester.pumpWidget( - _wrap(PermissionGateScreen(previouslyGranted: false, onGranted: () {})), - ); - await tester.pump(); - - await tester.pumpWidget(_wrap(const SizedBox.shrink())); - await tester.pump(); - - expect(find.byType(PermissionGateScreen), findsNothing); - }); - - testWidgets('_manualCheck success calls onGranted', (tester) async { - _setMockHandler(channel, (call) async { - if (call.method == 'requestAccessibility') return true; - if (call.method == 'checkAccessibility') return false; - return null; - }); - - var granted = false; - - await tester.pumpWidget( - _wrap( - PermissionGateScreen( - previouslyGranted: true, - onGranted: () => granted = true, - ), - ), - ); - await tester.pump(); - - expect(find.byType(OutlinedButton), findsOneWidget); - - await tester.tap(find.byType(OutlinedButton)); - await tester.pump(); - await tester.pump(); - - expect(granted, isTrue); - }); - - testWidgets('onRestart button tap calls onRestart callback', ( - tester, - ) async { - var restarted = false; - - await tester.pumpWidget( - _wrap( - PermissionGateScreen( - previouslyGranted: true, - onGranted: () {}, - onRestart: () => restarted = true, - ), - ), - ); - await tester.pump(); - - await tester.tap(find.byType(TextButton)); - await tester.pump(); - - expect(restarted, isTrue); - }); - - testWidgets('shows ... and disables button while checking', (tester) async { - final completer = Completer(); - _setMockHandler(channel, (call) async { - if (call.method == 'requestAccessibility') return completer.future; - if (call.method == 'checkAccessibility') return false; - return null; - }); - - await tester.pumpWidget( - _wrap(PermissionGateScreen(previouslyGranted: true, onGranted: () {})), - ); - await tester.pump(); - - await tester.tap(find.byType(OutlinedButton)); - await tester.pump(); - - expect(find.text('...'), findsOneWidget); - final btn = tester.widget(find.byType(OutlinedButton)); - expect(btn.onPressed, isNull); - - completer.complete(false); - await tester.pump(); - await tester.pump(); - - expect(find.text('...'), findsNothing); - }); - }); -} diff --git a/app/test/screens/settings_screen_test.dart b/app/test/screens/settings_screen_test.dart deleted file mode 100644 index dab9f0d7..00000000 --- a/app/test/screens/settings_screen_test.dart +++ /dev/null @@ -1,229 +0,0 @@ -import 'dart:io'; - -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/settings_screen.dart'; -import 'package:copypaste/theme/compact_theme.dart'; -import 'package:copypaste/theme/theme_provider.dart'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -late StorageConfig _storage; -late SqliteRepository _repo; -late ClipboardService _service; - -Widget _wrap(Widget child) => MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: CopyPasteTheme(themeData: CompactTheme(), child: child), -); - -Future _pump(WidgetTester tester, Widget child) async { - // Use a landscape desktop-ish size so the sidebar + content area both fit. - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.reset); - await tester.pumpWidget(_wrap(child)); - await tester.pump(); -} - -Widget _screen([AppConfig config = const AppConfig()]) => SettingsScreen( - config: config, - configPath: Directory.systemTemp.path, - clipboardService: _service, - storage: _storage, - onSave: (config, changed) async {}, - onSoftReset: () async {}, - onHardReset: () async {}, -); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -void main() { - setUpAll(() async { - _storage = await StorageConfig.create(baseDir: Directory.systemTemp.path); - }); - - setUp(() { - _repo = SqliteRepository.inMemory(); - _service = ClipboardService(_repo); - }); - - tearDown(() async { - await _service.dispose(); - await _repo.close(); - }); - - group('SettingsScreen – smoke', () { - testWidgets('renders sidebar title and all navigation items', ( - tester, - ) async { - await _pump(tester, _screen()); - - expect(find.text('Settings'), findsOneWidget); - expect(find.text('General'), findsOneWidget); - expect(find.text('Shortcuts'), findsOneWidget); - expect(find.text('Performance'), findsOneWidget); - expect(find.text('Cleanup & Privacy'), findsOneWidget); - expect(find.text('Backup & Support'), findsOneWidget); - expect(find.text('About'), findsOneWidget); - }); - - testWidgets('General tab (default) renders without crashing', ( - tester, - ) async { - await _pump(tester, _screen()); - expect(find.byType(SettingsScreen), findsOneWidget); - }); - - testWidgets('Shortcuts tab renders without crashing', (tester) async { - await _pump(tester, _screen()); - await tester.tap(find.text('Shortcuts')); - await tester.pump(); - expect(find.byType(SettingsScreen), findsOneWidget); - expect(find.text('Optional global plain-text paste'), findsOneWidget); - expect(find.textContaining('Disabled'), findsOneWidget); - expect( - find.text( - 'CopyPaste open: Paste the hovered, selected, or first history item as plain text', - ), - findsOneWidget, - ); - expect( - find.textContaining('CopyPaste does not intercept it'), - findsOneWidget, - ); - expect(find.text('Ctrl+Shift+V'), findsNothing); - }); - - testWidgets('enabled plain paste hotkey shows its current binding', ( - tester, - ) async { - await _pump( - tester, - _screen(const AppConfig(plainPasteHotkeyEnabled: true)), - ); - await tester.tap(find.text('Shortcuts')); - await tester.pump(); - - final binding = Platform.isMacOS ? '⌃⌥⇧V' : 'Ctrl + Alt + Shift + V'; - expect(find.textContaining('Current: $binding'), findsOneWidget); - expect( - find.text( - 'CopyPaste global: Paste the current clipboard as plain text', - ), - findsOneWidget, - ); - }); - - testWidgets('duplicate global hotkeys show a conflict warning', ( - tester, - ) async { - await _pump( - tester, - _screen( - const AppConfig( - plainPasteHotkeyEnabled: true, - plainPasteHotkeyUseCtrl: true, - plainPasteHotkeyUseAlt: true, - plainPasteHotkeyUseShift: false, - ), - ), - ); - await tester.tap(find.text('Shortcuts')); - await tester.pump(); - await tester.scrollUntilVisible( - find.text( - 'This combination is already assigned to the other CopyPaste shortcut.', - ), - 100, - scrollable: find.byType(Scrollable).last, - ); - - expect( - find.text( - 'This combination is already assigned to the other CopyPaste shortcut.', - ), - findsOneWidget, - ); - expect(find.text('Restore recommended shortcuts'), findsOneWidget); - }); - - testWidgets('Performance tab renders without crashing', (tester) async { - await _pump(tester, _screen()); - await tester.tap(find.text('Performance')); - await tester.pump(); - expect(find.byType(SettingsScreen), findsOneWidget); - }); - - testWidgets('Performance tab shows localized paste preset dropdown items', ( - tester, - ) async { - await _pump(tester, _screen()); - await tester.tap(find.text('Performance')); - await tester.pump(); - - // Open the DropdownButton to reveal items. - final dropdown = find.byType(DropdownButton); - expect(dropdown, findsOneWidget); - await tester.tap(dropdown); - await tester.pumpAndSettle(); - - // Localized labels should appear (not raw 'Fast'/'Slow' keys). - if (Platform.isWindows) expect(find.text('Instant'), findsWidgets); - expect(find.text('Fast'), findsWidgets); - expect(find.text('Normal'), findsWidgets); - expect(find.text('Safe'), findsWidgets); - expect(find.text('Slow'), findsWidgets); - }); - - testWidgets('Performance tab shows Switch to All on open toggle', ( - tester, - ) async { - await _pump(tester, _screen()); - await tester.tap(find.text('Performance')); - await tester.pumpAndSettle(); - - await tester.scrollUntilVisible( - find.text('Switch to All on open'), - 100, - scrollable: find.byType(Scrollable).last, - ); - - expect(find.text('Switch to All on open'), findsOneWidget); - }); - - testWidgets('Cleanup & Privacy tab renders without crashing', ( - tester, - ) async { - await _pump(tester, _screen()); - await tester.tap(find.text('Cleanup & Privacy')); - await tester.pump(); - expect(find.byType(SettingsScreen), findsOneWidget); - }); - - testWidgets('Backup & Support tab renders without crashing', ( - tester, - ) async { - await _pump(tester, _screen()); - await tester.tap(find.text('Backup & Support')); - await tester.pump(); - expect(find.byType(SettingsScreen), findsOneWidget); - }); - - testWidgets('About tab renders without crashing', (tester) async { - await _pump(tester, _screen()); - await tester.tap(find.text('About')); - await tester.pump(); - expect(find.byType(SettingsScreen), findsOneWidget); - }); - }); -} diff --git a/app/test/services/install_channel_test.dart b/app/test/services/install_channel_test.dart deleted file mode 100644 index 0b692e48..00000000 --- a/app/test/services/install_channel_test.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:copypaste/services/install_channel.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('InstallChannelDetector.detect', () { - test('detects homebrew on macOS Cellar paths', () { - final c = InstallChannelDetector.detect( - execPathOverride: '/opt/homebrew/Cellar/copypaste/2.3.0/bin/copypaste', - platformOverride: HostPlatform.macos, - ); - expect(c, InstallChannel.homebrew); - }); - - test('detects scoop installs on the default root', () { - final c = InstallChannelDetector.detect( - execPathOverride: - r'C:\Users\dev\scoop\apps\copypaste\current\CopyPaste.exe', - platformOverride: HostPlatform.windows, - ); - expect(c, InstallChannel.scoop); - }); - - test('detects scoop installs on a relocated root', () { - final c = InstallChannelDetector.detect( - execPathOverride: r'D:\tools\apps\copypaste-beta\2.9.0\CopyPaste.exe', - platformOverride: HostPlatform.windows, - ); - expect(c, InstallChannel.scoop); - }); - - test('a standalone install is still githubWindows', () { - final c = InstallChannelDetector.detect( - execPathOverride: r'C:\Users\dev\AppData\Local\CopyPaste\CopyPaste.exe', - platformOverride: HostPlatform.windows, - ); - expect(c, InstallChannel.githubWindows); - }); - }); - - group('manifestKey', () { - test('maps every channel to a non-empty key', () { - for (final c in InstallChannel.values) { - expect(InstallChannelDetector.manifestKey(c), isNotEmpty); - } - }); - }); -} diff --git a/app/test/services/manifest_signature_test.dart b/app/test/services/manifest_signature_test.dart deleted file mode 100644 index ce8e7824..00000000 --- a/app/test/services/manifest_signature_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:convert'; - -import 'package:copypaste/services/manifest_signature.dart'; -import 'package:cryptography/cryptography.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('ManifestSignature', () { - late SimpleKeyPair keyPair; - late SimplePublicKey publicKey; - late List publicKeyBytes; - - setUp(() async { - keyPair = await Ed25519().newKeyPair(); - publicKey = await keyPair.extractPublicKey(); - publicKeyBytes = publicKey.bytes; - ManifestSignature.reset(); - ManifestSignature.overridePublicKey(publicKeyBytes); - }); - - tearDown(ManifestSignature.reset); - - test('verifies a valid signature', () async { - final body = utf8.encode('{"hello":"world"}'); - final sig = await Ed25519().sign(body, keyPair: keyPair); - final ok = await ManifestSignature.verify(body, base64Encode(sig.bytes)); - expect(ok, isTrue); - }); - - test('rejects a tampered payload', () async { - final body = utf8.encode('{"hello":"world"}'); - final sig = await Ed25519().sign(body, keyPair: keyPair); - final tampered = utf8.encode('{"hello":"WORLD"}'); - final ok = await ManifestSignature.verify( - tampered, - base64Encode(sig.bytes), - ); - expect(ok, isFalse); - }); - - test('rejects a malformed signature string', () async { - final body = utf8.encode('payload'); - final ok = await ManifestSignature.verify(body, '!!!not-base64!!!'); - expect(ok, isFalse); - }); - - test('rejects a signature from a different key', () async { - final otherKey = await Ed25519().newKeyPair(); - final body = utf8.encode('payload'); - final sig = await Ed25519().sign(body, keyPair: otherKey); - final ok = await ManifestSignature.verify(body, base64Encode(sig.bytes)); - expect(ok, isFalse); - }); - }); -} diff --git a/app/test/services/release_manifest_service_test.dart b/app/test/services/release_manifest_service_test.dart deleted file mode 100644 index c9cd5da8..00000000 --- a/app/test/services/release_manifest_service_test.dart +++ /dev/null @@ -1,528 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:copypaste/services/release_manifest_service.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('ReleaseManifest.tryParse', () { - test('parses a minimal valid manifest', () { - final m = ReleaseManifest.tryParse(''' - { - "schema": 1, - "latest": "2.3.0", - "minimumSupported": "2.3.0", - "blockedVersions": ["2.2.6"], - "severity": "critical", - "channels": { - "github_windows": { "url": "https://example.com/x" } - }, - "releaseNotes": { - "en": { "summary": "Hello" } - } - } - '''); - expect(m, isNotNull); - expect(m!.latest, '2.3.0'); - expect(m.severity, ManifestSeverity.critical); - expect(m.blockedVersions, contains('2.2.6')); - expect(m.channels.containsKey('github_windows'), isTrue); - expect(m.notesFor('en')?.summary, 'Hello'); - }); - - test('rejects unknown schema', () { - final m = ReleaseManifest.tryParse( - '{"schema": 2, "latest":"1.0.0", "minimumSupported":"1.0.0"}', - ); - expect(m, isNull); - }); - - test('rejects invalid semver', () { - final m = ReleaseManifest.tryParse( - '{"schema": 1, "latest":"banana", "minimumSupported":"1.0.0"}', - ); - expect(m, isNull); - }); - - test('strips channel entries with non-https URLs', () { - final m = ReleaseManifest.tryParse(''' - { - "schema": 1, - "latest": "1.0.0", - "minimumSupported": "1.0.0", - "channels": { - "github_windows": { "url": "http://insecure.example/x" }, - "scoop": { "command": "scoop update copypaste" } - } - } - '''); - expect(m, isNotNull); - expect(m!.channels.containsKey('github_windows'), isFalse); - expect(m.channels['scoop']?.command, 'scoop update copypaste'); - }); - }); - - group('compareVersions', () { - test('orders patch versions', () { - expect( - ReleaseManifestService.compareVersions('2.3.0', '2.3.1'), - lessThan(0), - ); - expect( - ReleaseManifestService.compareVersions('2.3.1', '2.3.0'), - greaterThan(0), - ); - expect(ReleaseManifestService.compareVersions('2.3.0', '2.3.0'), 0); - }); - - test('ranks pre-release lower than the same base', () { - expect( - ReleaseManifestService.compareVersions('2.3.0-rc1', '2.3.0'), - lessThan(0), - ); - }); - }); - - group('isBlocked', () { - ManifestState stateFor({ - required String latest, - required String minimumSupported, - List blocked = const [], - ManifestSeverity severity = ManifestSeverity.patch, - bool expired = false, - }) { - return ManifestState( - manifest: ReleaseManifest( - schema: 1, - latest: latest, - minimumSupported: minimumSupported, - blockedVersions: blocked, - channels: const {}, - notes: const {}, - severity: severity, - ), - fetchedAt: DateTime.now().toUtc(), - expired: expired, - ); - } - - test('blocks when current is in blockedVersions', () { - expect( - ReleaseManifestService.isBlocked( - current: '2.2.6', - state: stateFor( - latest: '2.3.0', - minimumSupported: '2.3.0', - blocked: ['2.2.6'], - ), - ), - isTrue, - ); - }); - - test('blocks when current < minimumSupported and severity=critical', () { - expect( - ReleaseManifestService.isBlocked( - current: '2.2.0', - state: stateFor( - latest: '2.3.0', - minimumSupported: '2.3.0', - severity: ManifestSeverity.critical, - ), - ), - isTrue, - ); - }); - - test('does not block when severity is not critical', () { - expect( - ReleaseManifestService.isBlocked( - current: '2.2.0', - state: stateFor( - latest: '2.3.0', - minimumSupported: '2.3.0', - severity: ManifestSeverity.major, - ), - ), - isFalse, - ); - }); - - test('never blocks when the cache is expired', () { - expect( - ReleaseManifestService.isBlocked( - current: '2.2.6', - state: stateFor( - latest: '2.3.0', - minimumSupported: '2.3.0', - blocked: ['2.2.6'], - severity: ManifestSeverity.critical, - expired: true, - ), - ), - isFalse, - ); - }); - - test('returns false when there is no manifest at all', () { - expect( - ReleaseManifestService.isBlocked(current: '2.2.6', state: null), - isFalse, - ); - }); - }); - - group('badgeSeverity', () { - test('returns null when current is up to date', () { - final s = ManifestState( - manifest: ReleaseManifest( - schema: 1, - latest: '2.3.0', - minimumSupported: '2.0.0', - blockedVersions: const [], - channels: const {}, - notes: const {}, - severity: ManifestSeverity.patch, - ), - fetchedAt: DateTime.now().toUtc(), - expired: false, - ); - expect( - ReleaseManifestService.badgeSeverity(current: '2.3.0', state: s), - isNull, - ); - }); - - test('returns severity when current is older than latest', () { - final s = ManifestState( - manifest: ReleaseManifest( - schema: 1, - latest: '2.3.0', - minimumSupported: '2.0.0', - blockedVersions: const [], - channels: const {}, - notes: const {}, - severity: ManifestSeverity.minor, - ), - fetchedAt: DateTime.now().toUtc(), - expired: false, - ); - expect( - ReleaseManifestService.badgeSeverity(current: '2.2.0', state: s), - ManifestSeverity.minor, - ); - }); - }); - - group('ReleaseManifest.tryParse — edge cases', () { - test('returns null for non-JSON input', () { - expect(ReleaseManifest.tryParse('not json'), isNull); - }); - - test('returns null when root is not a Map', () { - expect(ReleaseManifest.tryParse('["array"]'), isNull); - }); - - test('returns null when minimumSupported is invalid semver', () { - final m = ReleaseManifest.tryParse( - '{"schema":1,"latest":"1.0.0","minimumSupported":"bad"}', - ); - expect(m, isNull); - }); - - test('skips blockedVersions entries that are not valid semver', () { - final m = ReleaseManifest.tryParse(''' - { - "schema": 1, "latest": "1.0.0", "minimumSupported": "1.0.0", - "blockedVersions": ["bad", "1.0.1", null] - } - '''); - expect(m, isNotNull); - expect(m!.blockedVersions, ['1.0.1']); - }); - - test('skips channel entries with null info', () { - final m = ReleaseManifest.tryParse(''' - { - "schema": 1, "latest": "1.0.0", "minimumSupported": "1.0.0", - "channels": { "bad": null, "ok": { "command": "brew upgrade x" } } - } - '''); - expect(m, isNotNull); - expect(m!.channels.containsKey('bad'), isFalse); - expect(m.channels['ok']?.command, 'brew upgrade x'); - }); - - test('parses all severity values', () { - for (final pair in [ - ('patch', ManifestSeverity.patch), - ('minor', ManifestSeverity.minor), - ('major', ManifestSeverity.major), - ('critical', ManifestSeverity.critical), - ('unknown_value', ManifestSeverity.patch), - ]) { - final m = ReleaseManifest.tryParse( - '{"schema":1,"latest":"1.0.0","minimumSupported":"1.0.0","severity":"${pair.$1}"}', - ); - expect(m?.severity, pair.$2, reason: 'severity=${pair.$1}'); - } - }); - - test('ms-windows-store URL is accepted', () { - final m = ReleaseManifest.tryParse(''' - { - "schema": 1, "latest": "1.0.0", "minimumSupported": "1.0.0", - "channels": { - "msstore": { "url": "ms-windows-store://pdp/?productid=XXXXX" } - } - } - '''); - expect(m, isNotNull); - expect(m!.channels['msstore']?.url, contains('ms-windows-store://')); - }); - - test('notesFor falls back to en when locale not present', () { - final m = ReleaseManifest.tryParse(''' - { - "schema": 1, "latest": "1.0.0", "minimumSupported": "1.0.0", - "releaseNotes": { "en": { "summary": "English note" } } - } - ''')!; - expect(m.notesFor('es')?.summary, 'English note'); - expect(m.notesFor('fr')?.summary, 'English note'); - }); - - test('notesFor returns null when notes is empty', () { - final m = ReleaseManifest( - schema: 1, - latest: '1.0.0', - minimumSupported: '1.0.0', - blockedVersions: const [], - channels: const {}, - notes: const {}, - severity: ManifestSeverity.patch, - ); - expect(m.notesFor('en'), isNull); - }); - - test('notesFor matches partial locale (es_CL -> es)', () { - final m = ReleaseManifest.tryParse(''' - { - "schema": 1, "latest": "1.0.0", "minimumSupported": "1.0.0", - "releaseNotes": { "es": { "summary": "Nota en español" } } - } - ''')!; - expect(m.notesFor('es_CL')?.summary, 'Nota en español'); - }); - - test('releaseNotes entries without summary are skipped', () { - final m = ReleaseManifest.tryParse(''' - { - "schema": 1, "latest": "1.0.0", "minimumSupported": "1.0.0", - "releaseNotes": { - "en": { "no_summary": "oops" }, - "es": { "summary": "Hola" } - } - } - ''')!; - expect(m.notesFor('en')?.summary, 'Hola'); - }); - }); - - group('compareVersions — extra cases', () { - test('major version wins', () { - expect( - ReleaseManifestService.compareVersions('3.0.0', '2.9.9'), - greaterThan(0), - ); - }); - test('minor version wins', () { - expect( - ReleaseManifestService.compareVersions('2.1.0', '2.0.9'), - greaterThan(0), - ); - }); - test('two pre-releases compare equal', () { - expect( - ReleaseManifestService.compareVersions('1.0.0-rc1', '1.0.0-rc2'), - 0, - ); - }); - test('pre-release is older than release', () { - expect( - ReleaseManifestService.compareVersions('1.0.0', '1.0.0-rc1'), - greaterThan(0), - ); - }); - }); - - group('isBlocked — extra cases', () { - ManifestState makeState({ - String latest = '2.3.0', - String minimumSupported = '2.3.0', - List blocked = const [], - ManifestSeverity severity = ManifestSeverity.patch, - bool expired = false, - }) => ManifestState( - manifest: ReleaseManifest( - schema: 1, - latest: latest, - minimumSupported: minimumSupported, - blockedVersions: blocked, - channels: const {}, - notes: const {}, - severity: severity, - ), - fetchedAt: DateTime.now().toUtc(), - expired: expired, - ); - - test('does not block when current >= minimumSupported', () { - expect( - ReleaseManifestService.isBlocked( - current: '2.3.0', - state: makeState(severity: ManifestSeverity.critical), - ), - isFalse, - ); - }); - - test('does not block for major severity below minimum', () { - expect( - ReleaseManifestService.isBlocked( - current: '2.2.0', - state: makeState(severity: ManifestSeverity.major), - ), - isFalse, - ); - }); - }); - - group('cache read/write', () { - late Directory tmpDir; - - setUp(() async { - tmpDir = await Directory.systemTemp.createTemp('manifest_test_'); - await ReleaseManifestService.reset(); - ReleaseManifestService.cacheDirOverride = tmpDir.path; - }); - - tearDown(() async { - await ReleaseManifestService.reset(); - await tmpDir.delete(recursive: true); - }); - - ReleaseManifest makeManifest() => ReleaseManifest( - schema: 1, - latest: '2.3.0', - minimumSupported: '2.3.0', - blockedVersions: const ['2.2.6'], - channels: const {}, - notes: const {}, - severity: ManifestSeverity.critical, - ); - - test('initialize reads cached manifest and emits it on stream', () async { - final m = makeManifest(); - final json = jsonEncode({ - 'schema': m.schema, - 'latest': m.latest, - 'minimumSupported': m.minimumSupported, - 'blockedVersions': m.blockedVersions, - 'channels': {}, - 'releaseNotes': {}, - 'severity': 'critical', - }); - final cacheFile = File('${tmpDir.path}/release_manifest.json'); - final metaFile = File('${tmpDir.path}/release_manifest.meta'); - await cacheFile.writeAsString(json); - await metaFile.writeAsString( - jsonEncode({'fetchedAt': DateTime.now().toUtc().toIso8601String()}), - ); - - ReleaseManifestService.manifestUrlOverride = 'https://example.com/fail'; - ReleaseManifestService.signatureUrlOverride = - 'https://example.com/fail.sig'; - - final emitted = []; - // Waiting a fixed slice raced the failing fetch under a loaded suite. - final firstEmit = Completer(); - final sub = ReleaseManifestService.stream.listen((state) { - emitted.add(state); - if (!firstEmit.isCompleted) firstEmit.complete(); - }); - - await ReleaseManifestService.initialize(storageConfigDir: tmpDir.path); - await firstEmit.future.timeout(const Duration(seconds: 10)); - - unawaited(sub.cancel()); - - expect(emitted, isNotEmpty); - expect(emitted.first?.manifest.latest, '2.3.0'); - expect(emitted.first?.expired, isFalse); - }); - - test('expired flag is set when cache is older than 15 days', () async { - final m = makeManifest(); - final json = jsonEncode({ - 'schema': m.schema, - 'latest': m.latest, - 'minimumSupported': m.minimumSupported, - 'blockedVersions': m.blockedVersions, - 'channels': {}, - 'releaseNotes': {}, - 'severity': 'critical', - }); - final cacheFile = File('${tmpDir.path}/release_manifest.json'); - final metaFile = File('${tmpDir.path}/release_manifest.meta'); - await cacheFile.writeAsString(json); - final old = DateTime.now().toUtc().subtract(const Duration(days: 20)); - await metaFile.writeAsString( - jsonEncode({'fetchedAt': old.toIso8601String()}), - ); - - ReleaseManifestService.manifestUrlOverride = 'https://example.com/fail'; - ReleaseManifestService.signatureUrlOverride = - 'https://example.com/fail.sig'; - - final emitted = []; - final sub = ReleaseManifestService.stream.listen(emitted.add); - - await ReleaseManifestService.initialize(storageConfigDir: tmpDir.path); - await Future.delayed(const Duration(milliseconds: 100)); - - unawaited(sub.cancel()); - - expect(emitted.first?.expired, isTrue); - expect( - ReleaseManifestService.isBlocked( - current: '2.2.6', - state: emitted.first, - ), - isFalse, - ); - }); - - test('returns null from cache when meta fetchedAt is missing', () async { - await File('${tmpDir.path}/release_manifest.json').writeAsString( - '{"schema":1,"latest":"1.0.0","minimumSupported":"1.0.0"}', - ); - await File( - '${tmpDir.path}/release_manifest.meta', - ).writeAsString('{"no_date": true}'); - - ReleaseManifestService.manifestUrlOverride = 'https://example.com/fail'; - ReleaseManifestService.signatureUrlOverride = - 'https://example.com/fail.sig'; - - final emitted = []; - final sub = ReleaseManifestService.stream.listen(emitted.add); - await ReleaseManifestService.initialize(storageConfigDir: tmpDir.path); - await Future.delayed(const Duration(milliseconds: 100)); - unawaited(sub.cancel()); - - expect(emitted, isEmpty); - }); - }); -} diff --git a/app/test/shell/app_window_range_test.dart b/app/test/shell/app_window_range_test.dart deleted file mode 100644 index 075f3ce2..00000000 --- a/app/test/shell/app_window_range_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/app_window.dart'; - -void main() { - group('AppWindow.isPositionInSaneRange', () { - test('origin (0, 0) is valid', () { - expect(AppWindow.isPositionInSaneRange(0, 0), isTrue); - }); - - test('(-9999, -9999) is within range', () { - expect(AppWindow.isPositionInSaneRange(-9999, -9999), isTrue); - }); - - test('x below -10000 is out of range', () { - expect(AppWindow.isPositionInSaneRange(-10001, 0), isFalse); - }); - - test('y below -10000 is out of range', () { - expect(AppWindow.isPositionInSaneRange(0, -10001), isFalse); - }); - - test('x above 50000 is out of range', () { - expect(AppWindow.isPositionInSaneRange(50001, 0), isFalse); - }); - - test('y above 30000 is out of range', () { - expect(AppWindow.isPositionInSaneRange(0, 30001), isFalse); - }); - - test('NaN x is invalid', () { - expect(AppWindow.isPositionInSaneRange(double.nan, 0), isFalse); - }); - - test('NaN y is invalid', () { - expect(AppWindow.isPositionInSaneRange(0, double.nan), isFalse); - }); - - test('infinite x is invalid', () { - expect(AppWindow.isPositionInSaneRange(double.infinity, 0), isFalse); - }); - - test('(-32000, -32000) is out of range (minimized Windows position)', () { - expect(AppWindow.isPositionInSaneRange(-32000, -32000), isFalse); - }); - - test('(1920, 1080) is valid (typical secondary monitor)', () { - expect(AppWindow.isPositionInSaneRange(1920, 1080), isTrue); - }); - - test('(3840, 0) is valid (4K secondary monitor to the right)', () { - expect(AppWindow.isPositionInSaneRange(3840, 0), isTrue); - }); - }); -} diff --git a/app/test/shell/app_window_test.dart b/app/test/shell/app_window_test.dart deleted file mode 100644 index 97c2c0f5..00000000 --- a/app/test/shell/app_window_test.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/app_window.dart'; - -// --------------------------------------------------------------------------- -// Mock for window_manager and screen_retriever MethodChannels -// --------------------------------------------------------------------------- - -const _wmChannel = MethodChannel('window_manager'); -const _screenRetrieverChannel = MethodChannel( - 'dev.leanflutter.plugins/screen_retriever', -); - -void _setupWindowManagerMock({required List calls}) { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_wmChannel, (call) async { - calls.add(call); - switch (call.method) { - case 'getBounds': - return {'x': 100.0, 'y': 100.0, 'width': 368.0, 'height': 500.0}; - case 'getSize': - return {'width': 368.0, 'height': 500.0}; - case 'getPosition': - return {'x': 100.0, 'y': 100.0}; - case 'isMinimized': - case 'isMaximized': - case 'isFullScreen': - case 'isVisible': - return false; - default: - return null; - } - }); - - // screen_retriever is used by _positionNearCursorNative - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_screenRetrieverChannel, (call) async { - switch (call.method) { - // getCursorScreenPoint returns {dx, dy} per _OffsetConverter - case 'getCursorScreenPoint': - return {'dx': 200.0, 'dy': 200.0}; - // Display JSON: id + size {width,height} + visiblePosition {dx,dy} - // visiblePosition is null-checked by calc_window_position.dart - case 'getPrimaryDisplay': - return { - 'id': 'screen1', - 'size': {'width': 1920.0, 'height': 1080.0}, - 'visiblePosition': {'dx': 0.0, 'dy': 0.0}, - 'visibleSize': {'width': 1920.0, 'height': 1040.0}, - 'scaleFactor': 1.0, - }; - case 'getAllDisplays': - return { - 'displays': [ - { - 'id': 'screen1', - 'size': {'width': 1920.0, 'height': 1080.0}, - 'visiblePosition': {'dx': 0.0, 'dy': 0.0}, - 'visibleSize': {'width': 1920.0, 'height': 1040.0}, - 'scaleFactor': 1.0, - }, - ], - }; - default: - return null; - } - }); -} - -void _teardownWindowManagerMock() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_wmChannel, null); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_screenRetrieverChannel, null); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('AppWindow.show()', () { - late List calls; - - setUp(() { - calls = []; - _setupWindowManagerMock(calls: calls); - }); - - tearDown(_teardownWindowManagerMock); - - test('isVisible becomes true after show()', () async { - if (Platform.isWindows) return; - - final window = AppWindow(popupWidth: 368, popupHeight: 500); - expect(window.isVisible, isFalse); - await window.show(); - expect(window.isVisible, isTrue); - }); - - test('isVisible becomes false after hide()', () async { - if (Platform.isWindows) return; - - final window = AppWindow(popupWidth: 368, popupHeight: 500); - await window.show(); - expect(window.isVisible, isTrue); - await window.hide(); - expect(window.isVisible, isFalse); - }); - - test('hide() is no-op when already hidden', () async { - if (Platform.isWindows) return; - - final window = AppWindow(popupWidth: 368, popupHeight: 500); - // Not yet shown — hiding should do nothing. - final callCountBefore = calls.length; - await window.hide(); - expect( - calls.length, - equals(callCountBefore), - reason: 'hide() should be a no-op when already hidden', - ); - }); - - test('toggle() shows when hidden and hides when visible', () async { - if (Platform.isWindows) return; - - final window = AppWindow(popupWidth: 368, popupHeight: 500); - expect(window.isVisible, isFalse); - - await window.toggle(); // hidden → visible - expect(window.isVisible, isTrue); - - await window.toggle(); // visible → hidden - expect(window.isVisible, isFalse); - }); - - test('onVisibilityChanged callback fires on show and hide', () async { - if (Platform.isWindows) return; - - final events = []; - final window = AppWindow( - onVisibilityChanged: events.add, - popupWidth: 368, - popupHeight: 500, - ); - - await window.show(); - await window.hide(); - - expect(events, equals([true, false])); - }); - }); - - group('AppWindow.updatePopupSize', () { - test('updates width and height', () { - final window = AppWindow(popupWidth: 360, popupHeight: 500); - window.updatePopupSize(400, 600); - // No direct getters, but we can verify it doesn't throw. - }); - }); -} diff --git a/app/test/shell/desktop_notifier_test.dart b/app/test/shell/desktop_notifier_test.dart deleted file mode 100644 index 68833a68..00000000 --- a/app/test/shell/desktop_notifier_test.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/desktop_notifier.dart'; - -void main() { - 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 – unsupported hosts', () { - test( - 'returns false when the platform has no notification channel', - () async { - if (Platform.isWindows) return; - final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); - expect(result, isFalse); - }, - ); - }); -} diff --git a/app/test/shell/focus_manager_test.dart b/app/test/shell/focus_manager_test.dart deleted file mode 100644 index 5154aecd..00000000 --- a/app/test/shell/focus_manager_test.dart +++ /dev/null @@ -1,246 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/focus_manager.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const channel = MethodChannel('copypaste/clipboard_writer'); - - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - switch (call.method) { - case 'captureFrontmostApp': - return 'com.apple.finder'; - case 'activateAndPaste': - return true; - default: - return null; - } - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - group('WindowFocusManager – macOS', () { - test('capturePreviousWindow calls captureFrontmostApp', () async { - if (!Platform.isMacOS) return; - - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - if (call.method == 'captureFrontmostApp') return 'com.test.app'; - return null; - }); - - final manager = WindowFocusManager(); - await manager.capturePreviousWindow(); - - expect(captured, isNotNull); - expect(captured!.method, equals('captureFrontmostApp')); - }); - - test( - 'restoreAndPaste returns early when no bundle id was captured', - () async { - if (!Platform.isMacOS) return; - - bool activateCalled = false; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'activateAndPaste') activateCalled = true; - return true; - }); - - final manager = WindowFocusManager(); - // capturePreviousWindow NOT called → _previousBundleId is null - await manager.restoreAndPaste( - delayBeforeFocusMs: 0, - maxFocusVerifyAttempts: 1, - delayBeforePasteMs: 0, - ); - - expect(activateCalled, isFalse); - }, - ); - - test( - 'restoreAndPaste returns early when captureFrontmostApp returned null', - () async { - if (!Platform.isMacOS) return; - - bool activateCalled = false; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'captureFrontmostApp') return null; - if (call.method == 'activateAndPaste') activateCalled = true; - return null; - }); - - final manager = WindowFocusManager(); - await manager.capturePreviousWindow(); - await manager.restoreAndPaste( - delayBeforeFocusMs: 0, - maxFocusVerifyAttempts: 1, - delayBeforePasteMs: 0, - ); - - expect(activateCalled, isFalse); - }, - ); - - test( - 'restoreAndPaste calls activateAndPaste with correct arguments', - () async { - if (!Platform.isMacOS) return; - - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - if (call.method == 'captureFrontmostApp') { - return 'com.apple.safari'; - } - if (call.method == 'activateAndPaste') return true; - return null; - }); - - final manager = WindowFocusManager(); - await manager.capturePreviousWindow(); - await manager.restoreAndPaste( - delayBeforeFocusMs: 0, - maxFocusVerifyAttempts: 1, - delayBeforePasteMs: 250, - ); - - final pasteCall = calls.firstWhere( - (c) => c.method == 'activateAndPaste', - ); - expect(pasteCall.arguments['bundleId'], equals('com.apple.safari')); - expect(pasteCall.arguments['delayMs'], equals(250)); - }, - ); - - test( - 'clear() resets bundle id so restoreAndPaste becomes a no-op', - () async { - if (!Platform.isMacOS) return; - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'captureFrontmostApp') { - return 'com.apple.finder'; - } - return null; - }); - - final manager = WindowFocusManager(); - await manager.capturePreviousWindow(); - manager.clear(); - - bool activateCalled = false; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'activateAndPaste') activateCalled = true; - return true; - }); - - await manager.restoreAndPaste( - delayBeforeFocusMs: 0, - maxFocusVerifyAttempts: 1, - delayBeforePasteMs: 0, - ); - - expect(activateCalled, isFalse); - }, - ); - - test( - 'multiple capturePreviousWindow calls keep the last bundle id', - () async { - if (!Platform.isMacOS) return; - - int callCount = 0; - final bundleIds = ['com.first.app', 'com.second.app']; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'captureFrontmostApp') { - return bundleIds[callCount++]; - } - if (call.method == 'activateAndPaste') return true; - return null; - }); - - final manager = WindowFocusManager(); - await manager.capturePreviousWindow(); // stores com.first.app - await manager.capturePreviousWindow(); // stores com.second.app - - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - if (call.method == 'activateAndPaste') return true; - return null; - }); - - await manager.restoreAndPaste( - delayBeforeFocusMs: 0, - maxFocusVerifyAttempts: 1, - delayBeforePasteMs: 0, - ); - - final pasteCall = calls.firstWhere( - (c) => c.method == 'activateAndPaste', - ); - expect(pasteCall.arguments['bundleId'], equals('com.second.app')); - }, - ); - - test( - 'restoreAndPaste propagates ACCESSIBILITY_DENIED PlatformException', - () async { - if (!Platform.isMacOS) return; - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'captureFrontmostApp') { - return 'com.apple.safari'; - } - if (call.method == 'activateAndPaste') { - throw PlatformException( - code: 'ACCESSIBILITY_DENIED', - message: 'Accessibility permission not granted', - ); - } - return null; - }); - - final manager = WindowFocusManager(); - await manager.capturePreviousWindow(); - - expect( - () => manager.restoreAndPaste( - delayBeforeFocusMs: 0, - maxFocusVerifyAttempts: 1, - delayBeforePasteMs: 0, - ), - throwsA( - isA().having( - (e) => e.code, - 'code', - equals('ACCESSIBILITY_DENIED'), - ), - ), - ); - }, - ); - }, skip: !Platform.isMacOS ? 'macOS-only' : null); -} diff --git a/app/test/shell/hotkey_binding_test.dart b/app/test/shell/hotkey_binding_test.dart deleted file mode 100644 index e2f6ac98..00000000 --- a/app/test/shell/hotkey_binding_test.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/hotkey_binding.dart'; - -HotkeyBinding _binding({ - int virtualKey = 0x56, - String keyName = 'V', - bool useCtrl = false, - bool useWin = false, - bool useAlt = false, - bool useShift = false, -}) => HotkeyBinding( - virtualKey: virtualKey, - keyName: keyName, - useCtrl: useCtrl, - useWin: useWin, - useAlt: useAlt, - useShift: useShift, -); - -void main() { - group('HotkeyBinding.label', () { - test('macOS order is Control, Option, Shift, Command, key', () { - final label = _binding( - useCtrl: true, - useWin: true, - useAlt: true, - useShift: true, - ).label(isMac: true); - expect(label, equals('Control+Option+Shift+Command+V')); - }); - - test('desktop order is Ctrl, Win, Alt, Shift, key', () { - if (Platform.isMacOS) return; - final label = _binding( - useCtrl: true, - useWin: true, - useAlt: true, - useShift: true, - ).label(); - expect(label, equals('Ctrl+Win+Alt+Shift+V')); - }); - - test('omits unset modifiers', () { - if (Platform.isMacOS) return; - expect( - _binding(useCtrl: true, useAlt: true).label(), - equals('Ctrl+Alt+V'), - ); - expect(_binding(useWin: true).label(), equals('Win+V')); - expect(_binding(keyName: 'C').label(), equals('C')); - }); - - test('meta renders as Command on macOS and Win elsewhere', () { - expect(_binding(useWin: true).label(isMac: true), equals('Command+V')); - if (!Platform.isMacOS) { - expect(_binding(useWin: true).label(), equals('Win+V')); - } - }); - }); - - group('HotkeyBinding equality', () { - test('identical field sets are equal and share a hashCode', () { - final a = _binding(useCtrl: true, useShift: true); - final b = _binding(useCtrl: true, useShift: true); - expect(a, equals(b)); - expect(a.hashCode, equals(b.hashCode)); - expect(identical(a, a) && a == a, isTrue); - }); - - test('any differing field breaks equality', () { - final base = _binding(useCtrl: true); - expect(base, isNot(equals(_binding(useCtrl: true, virtualKey: 0x43)))); - expect(base, isNot(equals(_binding(useCtrl: true, keyName: 'C')))); - expect(base, isNot(equals(_binding()))); - expect(base, isNot(equals(_binding(useCtrl: true, useWin: true)))); - expect(base, isNot(equals(_binding(useCtrl: true, useAlt: true)))); - expect(base, isNot(equals(_binding(useCtrl: true, useShift: true)))); - }); - - test('is not equal to a different type', () { - expect(_binding(), isNot(equals('V'))); - }); - }); - - group('HotkeyRegistrationResult', () { - test('carries the requested binding and an optional effective one', () { - final requested = _binding(useWin: true); - final effective = _binding(useCtrl: true); - const failed = HotkeyRegistrationStatus.failed; - - final result = HotkeyRegistrationResult( - status: failed, - requestedBinding: requested, - ); - expect(result.status, equals(failed)); - expect(result.requestedBinding, equals(requested)); - expect(result.effectiveBinding, isNull); - - final fallback = HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.fallbackRegistered, - requestedBinding: requested, - effectiveBinding: effective, - ); - expect(fallback.effectiveBinding, equals(effective)); - }); - }); -} diff --git a/app/test/shell/msix_startup_task_test.dart b/app/test/shell/msix_startup_task_test.dart deleted file mode 100644 index 562598ca..00000000 --- a/app/test/shell/msix_startup_task_test.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/msix_startup_task.dart'; - -const _channel = MethodChannel('copypaste/startup_task'); - -void _setHandler(Future Function(MethodCall) handler) { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_channel, handler); -} - -void _clearHandler() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_channel, null); -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - tearDown(_clearHandler); - - group('MsixStartupTask.getState', () { - test('returns enabled when channel replies "enabled"', () async { - _setHandler((_) async => 'enabled'); - final state = await MsixStartupTask.getState('TestTaskId'); - expect(state, MsixStartupTaskState.enabled); - }); - - test('returns disabled when channel replies "disabled"', () async { - _setHandler((_) async => 'disabled'); - final state = await MsixStartupTask.getState('TestTaskId'); - expect(state, MsixStartupTaskState.disabled); - }); - - test( - 'returns disabledByUser when channel replies "disabledByUser"', - () async { - _setHandler((_) async => 'disabledByUser'); - final state = await MsixStartupTask.getState('TestTaskId'); - expect(state, MsixStartupTaskState.disabledByUser); - }, - ); - - test( - 'returns disabledByPolicy when channel replies "disabledByPolicy"', - () async { - _setHandler((_) async => 'disabledByPolicy'); - final state = await MsixStartupTask.getState('TestTaskId'); - expect(state, MsixStartupTaskState.disabledByPolicy); - }, - ); - - test( - 'returns enabledByPolicy when channel replies "enabledByPolicy"', - () async { - _setHandler((_) async => 'enabledByPolicy'); - final state = await MsixStartupTask.getState('TestTaskId'); - expect(state, MsixStartupTaskState.enabledByPolicy); - }, - ); - - test('returns unknown for unrecognised reply', () async { - _setHandler((_) async => 'someFutureState'); - final state = await MsixStartupTask.getState('TestTaskId'); - expect(state, MsixStartupTaskState.unknown); - }); - - test('passes the taskId as argument', () async { - MethodCall? captured; - _setHandler((call) async { - captured = call; - return 'enabled'; - }); - await MsixStartupTask.getState('CopyPasteStartup'); - expect((captured!.arguments as Map)['taskId'], 'CopyPasteStartup'); - }); - - test('returns null on PlatformException', () async { - _setHandler((_) async => throw PlatformException(code: 'winrt_error')); - final state = await MsixStartupTask.getState('TestTaskId'); - expect(state, isNull); - }); - }); - - group('MsixStartupTask.enable', () { - test('invokes "enable" method on the channel', () async { - MethodCall? captured; - _setHandler((call) async { - captured = call; - return 'enabled'; - }); - await MsixStartupTask.enable('CopyPasteStartup'); - expect(captured!.method, 'enable'); - }); - - test('returns the state from the channel reply', () async { - _setHandler((_) async => 'disabledByUser'); - final state = await MsixStartupTask.enable('CopyPasteStartup'); - expect(state, MsixStartupTaskState.disabledByUser); - }); - - test('returns null on PlatformException', () async { - _setHandler((_) async => throw PlatformException(code: 'winrt_error')); - final state = await MsixStartupTask.enable('CopyPasteStartup'); - expect(state, isNull); - }); - }); - - group('MsixStartupTask.disable', () { - test('invokes "disable" method on the channel', () async { - MethodCall? captured; - _setHandler((call) async { - captured = call; - return 'disabled'; - }); - await MsixStartupTask.disable('CopyPasteStartup'); - expect(captured!.method, 'disable'); - }); - - test('returns the state from the channel reply', () async { - _setHandler((_) async => 'disabled'); - final state = await MsixStartupTask.disable('CopyPasteStartup'); - expect(state, MsixStartupTaskState.disabled); - }); - - test('returns null on PlatformException', () async { - _setHandler((_) async => throw PlatformException(code: 'winrt_error')); - final state = await MsixStartupTask.disable('CopyPasteStartup'); - expect(state, isNull); - }); - }); -} diff --git a/app/test/shell/single_instance_test.dart b/app/test/shell/single_instance_test.dart deleted file mode 100644 index 6d87157c..00000000 --- a/app/test/shell/single_instance_test.dart +++ /dev/null @@ -1,326 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/single_instance.dart'; - -// Keeps the mutex, pipe and wakeup file off the names a CopyPaste running on -// this machine already owns; without it that instance holds the mutex and -// drains the wakeup signals these tests assert on. -final String _namespace = '_test_$pid'; - -String get _wakeupFilePath => - '${Directory.systemTemp.path}/copypaste.wakeup$_namespace'; - -void _cleanupWakeupFile() { - try { - File(_wakeupFilePath).deleteSync(); - } catch (_) {} -} - -void main() { - setUpAll(() => SingleInstance.namespace = _namespace); - - group('SingleInstance – Windows', () { - setUp(() { - if (!Platform.isWindows) return; - SingleInstance.release(); - _cleanupWakeupFile(); - }); - - tearDown(() { - if (!Platform.isWindows) return; - SingleInstance.release(); - _cleanupWakeupFile(); - }); - - test('acquire() returns true on first call', () { - if (!Platform.isWindows) return; - expect(SingleInstance.acquire(), isTrue); - }); - - test('acquire() returns false when mutex already held', () { - if (!Platform.isWindows) return; - expect(SingleInstance.acquire(), isTrue); - // Second call while already holding the mutex → false - expect(SingleInstance.acquire(), isFalse); - }); - - test('release() allows re-acquire', () { - if (!Platform.isWindows) return; - expect(SingleInstance.acquire(), isTrue); - SingleInstance.release(); - expect(SingleInstance.acquire(), isTrue); - }); - - test('release() is idempotent', () { - if (!Platform.isWindows) return; - SingleInstance.release(); - SingleInstance.release(); - // After double release, re-acquire must still work - expect(SingleInstance.acquire(), isTrue); - }); - - test('signalWakeup() writes wakeup file as fallback', () { - if (!Platform.isWindows) return; - _cleanupWakeupFile(); - // With no pipe server running, signalWakeup falls back to file - SingleInstance.signalWakeup(); - expect(File(_wakeupFilePath).existsSync(), isTrue); - }); - - test('listenForWakeup() fires callback when wakeup file appears', () async { - if (!Platform.isWindows) return; - - final completer = Completer(); - SingleInstance.listenForWakeup(() { - if (!completer.isCompleted) completer.complete(); - }); - - // Give the listener time to start, then create the file - await Future.delayed(const Duration(milliseconds: 250)); - File(_wakeupFilePath).writeAsStringSync('wakeup'); - - await completer.future.timeout( - const Duration(seconds: 5), - onTimeout: () => fail('Callback was not fired'), - ); - }); - - test('listenForWakeup() fires for fresh pre-existing file', () async { - if (!Platform.isWindows) return; - - // Write file BEFORE starting the listener - File(_wakeupFilePath).writeAsStringSync('wakeup'); - - final completer = Completer(); - SingleInstance.listenForWakeup(() { - if (!completer.isCompleted) completer.complete(); - }); - - await completer.future.timeout( - const Duration(seconds: 2), - onTimeout: () => fail('Callback was not fired for pre-existing file'), - ); - }); - - test('stopListening() prevents further callbacks', () async { - if (!Platform.isWindows) return; - - var callCount = 0; - SingleInstance.listenForWakeup(() => callCount++); - SingleInstance.stopListening(); - - File(_wakeupFilePath).writeAsStringSync('wakeup'); - await Future.delayed(const Duration(seconds: 1)); - expect(callCount, 0); - }); - - test( - 'calling listenForWakeup() twice replaces the first listener', - () async { - if (!Platform.isWindows) return; - - var firstCallCount = 0; - SingleInstance.listenForWakeup(() => firstCallCount++); - - final completer = Completer(); - SingleInstance.listenForWakeup(() { - if (!completer.isCompleted) completer.complete(); - }); - - await Future.delayed(const Duration(milliseconds: 250)); - File(_wakeupFilePath).writeAsStringSync('wakeup'); - - await completer.future.timeout(const Duration(seconds: 5)); - expect(firstCallCount, 0); - }, - ); - - test('debounce: rapid signals fire callback only once', () async { - if (!Platform.isWindows) return; - - var callCount = 0; - SingleInstance.listenForWakeup(() => callCount++); - - await Future.delayed(const Duration(milliseconds: 250)); - - // Write, delete, write again rapidly (both within the 2 s debounce window) - File(_wakeupFilePath).writeAsStringSync('wakeup'); - await Future.delayed(const Duration(milliseconds: 1200)); - File(_wakeupFilePath).writeAsStringSync('wakeup'); - await Future.delayed(const Duration(milliseconds: 1200)); - - expect(callCount, 1); - }); - - test('release() cleans up pipe isolate and subscription', () async { - if (!Platform.isWindows) return; - SingleInstance.acquire(); - var callCount = 0; - SingleInstance.listenForWakeup(() => callCount++); - SingleInstance.release(); - - // Drain any in-flight periodic event before writing the file - await Future.delayed(Duration.zero); - - // After release, writing the signal must not fire the old callback - File(_wakeupFilePath).writeAsStringSync('wakeup'); - await Future.delayed(const Duration(seconds: 1)); - expect(callCount, 0); - }); - }); - - group('SingleInstance – Unix (macOS)', () { - setUp(() { - if (Platform.isWindows) return; - SingleInstance.release(); - }); - - tearDown(() { - if (Platform.isWindows) return; - SingleInstance.release(); - }); - - test('acquire() returns true on first call', () { - if (Platform.isWindows) return; - expect(SingleInstance.acquire(), isTrue); - }); - - test('acquire() creates the lock file', () { - if (Platform.isWindows) return; - SingleInstance.acquire(); - final lockPath = '${Directory.systemTemp.path}/copypaste.lock'; - expect(File(lockPath).existsSync(), isTrue); - }); - - test('release() deletes the lock file', () { - if (Platform.isWindows) return; - SingleInstance.acquire(); - SingleInstance.release(); - final lockPath = '${Directory.systemTemp.path}/copypaste.lock'; - expect(File(lockPath).existsSync(), isFalse); - }); - - test('can re-acquire after release', () { - if (Platform.isWindows) return; - expect(SingleInstance.acquire(), isTrue); - SingleInstance.release(); - expect(SingleInstance.acquire(), isTrue); - }); - - test('release() is idempotent — safe to call without prior acquire', () { - if (Platform.isWindows) return; - SingleInstance.release(); - SingleInstance.release(); - // After double release, re-acquire must still work - expect(SingleInstance.acquire(), isTrue); - }); - - test('lock file contains the process pid', () { - if (Platform.isWindows) return; - SingleInstance.acquire(); - final lockPath = '${Directory.systemTemp.path}/copypaste.lock'; - final content = File(lockPath).readAsStringSync().trim(); - expect(content, equals('$pid')); - }); - }); - - group('SingleInstance – wakeup file (cross-platform)', () { - setUp(() { - SingleInstance.stopListening(); - _cleanupWakeupFile(); - }); - - tearDown(() { - SingleInstance.stopListening(); - _cleanupWakeupFile(); - }); - - test('signalWakeup writes the wakeup file', () { - if (Platform.isWindows) { - // On Windows, signalWakeup tries pipe first; only writes file - // as fallback. Tested in Windows-specific group instead. - return; - } - SingleInstance.signalWakeup(); - expect(File(_wakeupFilePath).existsSync(), isTrue); - }); - - test('file polling fires callback within 600ms', () async { - SingleInstance.listenForWakeup(() {}); - - final completer = Completer(); - SingleInstance.listenForWakeup(() { - if (!completer.isCompleted) completer.complete(); - }); - - await Future.delayed(const Duration(milliseconds: 250)); - File(_wakeupFilePath).writeAsStringSync('wakeup'); - - await completer.future.timeout( - const Duration(milliseconds: 3000), - onTimeout: () => fail('File polling did not fire within expected time'), - ); - }); - - test('wakeup file is deleted after callback fires', () async { - final completer = Completer(); - SingleInstance.listenForWakeup(() { - if (!completer.isCompleted) completer.complete(); - }); - - await Future.delayed(const Duration(milliseconds: 250)); - File(_wakeupFilePath).writeAsStringSync('wakeup'); - - await completer.future.timeout(const Duration(seconds: 5)); - // Allow a tick for the delete to complete - await Future.delayed(const Duration(milliseconds: 50)); - expect(File(_wakeupFilePath).existsSync(), isFalse); - }); - - test('stale file (>30s) is deleted on listenForWakeup startup', () { - // We cannot easily set mtime to 31s ago in pure Dart, so we verify - // the code path indirectly: a freshly written file is NOT deleted - // (proving the age check exists and only targets old files). - File(_wakeupFilePath).writeAsStringSync('wakeup'); - SingleInstance.listenForWakeup(() {}); - // Fresh file should still exist (not deleted by stale check) - expect(File(_wakeupFilePath).existsSync(), isTrue); - }); - - test('stopListening prevents further callbacks', () async { - var callCount = 0; - SingleInstance.listenForWakeup(() => callCount++); - SingleInstance.stopListening(); - - File(_wakeupFilePath).writeAsStringSync('wakeup'); - await Future.delayed(const Duration(seconds: 1)); - expect(callCount, 0); - }); - - test('debounce prevents duplicate callbacks from rapid signals', () async { - var callCount = 0; - final firstFired = Completer(); - SingleInstance.listenForWakeup(() { - callCount++; - if (!firstFired.isCompleted) firstFired.complete(); - }); - - await Future.delayed(const Duration(milliseconds: 250)); - - File(_wakeupFilePath).writeAsStringSync('wakeup'); - await firstFired.future.timeout( - const Duration(seconds: 5), - onTimeout: () => fail('First callback did not fire'), - ); - - // Second signal within 2s debounce window — must be suppressed - File(_wakeupFilePath).writeAsStringSync('wakeup'); - await Future.delayed(const Duration(milliseconds: 1500)); - - expect(callCount, 1); - }); - }); -} diff --git a/app/test/shell/startup_helper_test.dart b/app/test/shell/startup_helper_test.dart deleted file mode 100644 index c9569120..00000000 --- a/app/test/shell/startup_helper_test.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/startup_helper.dart'; - -String _plistPath() { - const plistLabel = 'com.rgdevment.copypaste'; - final home = Platform.environment['HOME'] ?? '/tmp'; - return '$home/Library/LaunchAgents/$plistLabel.plist'; -} - -void main() { - const plistLabel = 'com.rgdevment.copypaste'; - - // Remove any plist left over from a previous test run. - tearDown(() async { - if (!Platform.isMacOS) return; - final f = File(_plistPath()); - if (f.existsSync()) f.deleteSync(); - }); - - group('StartupHelper – macOS', () { - test('apply(true) creates the LaunchAgent plist', () async { - if (!Platform.isMacOS) return; - await StartupHelper.apply(true); - expect(File(_plistPath()).existsSync(), isTrue); - }); - - test('plist is a valid XML plist document', () async { - if (!Platform.isMacOS) return; - await StartupHelper.apply(true); - final content = File(_plistPath()).readAsStringSync(); - expect(content, contains('')); - expect(content, contains('')); - }); - - test('plist contains the correct Label key', () async { - if (!Platform.isMacOS) return; - await StartupHelper.apply(true); - final content = File(_plistPath()).readAsStringSync(); - expect(content, contains('Label')); - expect(content, contains('$plistLabel')); - }); - - test('plist contains RunAtLoad set to true', () async { - if (!Platform.isMacOS) return; - await StartupHelper.apply(true); - final content = File(_plistPath()).readAsStringSync(); - expect(content, contains('RunAtLoad')); - expect(content, contains('')); - }); - - test('plist contains KeepAlive set to false', () async { - if (!Platform.isMacOS) return; - await StartupHelper.apply(true); - final content = File(_plistPath()).readAsStringSync(); - expect(content, contains('KeepAlive')); - expect(content, contains('')); - }); - - test('plist ProgramArguments contains the executable path', () async { - if (!Platform.isMacOS) return; - await StartupHelper.apply(true); - final content = File(_plistPath()).readAsStringSync(); - expect(content, contains('ProgramArguments')); - expect(content, contains(Platform.resolvedExecutable)); - }); - - test('apply(false) removes the LaunchAgent plist', () async { - if (!Platform.isMacOS) return; - await StartupHelper.apply(true); - expect(File(_plistPath()).existsSync(), isTrue); - await StartupHelper.apply(false); - expect(File(_plistPath()).existsSync(), isFalse); - }); - - test('apply(false) does not throw when plist does not exist', () async { - if (!Platform.isMacOS) return; - final f = File(_plistPath()); - if (f.existsSync()) f.deleteSync(); - await expectLater(StartupHelper.apply(false), completes); - }); - - test('apply(true) overwrites an existing plist', () async { - if (!Platform.isMacOS) return; - await StartupHelper.apply(true); - final firstModified = File(_plistPath()).lastModifiedSync(); - - // Small delay so the timestamp can differ if a write occurs. - await Future.delayed(const Duration(milliseconds: 5)); - await StartupHelper.apply(true); - final secondModified = File(_plistPath()).lastModifiedSync(); - - // The file should have been rewritten. - expect( - secondModified.isAtSameMomentAs(firstModified) || - secondModified.isAfter(firstModified), - isTrue, - ); - }); - }, skip: !Platform.isMacOS ? 'macOS-only' : null); -} diff --git a/app/test/shell/startup_helper_windows_test.dart b/app/test/shell/startup_helper_windows_test.dart deleted file mode 100644 index 822c3455..00000000 --- a/app/test/shell/startup_helper_windows_test.dart +++ /dev/null @@ -1,218 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/msix_startup_task.dart'; -import 'package:copypaste/shell/startup_helper.dart'; - -const _startupChannel = MethodChannel('copypaste/startup_task'); - -void _setStartupHandler(Future Function(MethodCall) handler) { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_startupChannel, handler); -} - -void _clearHandlers() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_startupChannel, null); -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - tearDown(_clearHandlers); - - // --------------------------------------------------------------------------- - // isDevBuildPath — pure logic, always runs - // --------------------------------------------------------------------------- - - group('StartupHelper.isDevBuildPath', () { - test('detects a typical Debug build path', () { - expect( - StartupHelper.isDevBuildPath( - r'C:\Users\dev\CopyPaste\app\build\windows\x64\runner\Debug\copypaste.exe', - ), - isTrue, - ); - }); - - test('detects a Release build path', () { - expect( - StartupHelper.isDevBuildPath( - r'C:\Users\dev\CopyPaste\app\build\windows\x64\runner\Release\copypaste.exe', - ), - isTrue, - ); - }); - - test('detects forward-slash variant', () { - expect( - StartupHelper.isDevBuildPath( - r'C:/Users/dev/CopyPaste/app/build/windows/x64/runner/Release/copypaste.exe', - ), - isTrue, - ); - }); - - test('detects mixed slash variant', () { - expect( - StartupHelper.isDevBuildPath( - r'C:\Users\dev/CopyPaste\app\build/windows\x64\copypaste.exe', - ), - isTrue, - ); - }); - - test('is case-insensitive', () { - expect( - StartupHelper.isDevBuildPath( - r'C:\Users\Dev\CopyPaste\APP\BUILD\WINDOWS\x64\copypaste.exe', - ), - isTrue, - ); - }); - - test('returns false for a proper installed path', () { - expect( - StartupHelper.isDevBuildPath( - r'C:\Program Files\CopyPaste\CopyPaste.exe', - ), - isFalse, - ); - }); - - test( - 'returns false for a path that contains "windows" but not build path', - () { - expect( - StartupHelper.isDevBuildPath(r'C:\Users\dev\windows\CopyPaste.exe'), - isFalse, - ); - }, - ); - - test('returns false for empty string', () { - expect(StartupHelper.isDevBuildPath(''), isFalse); - }); - }); - - group('StartupHelper.stableExecutablePath', () { - late Directory root; - - setUp(() { - root = Directory.systemTemp.createTempSync('scoop_layout_'); - }); - - tearDown(() => root.deleteSync(recursive: true)); - - String seed(String versionDir, {bool withCurrent = true}) { - final versioned = Directory( - '${root.path}${Platform.pathSeparator}apps' - '${Platform.pathSeparator}copypaste' - '${Platform.pathSeparator}$versionDir', - )..createSync(recursive: true); - final exe = File( - '${versioned.path}${Platform.pathSeparator}CopyPaste.exe', - )..writeAsStringSync(''); - if (withCurrent) { - final current = Directory( - '${root.path}${Platform.pathSeparator}apps' - '${Platform.pathSeparator}copypaste' - '${Platform.pathSeparator}current', - )..createSync(recursive: true); - File( - '${current.path}${Platform.pathSeparator}CopyPaste.exe', - ).writeAsStringSync(''); - } - return exe.path; - } - - test('rewrites a versioned Scoop path to current', () { - final resolved = StartupHelper.stableExecutablePath(seed('2.9.0')); - expect(resolved, contains('current')); - expect(resolved, isNot(contains('2.9.0'))); - }); - - test('keeps the versioned path when current does not exist', () { - final versioned = seed('2.9.0', withCurrent: false); - expect(StartupHelper.stableExecutablePath(versioned), versioned); - }); - - test('leaves a path already on current untouched', () { - seed('2.9.0'); - final currentExe = - '${root.path}${Platform.pathSeparator}apps' - '${Platform.pathSeparator}copypaste' - '${Platform.pathSeparator}current' - '${Platform.pathSeparator}CopyPaste.exe'; - expect(StartupHelper.stableExecutablePath(currentExe), currentExe); - }); - - test('leaves a standalone install untouched', () { - const standalone = r'C:\Users\dev\AppData\Local\CopyPaste\CopyPaste.exe'; - expect(StartupHelper.stableExecutablePath(standalone), standalone); - }); - }); - - // --------------------------------------------------------------------------- - // apply() on Windows — MSIX path: calls enable/disable and clears registry - // --------------------------------------------------------------------------- - - group('StartupHelper.apply – MSIX StartupTask interaction', () { - test('enable is called with the correct taskId', () async { - if (!Platform.isWindows) return; - - MethodCall? captured; - _setStartupHandler((call) async { - captured = call; - return 'enabled'; - }); - - // We cannot mock WinPackageContext.isMsix directly, so this test is only - // meaningful in a real MSIX context. On a dev machine it exercises the - // channel mock plumbing at minimum. - await MsixStartupTask.enable('CopyPasteStartup'); - - expect(captured?.method, 'enable'); - expect((captured?.arguments as Map)['taskId'], 'CopyPasteStartup'); - }); - - test('disable is called with the correct taskId', () async { - if (!Platform.isWindows) return; - - MethodCall? captured; - _setStartupHandler((call) async { - captured = call; - return 'disabled'; - }); - - await MsixStartupTask.disable('CopyPasteStartup'); - - expect(captured?.method, 'disable'); - expect((captured?.arguments as Map)['taskId'], 'CopyPasteStartup'); - }); - - test( - 'enable returns disabledByUser when the user has blocked the task', - () async { - if (!Platform.isWindows) return; - - _setStartupHandler((_) async => 'disabledByUser'); - final state = await MsixStartupTask.enable('CopyPasteStartup'); - expect(state, MsixStartupTaskState.disabledByUser); - }, - ); - - test( - 'enable returns enabledByPolicy when policy forces the task on', - () async { - if (!Platform.isWindows) return; - - _setStartupHandler((_) async => 'enabledByPolicy'); - final state = await MsixStartupTask.enable('CopyPasteStartup'); - expect(state, MsixStartupTaskState.enabledByPolicy); - }, - ); - }); -} diff --git a/app/test/shell/windows_hotkey_channel_test.dart b/app/test/shell/windows_hotkey_channel_test.dart deleted file mode 100644 index 7137dd8d..00000000 --- a/app/test/shell/windows_hotkey_channel_test.dart +++ /dev/null @@ -1,208 +0,0 @@ -import 'package:copypaste/shell/windows_hotkey_channel.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const channelName = 'copypaste/test_windows_hotkeys'; - const methodChannel = MethodChannel(channelName); - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - final calls = []; - - setUp(() { - calls.clear(); - messenger.setMockMethodCallHandler(methodChannel, (call) async { - calls.add(call); - if (call.method == 'register') { - return {'success': true}; - } - return null; - }); - }); - - tearDown(() { - messenger.setMockMethodCallHandler(methodChannel, null); - }); - - test( - 'sends the complete binding and parses successful registration', - () async { - final channel = WindowsHotkeyChannel(channel: methodChannel); - await channel.start((_) {}); - - final response = await channel.register( - id: 'plainPaste', - virtualKey: 0x56, - useCtrl: true, - useWin: false, - useAlt: true, - useShift: true, - ); - - expect(response.success, isTrue); - expect(calls, hasLength(1)); - expect(calls.single.method, 'register'); - expect(calls.single.arguments, { - 'id': 'plainPaste', - 'virtualKey': 0x56, - 'useCtrl': true, - 'useWin': false, - 'useAlt': true, - 'useShift': true, - }); - - await channel.dispose(); - expect(calls.last.method, 'unregisterAll'); - }, - ); - - test('forwards WM_HOTKEY events from the runner exactly once', () async { - final invoked = []; - final channel = WindowsHotkeyChannel(channel: methodChannel); - await channel.start(invoked.add); - - final data = const StandardMethodCodec().encodeMethodCall( - const MethodCall('hotkeyPressed', 'open'), - ); - await messenger.handlePlatformMessage(channelName, data, (_) {}); - - expect(invoked, ['open']); - await channel.dispose(); - }); - - test('preserves native registration diagnostics', () async { - messenger.setMockMethodCallHandler(methodChannel, (call) async { - if (call.method == 'register') { - return { - 'success': false, - 'errorCode': 'registerFailed', - 'win32Error': 1409, - }; - } - return null; - }); - final channel = WindowsHotkeyChannel(channel: methodChannel); - await channel.start((_) {}); - - final response = await channel.register( - id: 'open', - virtualKey: 0x43, - useCtrl: true, - useWin: false, - useAlt: true, - useShift: false, - ); - - expect(response.success, isFalse); - expect(response.errorCode, 'registerFailed'); - expect(response.win32Error, 1409); - await channel.dispose(); - }); - - test('parses verified SendInput delivery', () async { - messenger.setMockMethodCallHandler(methodChannel, (call) async { - expect(call.method, 'sendPaste'); - return { - 'success': true, - 'sentInputs': 9, - 'expectedInputs': 9, - }; - }); - - final response = await WindowsHotkeyChannel.sendPaste( - channel: methodChannel, - ); - - expect(response.success, isTrue); - expect(response.sentInputs, 9); - expect(response.expectedInputs, 9); - }); - - test('preserves SendInput failure diagnostics', () async { - messenger.setMockMethodCallHandler(methodChannel, (call) async { - return { - 'success': false, - 'sentInputs': 0, - 'expectedInputs': 9, - 'errorCode': 'sendInputFailed', - 'win32Error': 5, - }; - }); - - final response = await WindowsHotkeyChannel.sendPaste( - channel: methodChannel, - ); - - expect(response.success, isFalse); - expect(response.sentInputs, 0); - expect(response.expectedInputs, 9); - expect(response.errorCode, 'sendInputFailed'); - expect(response.win32Error, 5); - }); - - test( - 'forwards the destination window, focus and thread to the runner', - () async { - MethodCall? captured; - messenger.setMockMethodCallHandler(methodChannel, (call) async { - captured = call; - return {'success': true}; - }); - - await WindowsHotkeyChannel.sendPaste( - targetHwnd: 460450, - targetFocusHwnd: 461184, - targetThreadId: 20832, - channel: methodChannel, - ); - - expect(captured!.method, 'sendPaste'); - expect(captured!.arguments['targetHwnd'], 460450); - expect(captured!.arguments['targetFocusHwnd'], 461184); - expect(captured!.arguments['targetThreadId'], 20832); - }, - ); - - test('parses the focus repair diagnostics', () async { - messenger.setMockMethodCallHandler(methodChannel, (call) async { - return { - 'success': true, - 'sentInputs': 9, - 'expectedInputs': 9, - 'attached': true, - 'focusRepaired': true, - 'focusBefore': 0, - }; - }); - - final response = await WindowsHotkeyChannel.sendPaste( - channel: methodChannel, - ); - - expect(response.attached, isTrue); - expect(response.focusRepaired, isTrue); - expect(response.focusBefore, 0); - }); - - test( - 'surfaces a destination that lost the foreground before injection', - () async { - messenger.setMockMethodCallHandler(methodChannel, (call) async { - return { - 'success': false, - 'errorCode': 'targetNotForeground', - }; - }); - - final response = await WindowsHotkeyChannel.sendPaste( - channel: methodChannel, - ); - - expect(response.success, isFalse); - expect(response.errorCode, 'targetNotForeground'); - expect(response.focusRepaired, isFalse); - }, - ); -} diff --git a/app/test/theme/compact_theme_test.dart b/app/test/theme/compact_theme_test.dart deleted file mode 100644 index a2fb17b6..00000000 --- a/app/test/theme/compact_theme_test.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/theme/compact_theme.dart'; - -void main() { - group('CompactTheme', () { - late CompactTheme theme; - - setUp(() { - theme = CompactTheme(); - }); - - test('id returns compact', () { - expect(theme.id, 'compact'); - }); - - test('name returns Compact', () { - expect(theme.name, 'Compact'); - }); - - test('filterStyle has expected chipSpacing', () { - expect(theme.filterStyle.chipSpacing, 6); - }); - - test('toolbarStyle has expected buttonSpacing', () { - expect(theme.toolbarStyle.buttonSpacing, 2); - }); - }); -} diff --git a/app/test/theme/dark_theme_test.dart b/app/test/theme/dark_theme_test.dart deleted file mode 100644 index 1ecfd19f..00000000 --- a/app/test/theme/dark_theme_test.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/theme/dark_theme.dart'; - -void main() { - group('darkColorScheme', () { - test('surface is dark', () { - expect(darkColorScheme.surface, equals(const Color(0xFF1A1D2E))); - }); - - test('background matches surface', () { - expect(darkColorScheme.background, equals(const Color(0xFF1A1D2E))); - }); - - test('onSurface is white', () { - expect(darkColorScheme.onSurface, equals(const Color(0xFFFFFFFF))); - }); - - test('primary is light indigo', () { - expect(darkColorScheme.primary, equals(const Color(0xFF818CF8))); - }); - - test('cardBackground is slightly lighter than surface', () { - expect(darkColorScheme.cardBackground, equals(const Color(0xFF1E2132))); - }); - - test('danger color is light red', () { - expect(darkColorScheme.danger, equals(const Color(0xFFFCA5A5))); - }); - - test('warning color is light yellow', () { - expect(darkColorScheme.warning, equals(const Color(0xFFFDE047))); - }); - - test('accent colors are all defined', () { - expect(darkColorScheme.accentRed, equals(const Color(0xFFFCA5A5))); - expect(darkColorScheme.accentGreen, equals(const Color(0xFF86EFAC))); - expect(darkColorScheme.accentPurple, equals(const Color(0xFFA5B4FC))); - expect(darkColorScheme.accentYellow, equals(const Color(0xFFFDE047))); - expect(darkColorScheme.accentBlue, equals(const Color(0xFFA5B4FC))); - expect(darkColorScheme.accentOrange, equals(const Color(0xFFFDBA74))); - }); - - test('accentForIndex returns transparent for index 0', () { - expect(darkColorScheme.accentForIndex(0), equals(Colors.transparent)); - }); - - test('accentForIndex returns accentRed for index 1', () { - expect( - darkColorScheme.accentForIndex(1), - equals(darkColorScheme.accentRed), - ); - }); - - test('accentForIndex returns accentGreen for index 2', () { - expect( - darkColorScheme.accentForIndex(2), - equals(darkColorScheme.accentGreen), - ); - }); - }); -} diff --git a/app/test/theme/light_theme_test.dart b/app/test/theme/light_theme_test.dart deleted file mode 100644 index 3f20587b..00000000 --- a/app/test/theme/light_theme_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/theme/light_theme.dart'; - -void main() { - group('lightColorScheme', () { - test('surface is light gray', () { - expect(lightColorScheme.surface, equals(const Color(0xFFEBEBF0))); - }); - - test('background matches surface', () { - expect(lightColorScheme.background, equals(const Color(0xFFEBEBF0))); - }); - - test('onSurface is black', () { - expect(lightColorScheme.onSurface, equals(const Color(0xFF000000))); - }); - - test('primary is indigo', () { - expect(lightColorScheme.primary, equals(const Color(0xFF4F46E5))); - }); - - test('cardBackground is white', () { - expect(lightColorScheme.cardBackground, equals(const Color(0xFFFFFFFF))); - }); - - test('danger color is dark red', () { - expect(lightColorScheme.danger, equals(const Color(0xFFB91C1C))); - }); - - test('warning color is dark amber', () { - expect(lightColorScheme.warning, equals(const Color(0xFF92400E))); - }); - - test('accent colors are defined', () { - expect(lightColorScheme.accentRed, equals(const Color(0xFFDC2626))); - expect(lightColorScheme.accentGreen, equals(const Color(0xFF166534))); - expect(lightColorScheme.accentPurple, equals(const Color(0xFF3730A3))); - expect(lightColorScheme.accentYellow, equals(const Color(0xFF92400E))); - expect(lightColorScheme.accentBlue, equals(const Color(0xFF3730A3))); - expect(lightColorScheme.accentOrange, equals(const Color(0xFFC2410C))); - }); - - test('accentForIndex returns transparent for index 0', () { - expect(lightColorScheme.accentForIndex(0), equals(Colors.transparent)); - }); - - test('accentForIndex returns accentRed for index 1', () { - expect( - lightColorScheme.accentForIndex(1), - equals(lightColorScheme.accentRed), - ); - }); - - test('onPrimary is white', () { - expect(lightColorScheme.onPrimary, equals(const Color(0xFFFFFFFF))); - }); - }); -} diff --git a/app/test/theme/theme_provider_test.dart b/app/test/theme/theme_provider_test.dart deleted file mode 100644 index 13a66f81..00000000 --- a/app/test/theme/theme_provider_test.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/theme/app_theme_data.dart'; -import 'package:copypaste/theme/compact_theme.dart'; -import 'package:copypaste/theme/theme_provider.dart'; - -// Test-only subclasses to simulate different theme IDs -class _ThemeA extends CompactTheme { - @override - String get id => 'theme_a'; - @override - String get name => 'Theme A'; -} - -class _ThemeB extends CompactTheme { - @override - String get id => 'theme_b'; - @override - String get name => 'Theme B'; -} - -void main() { - group('CopyPasteTheme', () { - testWidgets('of() throws when no CopyPasteTheme in context', ( - WidgetTester tester, - ) async { - FlutterError? error; - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) { - try { - CopyPasteTheme.of(context); - } on FlutterError catch (e) { - error = e; - } - return const SizedBox.shrink(); - }, - ), - ), - ); - expect(error, isNotNull); - }); - - testWidgets('of() returns correct AppThemeData', ( - WidgetTester tester, - ) async { - final themeData = _ThemeA(); - AppThemeData? result; - - await tester.pumpWidget( - MaterialApp( - home: CopyPasteTheme( - themeData: themeData, - child: Builder( - builder: (context) { - result = CopyPasteTheme.of(context); - return const SizedBox.shrink(); - }, - ), - ), - ), - ); - - expect(result, isNotNull); - expect(result!.id, equals('theme_a')); - }); - - testWidgets('updateShouldNotify notifies when theme ID changes', ( - WidgetTester tester, - ) async { - int buildCount = 0; - - await tester.pumpWidget( - MaterialApp( - home: CopyPasteTheme( - themeData: _ThemeA(), - child: Builder( - builder: (context) { - buildCount++; - CopyPasteTheme.of(context); - return const SizedBox.shrink(); - }, - ), - ), - ), - ); - - expect(buildCount, equals(1)); - - // Update to a different theme - await tester.pumpWidget( - MaterialApp( - home: CopyPasteTheme( - themeData: _ThemeB(), - child: Builder( - builder: (context) { - buildCount++; - CopyPasteTheme.of(context); - return const SizedBox.shrink(); - }, - ), - ), - ), - ); - - // Build count should increase because theme ID changed - expect(buildCount, equals(2)); - }); - - test('updateShouldNotify returns false when theme ID is the same', () { - final widget1 = CopyPasteTheme( - themeData: _ThemeA(), - child: const SizedBox.shrink(), - ); - final widget2 = CopyPasteTheme( - themeData: _ThemeA(), - child: const SizedBox.shrink(), - ); - expect(widget1.updateShouldNotify(widget2), isFalse); - }); - - test('updateShouldNotify returns true when theme ID changes', () { - final widgetA = CopyPasteTheme( - themeData: _ThemeA(), - child: const SizedBox.shrink(), - ); - final widgetB = CopyPasteTheme( - themeData: _ThemeB(), - child: const SizedBox.shrink(), - ); - expect(widgetA.updateShouldNotify(widgetB), isTrue); - }); - - testWidgets('colorsOf returns light colors in light brightness', ( - WidgetTester tester, - ) async { - AppThemeColorScheme? colors; - - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.light), - home: CopyPasteTheme( - themeData: _ThemeA(), - child: Builder( - builder: (context) { - colors = CopyPasteTheme.colorsOf(context); - return const SizedBox.shrink(); - }, - ), - ), - ), - ); - - // colorsOf should return the light scheme when Material brightness is light - expect(colors, isNotNull); - expect(colors!.surface, equals(_ThemeA().light.surface)); - }); - - testWidgets('colorsOf returns dark colors in dark brightness', ( - WidgetTester tester, - ) async { - AppThemeColorScheme? colors; - - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.dark), - home: CopyPasteTheme( - themeData: _ThemeA(), - child: Builder( - builder: (context) { - colors = CopyPasteTheme.colorsOf(context); - return const SizedBox.shrink(); - }, - ), - ), - ), - ); - - // colorsOf should return the dark scheme when Material brightness is dark - expect(colors, isNotNull); - expect(colors!.surface, equals(_ThemeA().dark.surface)); - }); - }); -} diff --git a/app/test/widgets/accessibility_dialog_test.dart b/app/test/widgets/accessibility_dialog_test.dart deleted file mode 100644 index c8d91d61..00000000 --- a/app/test/widgets/accessibility_dialog_test.dart +++ /dev/null @@ -1,381 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/l10n/app_localizations.dart'; -import 'package:copypaste/theme/compact_theme.dart'; -import 'package:copypaste/theme/theme_provider.dart'; -import 'package:copypaste/widgets/accessibility_dialog.dart'; - -import '../helpers/test_wrapper.dart'; - -void _setMockHandler( - MethodChannel channel, - Future Function(MethodCall) handler, -) { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, handler); -} - -void _clearMockHandler(MethodChannel channel) { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); -} - -void main() { - const channel = MethodChannel('copypaste/clipboard_writer'); - - setUp(() { - _setMockHandler(channel, (call) async { - switch (call.method) { - case 'requestAccessibility': - return false; - case 'checkAccessibility': - return false; - case 'openAccessibilitySettings': - return null; - default: - return null; - } - }); - }); - - tearDown(() => _clearMockHandler(channel)); - - group('AccessibilityDialog', () { - testWidgets('renders dialog with icon and buttons', (tester) async { - await tester.pumpWidget( - wrapWidget(const AccessibilityDialog(previouslyGranted: false)), - ); - await tester.pumpAndSettle(); - - expect(find.byType(AccessibilityDialog), findsOneWidget); - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.byIcon(Icons.security), findsOneWidget); - expect(find.byType(FilledButton), findsOneWidget); - expect(find.byType(TextButton), findsOneWidget); - }); - - testWidgets('dismiss TextButton pops dialog', (tester) async { - var popped = false; - - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: Builder( - builder: (ctx) => ElevatedButton( - onPressed: () async { - await showDialog( - context: ctx, - builder: (_) => - const AccessibilityDialog(previouslyGranted: false), - ); - popped = true; - }, - child: const Text('Open'), - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - expect(find.byType(AlertDialog), findsOneWidget); - - await tester.tap(find.byType(TextButton)); - await tester.pumpAndSettle(); - - expect(popped, isTrue); - expect(find.byType(AlertDialog), findsNothing); - }); - - testWidgets('open settings FilledButton calls openAccessibilitySettings', ( - tester, - ) async { - var settingsOpened = false; - - _setMockHandler(channel, (call) async { - if (call.method == 'openAccessibilitySettings') settingsOpened = true; - return null; - }); - - await tester.pumpWidget( - wrapWidget(const AccessibilityDialog(previouslyGranted: false)), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byType(FilledButton)); - await tester.pump(); - - expect(settingsOpened, isTrue); - }); - - testWidgets( - 'checkAndShow skips dialog when accessibility already granted', - (tester) async { - _setMockHandler(channel, (call) async { - if (call.method == 'checkAccessibility') return true; - return null; - }); - - var completed = false; - - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (ctx) => ElevatedButton( - onPressed: () async { - await AccessibilityDialog.checkAndShow(ctx); - completed = true; - }, - child: const Text('Check'), - ), - ), - ), - ); - - await tester.tap(find.text('Check')); - await tester.pumpAndSettle(); - - expect(find.byType(AlertDialog), findsNothing); - expect(completed, isTrue); - }, - ); - - testWidgets( - 'poll timer auto-closes dialog when accessibility becomes granted', - (tester) async { - var checkCallCount = 0; - - _setMockHandler(channel, (call) async { - if (call.method == 'requestAccessibility') return false; - if (call.method == 'checkAccessibility') { - checkCallCount++; - return checkCallCount >= 2; - } - return null; - }); - - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: Builder( - builder: (ctx) => - const AccessibilityDialog(previouslyGranted: false), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(AccessibilityDialog), findsOneWidget); - - // Advance clock past two timer ticks (1s each) - await tester.pump(const Duration(seconds: 3)); - await tester.pumpAndSettle(); - - expect(checkCallCount, greaterThanOrEqualTo(2)); - }, - ); - - testWidgets('dispose cancels poll timer without errors', (tester) async { - await tester.pumpWidget( - wrapWidget(const AccessibilityDialog(previouslyGranted: false)), - ); - await tester.pumpAndSettle(); - - // Replacing widget tree disposes the dialog state - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pumpAndSettle(); - - // No exception after disposal - expect(find.byType(AccessibilityDialog), findsNothing); - }); - - testWidgets( - 'renders warning icon and stale content when previouslyGranted is true', - (tester) async { - await tester.pumpWidget( - wrapWidget(const AccessibilityDialog(previouslyGranted: true)), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); - expect(find.byIcon(Icons.security), findsNothing); - expect(find.byType(OutlinedButton), findsOneWidget); - }, - ); - - testWidgets('_manualCheck success path closes dialog', (tester) async { - _setMockHandler(channel, (call) async { - if (call.method == 'checkAccessibility') return false; - if (call.method == 'requestAccessibility') return true; - return null; - }); - - var popped = false; - - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: Builder( - builder: (ctx) => ElevatedButton( - onPressed: () async { - await showDialog( - context: ctx, - builder: (_) => - const AccessibilityDialog(previouslyGranted: true), - ); - popped = true; - }, - child: const Text('Open'), - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - expect(find.byType(AlertDialog), findsOneWidget); - - await tester.tap(find.byType(OutlinedButton)); - await tester.pump(); - await tester.pump(); - await tester.pumpAndSettle(); - - expect(popped, isTrue); - expect(find.byType(AlertDialog), findsNothing); - }); - - testWidgets('_manualCheck failure sets retryNeeded phase', (tester) async { - await tester.pumpWidget( - wrapWidget(const AccessibilityDialog(previouslyGranted: true)), - ); - await tester.pumpAndSettle(); - - expect(find.byType(OutlinedButton), findsOneWidget); - - await tester.tap(find.byType(OutlinedButton)); - await tester.pump(); - await tester.pump(); - - // After failure, button is re-enabled (not showing '...') - expect(find.text('...'), findsNothing); - expect(find.byType(OutlinedButton), findsOneWidget); - }); - - testWidgets('shows ... and disables button while checking', (tester) async { - final completer = Completer(); - _setMockHandler(channel, (call) async { - if (call.method == 'requestAccessibility') return completer.future; - if (call.method == 'checkAccessibility') return false; - return null; - }); - - await tester.pumpWidget( - wrapWidget(const AccessibilityDialog(previouslyGranted: true)), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byType(OutlinedButton)); - await tester.pump(); - - expect(find.text('...'), findsOneWidget); - final btn = tester.widget(find.byType(OutlinedButton)); - expect(btn.onPressed, isNull); - - completer.complete(false); - await tester.pump(); - await tester.pump(); - - expect(find.text('...'), findsNothing); - }); - - testWidgets('phase transitions to retryNeeded after 30 timer ticks', ( - tester, - ) async { - await tester.pumpWidget( - wrapWidget(const AccessibilityDialog(previouslyGranted: false)), - ); - await tester.pumpAndSettle(); - - expect(find.byType(OutlinedButton), findsNothing); - - for (var i = 0; i < 31; i++) { - await tester.pump(const Duration(seconds: 1)); - } - await tester.pump(); - - expect(find.byType(OutlinedButton), findsOneWidget); - }); - - testWidgets('checkAndShow shows dialog when not initially granted', ( - tester, - ) async { - _setMockHandler(channel, (call) async { - if (call.method == 'checkAccessibility') return false; - return null; - }); - - bool? result; - - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: Builder( - builder: (ctx) => ElevatedButton( - onPressed: () async { - result = await AccessibilityDialog.checkAndShow(ctx); - }, - child: const Text('Check'), - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('Check')); - await tester.pump(); - await tester.pump(); - - expect(find.byType(AlertDialog), findsOneWidget); - - await tester.tap(find.byType(TextButton)); - await tester.pump(); - await tester.pumpAndSettle(); - - expect(result, isFalse); - expect(find.byType(AlertDialog), findsNothing); - }); - }); -} diff --git a/app/test/widgets/clipboard_card_test.dart b/app/test/widgets/clipboard_card_test.dart deleted file mode 100644 index ef75cf8d..00000000 --- a/app/test/widgets/clipboard_card_test.dart +++ /dev/null @@ -1,2175 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -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/widgets/clipboard_card.dart'; - -import '../helpers/test_wrapper.dart'; - -ClipboardItem _makeTextItem({ - String content = 'Sample clipboard content', - bool isPinned = false, - CardColor cardColor = CardColor.none, - String? label, -}) { - return ClipboardItem( - content: content, - type: ClipboardContentType.text, - isPinned: isPinned, - cardColor: cardColor, - label: label, - ); -} - -// Minimal valid 1x1 PNG. -const _png1x1 = [ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - 0x00, - 0x00, - 0x00, - 0x0D, - 0x49, - 0x48, - 0x44, - 0x52, - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, - 0x08, - 0x02, - 0x00, - 0x00, - 0x00, - 0x90, - 0x77, - 0x53, - 0xDE, - 0x00, - 0x00, - 0x00, - 0x0C, - 0x49, - 0x44, - 0x41, - 0x54, - 0x08, - 0xD7, - 0x63, - 0xF8, - 0xCF, - 0xC0, - 0x00, - 0x00, - 0x00, - 0x02, - 0x00, - 0x01, - 0xE2, - 0x21, - 0xBC, - 0x33, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4E, - 0x44, - 0xAE, - 0x42, - 0x60, - 0x82, -]; - -void main() { - group('ClipboardCard', () { - testWidgets('renders text content', (tester) async { - final item = _makeTextItem(content: 'Hello clipboard'); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Hello clipboard'), findsOneWidget); - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('plain text uses the plain glyph', (tester) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget); - expect(find.byIcon(Icons.text_format_rounded), findsNothing); - }); - - testWidgets('rich text swaps the glyph but keeps the type color', ( - tester, - ) async { - final plain = _makeTextItem(); - final rich = plain.copyWith(metadata: '{"rtf":"e1xydGYx"}'); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: rich, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.text_format_rounded), findsOneWidget); - expect(find.byIcon(Icons.text_snippet_outlined), findsNothing); - }); - - testWidgets('html-only metadata keeps the plain glyph', (tester) async { - final item = _makeTextItem().copyWith(metadata: '{"html":"PGh0bWw+"}'); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget); - }); - - testWidgets('a styled link keeps its link glyph', (tester) async { - // The type is the stronger signal for non-plain-text kinds, so rich - // formatting must not override it. - final item = ClipboardItem( - content: 'https://example.com', - type: ClipboardContentType.link, - ).copyWith(metadata: '{"rtf":"e1xydGYx"}'); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.link_rounded), findsOneWidget); - expect(find.byIcon(Icons.text_format_rounded), findsNothing); - }); - - testWidgets('glyph updates when metadata changes in place', (tester) async { - final plain = _makeTextItem(); - - Widget build(ClipboardItem item) => wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ); - - await tester.pumpWidget(build(plain)); - await tester.pumpAndSettle(); - expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget); - - // A re-copy with styles reuses the same item, so the cached flag must be - // recomputed rather than kept from the first build. - await tester.pumpWidget( - build(plain.copyWith(metadata: '{"rtf":"e1xydGYx"}')), - ); - await tester.pumpAndSettle(); - expect(find.byIcon(Icons.text_format_rounded), findsOneWidget); - }); - - testWidgets('double-tap triggers onTap', (tester) async { - var tapCount = 0; - var selectCount = 0; - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(), - onTap: () => tapCount++, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onSelect: () => selectCount++, - ), - ), - ); - await tester.pumpAndSettle(); - - // Two pointer-downs within 300ms triggers paste - final center = tester.getCenter(find.byType(ClipboardCard)); - final gesture = await tester.startGesture(center); - await gesture.up(); - await tester.pump(const Duration(milliseconds: 100)); - final gesture2 = await tester.startGesture(center); - await gesture2.up(); - await tester.pumpAndSettle(); - - expect(tapCount, equals(1)); - expect(selectCount, equals(2)); - }); - - testWidgets('single tap triggers onSelect', (tester) async { - var selectCount = 0; - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onSelect: () => selectCount++, - ), - ), - ); - await tester.pumpAndSettle(); - - final center = tester.getCenter(find.byType(ClipboardCard)); - final gesture = await tester.startGesture(center); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(selectCount, equals(1)); - }); - - testWidgets('expand toggle button triggers onExpandToggle', (tester) async { - var expandCount = 0; - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(content: 'Line1\nLine2\nLine3\nLine4\nLine5'), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onExpandToggle: () => expandCount++, - ), - ), - ); - await tester.pumpAndSettle(); - - // Find and tap the expand icon button - final expandIcon = find.byIcon(Icons.expand_more_rounded); - expect(expandIcon, findsOneWidget); - await tester.tap(expandIcon); - await tester.pumpAndSettle(); - - expect(expandCount, equals(1)); - }); - - testWidgets('expand toggle hidden for short content', (tester) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(content: 'Short text'), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onExpandToggle: () {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.expand_more_rounded), findsNothing); - }); - - testWidgets('shows selection border when isSelected is true', ( - tester, - ) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - isSelected: true, - ), - ), - ); - await tester.pumpAndSettle(); - - // Selected card should render without error - expect(find.byType(AnimatedContainer), findsAtLeastNWidgets(1)); - }); - - testWidgets('shows expanded content when isExpanded is true', ( - tester, - ) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem( - content: 'Line1\nLine2\nLine3\nLine4\nLine5\nLine6', - ), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - isExpanded: true, - cardMaxLines: 5, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('link type item renders without error', (tester) async { - final item = ClipboardItem( - content: 'https://example.com', - type: ClipboardContentType.link, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('pinned item renders without error', (tester) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(isPinned: true), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('card with label renders without error', (tester) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(label: 'Work'), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Work'), findsOneWidget); - }); - - testWidgets('card with color renders without error', (tester) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(cardColor: CardColor.red), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('dark mode renders without error', (tester) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - brightness: Brightness.dark, - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('onPin callback is called via action button', (tester) async { - var pinCount = 0; - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(), - onTap: () {}, - onPin: () => pinCount++, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // Hover to show action buttons - final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); - final card = find.byType(ClipboardCard); - await gesture.moveTo(tester.getCenter(card)); - await tester.pumpAndSettle(); - - // Find and tap pin button - final pinButtons = find.byIcon(Icons.push_pin_outlined); - if (pinButtons.evaluate().isNotEmpty) { - await tester.tap(pinButtons.first); - await tester.pump(); - expect(pinCount, equals(1)); - } - }); - - testWidgets('onDelete callback is called via action button', ( - tester, - ) async { - var deleteCount = 0; - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(), - onTap: () {}, - onPin: () {}, - onDelete: () => deleteCount++, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); - final card = find.byType(ClipboardCard); - await gesture.moveTo(tester.getCenter(card)); - await tester.pumpAndSettle(); - - final deleteButtons = find.byIcon(Icons.delete_rounded); - if (deleteButtons.evaluate().isNotEmpty) { - await tester.tap(deleteButtons.first); - await tester.pump(); - expect(deleteCount, equals(1)); - } - }); - - testWidgets('onPastePlain callback exposed for formatted text', ( - tester, - ) async { - var plainCount = 0; - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem().copyWith(metadata: '{"rtf":"e1xydGYx"}'), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onPastePlain: () => plainCount++, - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byType(ClipboardCard), findsOneWidget); - expect(find.byIcon(Icons.notes_rounded), findsOneWidget); - }); - - testWidgets('plain paste button is hidden when there is no formatting', ( - tester, - ) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onPastePlain: () {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.notes_rounded), findsNothing); - }); - - testWidgets('html-only metadata still offers the plain paste button', ( - tester, - ) async { - // The writer restores html too, so a normal paste would carry formatting - // even though the card shows the plain glyph. - final item = _makeTextItem().copyWith(metadata: '{"html":"PGh0bWw+"}'); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onPastePlain: () {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.notes_rounded), findsOneWidget); - expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget); - }); - - testWidgets('file type item renders filename', (tester) async { - final sep = Platform.pathSeparator; - final item = ClipboardItem( - content: '${sep}home${sep}user${sep}document.pdf', - type: ClipboardContentType.file, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - expect(find.text('document.pdf'), findsOneWidget); - }); - - testWidgets('folder type item renders without error', (tester) async { - final sep = Platform.pathSeparator; - final item = ClipboardItem( - content: '${sep}home${sep}user${sep}Documents', - type: ClipboardContentType.folder, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('file type with multiple files shows count badge', ( - tester, - ) async { - final sep = Platform.pathSeparator; - final item = ClipboardItem( - content: - '${sep}home${sep}user${sep}a.pdf\n${sep}home${sep}user${sep}b.txt\n${sep}home${sep}user${sep}c.docx', - type: ClipboardContentType.file, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // "+2" badge for 2 extra files - expect(find.text('+2'), findsOneWidget); - }); - - testWidgets('image type with non-existent path renders placeholder', ( - tester, - ) async { - final item = ClipboardItem( - content: 'C:\\nonexistent\\image.png', - type: ClipboardContentType.image, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('image type with empty content shows placeholder', ( - tester, - ) async { - final item = ClipboardItem(content: '', type: ClipboardContentType.image); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('image type with existing file renders Image widget', ( - tester, - ) async { - // Create a real PNG file for image rendering - final tmpDir = Directory.systemTemp.createTempSync('card_img_test_'); - final imgFile = File('${tmpDir.path}/test.png'); - // Minimal 1x1 PNG - imgFile.writeAsBytesSync([ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - 0x00, - 0x00, - 0x00, - 0x0D, - 0x49, - 0x48, - 0x44, - 0x52, - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, - 0x08, - 0x02, - 0x00, - 0x00, - 0x00, - 0x90, - 0x77, - 0x53, - 0xDE, - 0x00, - 0x00, - 0x00, - 0x0C, - 0x49, - 0x44, - 0x41, - 0x54, - 0x08, - 0xD7, - 0x63, - 0xF8, - 0xCF, - 0xC0, - 0x00, - 0x00, - 0x00, - 0x02, - 0x00, - 0x01, - 0xE2, - 0x21, - 0xBC, - 0x33, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4E, - 0x44, - 0xAE, - 0x42, - 0x60, - 0x82, - ]); - - final item = ClipboardItem( - content: imgFile.path, - type: ClipboardContentType.image, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - // Allow image path resolution - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - tmpDir.deleteSync(recursive: true); - }); - - testWidgets('image with existing file is draggable and starts native drag', ( - tester, - ) async { - const channel = MethodChannel('copypaste/clipboard_writer'); - final calls = []; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, - ) async { - calls.add(call); - return true; - }); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ), - ); - - final tmpDir = Directory.systemTemp.createTempSync('card_drag_test_'); - final imgFile = File('${tmpDir.path}/test.png') - ..writeAsBytesSync(_png1x1); - addTearDown(() => tmpDir.deleteSync(recursive: true)); - - final item = ClipboardItem( - content: imgFile.path, - type: ClipboardContentType.image, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // A grab cursor is exposed only once the backing file is confirmed on disk. - final dragHandle = find.byWidgetPredicate( - (w) => w is MouseRegion && w.cursor == SystemMouseCursors.grab, - ); - expect(dragHandle, findsOneWidget); - - await tester.drag(dragHandle, const Offset(60, 0)); - await tester.pumpAndSettle(); - - expect(calls.single.method, equals('startFileDrag')); - expect(calls.single.arguments['paths'], equals([imgFile.path])); - }); - - testWidgets('text card is not draggable (no grab cursor)', (tester) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect( - find.byWidgetPredicate( - (w) => w is MouseRegion && w.cursor == SystemMouseCursors.grab, - ), - findsNothing, - ); - }); - - testWidgets( - 'prefers thumbPath over content and invokes onRequestThumbnailRefresh', - (tester) async { - final tmpDir = Directory.systemTemp.createTempSync('card_thumb_test_'); - // Minimal valid 1x1 PNG bytes. - final png = [ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - 0x00, - 0x00, - 0x00, - 0x0D, - 0x49, - 0x48, - 0x44, - 0x52, - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, - 0x08, - 0x02, - 0x00, - 0x00, - 0x00, - 0x90, - 0x77, - 0x53, - 0xDE, - 0x00, - 0x00, - 0x00, - 0x0C, - 0x49, - 0x44, - 0x41, - 0x54, - 0x08, - 0xD7, - 0x63, - 0xF8, - 0xCF, - 0xC0, - 0x00, - 0x00, - 0x00, - 0x02, - 0x00, - 0x01, - 0xE2, - 0x21, - 0xBC, - 0x33, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4E, - 0x44, - 0xAE, - 0x42, - 0x60, - 0x82, - ]; - final source = File('${tmpDir.path}/source.png')..writeAsBytesSync(png); - final thumb = File('${tmpDir.path}/source_thumb.png') - ..writeAsBytesSync(png); - - ClipboardItem? refreshed; - final item = ClipboardItem( - content: source.path, - type: ClipboardContentType.image, - thumbPath: thumb.path, - ); - - await tester.runAsync(() async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onRequestThumbnailRefresh: (it) => refreshed = it, - ), - ), - ); - for (var i = 0; i < 10; i++) { - await Future.delayed(const Duration(milliseconds: 20)); - await tester.pump(); - } - }); - await tester.pumpAndSettle(); - - expect(refreshed?.id, equals(item.id)); - - String? imageProviderPath(ImageProvider provider) { - if (provider is FileImage) return provider.file.path; - if (provider is ResizeImage) { - return imageProviderPath(provider.imageProvider); - } - return null; - } - - final paths = tester - .widgetList(find.byType(Image)) - .map((w) => imageProviderPath(w.image)) - .whereType() - .toList(); - expect( - paths, - contains(thumb.path), - reason: 'card should render the thumbnail file when present', - ); - expect( - paths, - isNot(contains(source.path)), - reason: 'card should not render the source when a thumb is available', - ); - - tmpDir.deleteSync(recursive: true); - }, - ); - - testWidgets('falls back to content when thumbPath file is missing', ( - tester, - ) async { - final tmpDir = Directory.systemTemp.createTempSync('card_thumb_fb_'); - final png = [ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - 0x00, - 0x00, - 0x00, - 0x0D, - 0x49, - 0x48, - 0x44, - 0x52, - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, - 0x08, - 0x02, - 0x00, - 0x00, - 0x00, - 0x90, - 0x77, - 0x53, - 0xDE, - 0x00, - 0x00, - 0x00, - 0x0C, - 0x49, - 0x44, - 0x41, - 0x54, - 0x08, - 0xD7, - 0x63, - 0xF8, - 0xCF, - 0xC0, - 0x00, - 0x00, - 0x00, - 0x02, - 0x00, - 0x01, - 0xE2, - 0x21, - 0xBC, - 0x33, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4E, - 0x44, - 0xAE, - 0x42, - 0x60, - 0x82, - ]; - final source = File('${tmpDir.path}/source.png')..writeAsBytesSync(png); - final missingThumb = '${tmpDir.path}/does_not_exist_thumb.png'; - - final item = ClipboardItem( - content: source.path, - type: ClipboardContentType.image, - thumbPath: missingThumb, - ); - - await tester.runAsync(() async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - for (var i = 0; i < 10; i++) { - await Future.delayed(const Duration(milliseconds: 20)); - await tester.pump(); - } - }); - await tester.pumpAndSettle(); - - String? imageProviderPath(ImageProvider provider) { - if (provider is FileImage) return provider.file.path; - if (provider is ResizeImage) { - return imageProviderPath(provider.imageProvider); - } - return null; - } - - final paths = tester - .widgetList(find.byType(Image)) - .map((w) => imageProviderPath(w.image)) - .whereType() - .toList(); - expect(paths, contains(source.path)); - expect(paths, isNot(contains(missingThumb))); - - tmpDir.deleteSync(recursive: true); - }); - - testWidgets('audio type item renders without error', (tester) async { - final sep = Platform.pathSeparator; - final item = ClipboardItem( - content: '${sep}home${sep}user${sep}song.mp3', - type: ClipboardContentType.audio, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('video type item renders without error', (tester) async { - final sep = Platform.pathSeparator; - final item = ClipboardItem( - content: '${sep}home${sep}user${sep}clip.mp4', - type: ClipboardContentType.video, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('item with appSource displays appSource text', (tester) async { - final item = ClipboardItem( - content: 'Some text', - type: ClipboardContentType.text, - appSource: 'Notepad', - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('· Notepad'), findsOneWidget); - }); - - testWidgets('card updates when item changes', (tester) async { - final key = GlobalKey(); - final item1 = _makeTextItem(content: 'First content'); - final item2 = _makeTextItem(content: 'Second content'); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - key: key, - item: item1, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('First content'), findsOneWidget); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - key: key, - item: item2, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Second content'), findsOneWidget); - }); - - testWidgets('hover enter and exit changes card appearance', (tester) async { - final hoverChanges = []; - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onHoverChanged: hoverChanges.add, - ), - ), - ); - await tester.pumpAndSettle(); - - final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); - - // Enter hover - final card = find.byType(ClipboardCard); - await gesture.moveTo(tester.getCenter(card)); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - expect(hoverChanges, [true]); - - // Exit hover - await gesture.moveTo(const Offset(-100, -100)); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - expect(hoverChanges, [true, false]); - }); - - testWidgets('file not found shows warning badge', (tester) async { - final item = ClipboardItem( - content: 'C:\\nonexistent\\file.pdf', - type: ClipboardContentType.file, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // File not found badge should appear - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('unknown type renders text content', (tester) async { - final item = ClipboardItem( - content: 'Unknown content', - type: ClipboardContentType.unknown, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Unknown content'), findsOneWidget); - }); - - testWidgets('card with all colors renders without error', (tester) async { - for (final color in CardColor.values) { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(cardColor: color), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byType(ClipboardCard), findsOneWidget); - } - }); - - testWidgets('text item with pasteCount shows footer', (tester) async { - final item = ClipboardItem( - content: 'Pasted many times', - type: ClipboardContentType.text, - pasteCount: 5, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('×5'), findsOneWidget); - }); - - testWidgets('image item with dimensions metadata shows footer', ( - tester, - ) async { - final meta = jsonEncode({'width': 1920, 'height': 1080}); - final item = ClipboardItem( - content: '', - type: ClipboardContentType.image, - metadata: meta, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('1920×1080'), findsOneWidget); - }); - - testWidgets('file item with file_size metadata shows size footer', ( - tester, - ) async { - final sep = Platform.pathSeparator; - final meta = jsonEncode({'file_size': 512 * 1024}); // 512 KB - final item = ClipboardItem( - content: '${sep}docs${sep}report.pdf', - type: ClipboardContentType.file, - metadata: meta, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // Size chip should appear - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('video item with duration metadata shows duration footer', ( - tester, - ) async { - final sep = Platform.pathSeparator; - final meta = jsonEncode({'duration': 125}); // 2m5s - final item = ClipboardItem( - content: '${sep}videos${sep}clip.mp4', - type: ClipboardContentType.video, - metadata: meta, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('link item with valid URL renders domain badge', ( - tester, - ) async { - final item = ClipboardItem( - content: 'https://github.com/user/repo', - type: ClipboardContentType.link, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('github.com'), findsOneWidget); - }); - - testWidgets('link item with URL renders full URL', (tester) async { - final item = ClipboardItem( - content: 'https://flutter.dev/docs', - type: ClipboardContentType.link, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - isExpanded: true, - cardMaxLines: 5, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('link item open action triggers onOpen callback', ( - tester, - ) async { - var openCount = 0; - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: ClipboardItem( - content: 'https://flutter.dev/docs', - type: ClipboardContentType.link, - ), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onOpen: () => openCount++, - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byIcon(Icons.open_in_new_rounded)); - await tester.pumpAndSettle(); - - expect(openCount, equals(1)); - }); - - testWidgets('email item shows provider badge and open action', ( - tester, - ) async { - var openCount = 0; - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: ClipboardItem( - content: 'person@gmail.com', - type: ClipboardContentType.email, - ), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onOpen: () => openCount++, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Gmail'), findsOneWidget); - - await tester.tap(find.byIcon(Icons.open_in_new_rounded)); - await tester.pumpAndSettle(); - - expect(openCount, equals(1)); - }); - - testWidgets('phone item shows country badge and open action', ( - tester, - ) async { - var openCount = 0; - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: ClipboardItem( - content: '+34 600 111 222', - type: ClipboardContentType.phone, - ), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onOpen: () => openCount++, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Spain'), findsOneWidget); - - await tester.tap(find.byIcon(Icons.open_in_new_rounded)); - await tester.pumpAndSettle(); - - expect(openCount, equals(1)); - }); - - testWidgets('right-click shows context menu', (tester) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(content: 'Right click me'), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // Secondary tap using mouse gesture to open context menu - final card = find.byType(ClipboardCard); - final gesture = await tester.startGesture( - tester.getCenter(card), - kind: PointerDeviceKind.mouse, - buttons: kSecondaryMouseButton, - ); - await gesture.up(); - await tester.pumpAndSettle(); - - // Menu should have appeared with Paste option - expect(find.text('Paste'), findsOneWidget); - }); - - testWidgets('right-click menu paste action triggers onTap', (tester) async { - var tapCount = 0; - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(content: 'Paste via menu'), - onTap: () => tapCount++, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - final gesture = await tester.startGesture( - tester.getCenter(find.byType(ClipboardCard)), - kind: PointerDeviceKind.mouse, - buttons: kSecondaryMouseButton, - ); - await gesture.up(); - await tester.pumpAndSettle(); - - // Tap Paste menu item - final paste = find.text('Paste'); - expect(paste, findsOneWidget); - await tester.tap(paste.first); - await tester.pumpAndSettle(); - - expect(tapCount, 1); - }); - - testWidgets('text item with paste plain in menu, tapping it fires', ( - tester, - ) async { - var plainPasted = false; - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(content: 'Plain text'), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - onPastePlain: () => plainPasted = true, - ), - ), - ); - await tester.pumpAndSettle(); - - final gesture = await tester.startGesture( - tester.getCenter(find.byType(ClipboardCard)), - kind: PointerDeviceKind.mouse, - buttons: kSecondaryMouseButton, - ); - await gesture.up(); - await tester.pumpAndSettle(); - - // Paste plain should appear in menu for text type - final pastePlain = find.text('Paste plain'); - if (pastePlain.evaluate().isNotEmpty) { - await tester.tap(pastePlain.first); - await tester.pumpAndSettle(); - expect(plainPasted, isTrue); - } - }); - - testWidgets('image with large file size shows GB format', (tester) async { - final meta = jsonEncode({'file_size': 2 * 1024 * 1024 * 1024}); // 2GB - final item = ClipboardItem( - content: '', - type: ClipboardContentType.image, - metadata: meta, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('image with small file size shows bytes format', ( - tester, - ) async { - final meta = jsonEncode({'file_size': 500}); // 500 bytes - final item = ClipboardItem( - content: '', - type: ClipboardContentType.image, - metadata: meta, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('video item with duration over 1 hour shows H:MM:SS format', ( - tester, - ) async { - final meta = jsonEncode({'duration': 3700}); // 1h 1m 40s - final item = ClipboardItem( - content: '/video.mp4', - type: ClipboardContentType.video, - metadata: meta, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('card with MB file size shows MB format', (tester) async { - final meta = jsonEncode({'file_size': 5 * 1024 * 1024}); // 5MB - final item = ClipboardItem( - content: '/big_file.bin', - type: ClipboardContentType.file, - metadata: meta, - ); - - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('pinned item in header shows pin icon when not hovering', ( - tester, - ) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(isPinned: true), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.push_pin_rounded), findsAtLeastNWidgets(1)); - }); - - testWidgets('dark mode hover covers surfaceVariant color path', ( - tester, - ) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - brightness: Brightness.dark, - ), - ); - await tester.pumpAndSettle(); - - // Hover in dark mode to trigger surfaceVariant color path - final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); - await gesture.moveTo(tester.getCenter(find.byType(ClipboardCard))); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('hover edit button triggers _editLabelColor and shows dialog', ( - tester, - ) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(content: 'Edit via hover'), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // Hover to show action buttons - final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); - await gesture.moveTo(tester.getCenter(find.byType(ClipboardCard))); - await tester.pumpAndSettle(); - - // Edit button must exist when hovering - final editButtons = find.byIcon(Icons.edit_outlined); - expect(editButtons, findsAtLeastNWidgets(1)); - await tester.tap(editButtons.first); - await tester.pumpAndSettle(); - - // Cancel dialog if shown - final cancel = find.text('Cancel'); - if (cancel.evaluate().isNotEmpty) { - await tester.tap(cancel.first); - await tester.pumpAndSettle(); - } - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('context menu dismissed without selection covers null case', ( - tester, - ) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(content: 'Dismiss menu'), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // Open context menu - final gesture = await tester.startGesture( - tester.getCenter(find.byType(ClipboardCard)), - kind: PointerDeviceKind.mouse, - buttons: kSecondaryMouseButton, - ); - await gesture.up(); - await tester.pumpAndSettle(); - - // Verify menu is open - expect(find.text('Paste'), findsOneWidget); - - // Tap outside the menu to dismiss it (null case) - await tester.tapAt(const Offset(10, 10)); - await tester.pumpAndSettle(); - - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('right-click delete action triggers onDelete', (tester) async { - var deleted = false; - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(content: 'Delete me'), - onTap: () {}, - onPin: () {}, - onDelete: () => deleted = true, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - final gesture = await tester.startGesture( - tester.getCenter(find.byType(ClipboardCard)), - kind: PointerDeviceKind.mouse, - buttons: kSecondaryMouseButton, - ); - await gesture.up(); - await tester.pumpAndSettle(); - - final deleteItem = find.text('Delete'); - if (deleteItem.evaluate().isNotEmpty) { - await tester.tap(deleteItem.first); - await tester.pumpAndSettle(); - expect(deleted, isTrue); - } - }); - - testWidgets('right-click pin action triggers onPin', (tester) async { - var pinned = false; - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(content: 'Pin me'), - onTap: () {}, - onPin: () => pinned = true, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - final gesture = await tester.startGesture( - tester.getCenter(find.byType(ClipboardCard)), - kind: PointerDeviceKind.mouse, - buttons: kSecondaryMouseButton, - ); - await gesture.up(); - await tester.pumpAndSettle(); - - final pinItem = find.text('Pin'); - if (pinItem.evaluate().isNotEmpty) { - await tester.tap(pinItem.first); - await tester.pumpAndSettle(); - expect(pinned, isTrue); - } - }); - - testWidgets('right-click edit action opens label dialog', (tester) async { - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: _makeTextItem(content: 'Edit me'), - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (label, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - final gesture = await tester.startGesture( - tester.getCenter(find.byType(ClipboardCard)), - kind: PointerDeviceKind.mouse, - buttons: kSecondaryMouseButton, - ); - await gesture.up(); - await tester.pumpAndSettle(); - - // Verify context menu opened (Paste is always present) - expect(find.text('Paste'), findsOneWidget); - - // Tap 'Edit card' menu item - final editItem = find.text('Edit card'); - expect(editItem, findsOneWidget); - await tester.tap(editItem.first); - await tester.pumpAndSettle(); - - // LabelColorDialog should appear - final cancel = find.text('Cancel'); - if (cancel.evaluate().isNotEmpty) { - await tester.tap(cancel.first); - await tester.pumpAndSettle(); - } - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('timestamp for item modified 5 minutes ago shows Xm', ( - tester, - ) async { - final item = ClipboardItem( - content: 'Old item', - type: ClipboardContentType.text, - modifiedAt: DateTime.now().subtract(const Duration(minutes: 5)), - ); - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byType(ClipboardCard), findsOneWidget); - expect(find.textContaining('m'), findsWidgets); - }); - - testWidgets('timestamp for item modified 3 hours ago shows Xh', ( - tester, - ) async { - final item = ClipboardItem( - content: 'Hours old', - type: ClipboardContentType.text, - modifiedAt: DateTime.now().subtract(const Duration(hours: 3)), - ); - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byType(ClipboardCard), findsOneWidget); - expect(find.textContaining('h'), findsWidgets); - }); - - testWidgets('timestamp for item modified 4 days ago shows Xd', ( - tester, - ) async { - final item = ClipboardItem( - content: 'Days old', - type: ClipboardContentType.text, - modifiedAt: DateTime.now().subtract(const Duration(days: 4)), - ); - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byType(ClipboardCard), findsOneWidget); - expect(find.textContaining('d'), findsWidgets); - }); - - testWidgets('timestamp for item modified 30 days ago shows month/day', ( - tester, - ) async { - final item = ClipboardItem( - content: 'Very old', - type: ClipboardContentType.text, - modifiedAt: DateTime.now().subtract(const Duration(days: 30)), - ); - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('video item without extension but with duration has footer', ( - tester, - ) async { - final meta = jsonEncode({'duration': 120}); - final item = ClipboardItem( - content: '/videos/clip', - type: ClipboardContentType.video, - metadata: meta, - ); - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('audio item with empty content shows audio label', ( - tester, - ) async { - final item = ClipboardItem(content: '', type: ClipboardContentType.audio); - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byType(ClipboardCard), findsOneWidget); - }); - - testWidgets('video item with empty content shows video label', ( - tester, - ) async { - final item = ClipboardItem(content: '', type: ClipboardContentType.video); - await tester.pumpWidget( - wrapWidget( - ClipboardCard( - item: item, - onTap: () {}, - onPin: () {}, - onDelete: () {}, - onLabelColor: (_, _) {}, - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byType(ClipboardCard), findsOneWidget); - }); - }); -} diff --git a/app/test/widgets/empty_state_test.dart b/app/test/widgets/empty_state_test.dart deleted file mode 100644 index 4d1c75ec..00000000 --- a/app/test/widgets/empty_state_test.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/widgets/empty_state.dart'; - -import '../helpers/test_wrapper.dart'; - -void main() { - group('EmptyState', () { - testWidgets('renders without error', (tester) async { - await tester.pumpWidget(wrapWidget(const EmptyState())); - await tester.pumpAndSettle(); - - expect(find.byType(EmptyState), findsOneWidget); - }); - - testWidgets('shows paste icon', (tester) async { - await tester.pumpWidget(wrapWidget(const EmptyState())); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.content_paste_rounded), findsOneWidget); - }); - - testWidgets('renders in dark mode without error', (tester) async { - await tester.pumpWidget( - wrapWidget(const EmptyState(), brightness: Brightness.dark), - ); - await tester.pumpAndSettle(); - - expect(find.byType(EmptyState), findsOneWidget); - }); - }); -} diff --git a/app/test/widgets/filter_bar_test.dart b/app/test/widgets/filter_bar_test.dart deleted file mode 100644 index 06beac68..00000000 --- a/app/test/widgets/filter_bar_test.dart +++ /dev/null @@ -1,264 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/widgets/filter_bar.dart'; - -import '../helpers/test_wrapper.dart'; - -void main() { - group('FilterBar', () { - testWidgets('renders without error when no colors selected', ( - tester, - ) async { - await tester.pumpWidget( - wrapWidget( - FilterBar( - selectedTypes: const [], - selectedColors: const [], - onTypesChanged: (_) {}, - onColorsChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(FilterBar), findsOneWidget); - }); - - testWidgets('shows no badge when no colors selected', (tester) async { - await tester.pumpWidget( - wrapWidget( - FilterBar( - selectedTypes: const [], - selectedColors: const [], - onTypesChanged: (_) {}, - onColorsChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // With no active filters, badge text should not appear - expect(find.text('1'), findsNothing); - expect(find.text('2'), findsNothing); - }); - - testWidgets('shows badge with count when colors selected', (tester) async { - await tester.pumpWidget( - wrapWidget( - FilterBar( - selectedTypes: const [], - selectedColors: const [CardColor.red, CardColor.blue], - onTypesChanged: (_) {}, - onColorsChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // Badge should show count "2" - expect(find.text('2'), findsOneWidget); - }); - - testWidgets('shows badge count 1 for single color', (tester) async { - await tester.pumpWidget( - wrapWidget( - FilterBar( - selectedTypes: const [], - selectedColors: const [CardColor.green], - onTypesChanged: (_) {}, - onColorsChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('1'), findsOneWidget); - }); - - testWidgets('has tappable button area', (tester) async { - await tester.pumpWidget( - wrapWidget( - FilterBar( - selectedTypes: const [], - selectedColors: const [], - onTypesChanged: (_) {}, - onColorsChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // The FilterBar renders a tappable button (GestureDetector or InkWell) - expect( - find.byWidgetPredicate( - (w) => w is GestureDetector || w is InkWell || w is MouseRegion, - ), - findsAtLeastNWidgets(1), - ); - }); - - testWidgets('openMenu can be called via GlobalKey', (tester) async { - final key = GlobalKey(); - - await tester.pumpWidget( - wrapWidget( - FilterBar( - key: key, - selectedTypes: const [], - selectedColors: const [], - onTypesChanged: (_) {}, - onColorsChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // Should not throw - key.currentState!.openMenu(); - await tester.pumpAndSettle(); - }); - - testWidgets('dark mode renders without error', (tester) async { - await tester.pumpWidget( - wrapWidget( - FilterBar( - selectedTypes: const [], - selectedColors: const [CardColor.red], - onTypesChanged: (_) {}, - onColorsChanged: (_) {}, - ), - brightness: Brightness.dark, - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(FilterBar), findsOneWidget); - }); - - testWidgets('openMenu with active color shows clear option', ( - tester, - ) async { - final key = GlobalKey(); - var clearCalled = false; - - await tester.pumpWidget( - wrapWidget( - FilterBar( - key: key, - selectedTypes: const [], - selectedColors: const [CardColor.red], - onTypesChanged: (_) {}, - onColorsChanged: (_) {}, - onClear: () => clearCalled = true, - ), - ), - ); - await tester.pumpAndSettle(); - - key.currentState!.openMenu(); - await tester.pumpAndSettle(); - - // Menu is open - clear option should be first item - final menuItems = find.byType(PopupMenuItem); - expect(menuItems, findsAtLeastNWidgets(1)); - - // Tap first menu item (Clear all filters) - await tester.tap(menuItems.first); - await tester.pumpAndSettle(); - - expect(clearCalled, isTrue); - }); - - testWidgets('openMenu with active color, tap color removes it', ( - tester, - ) async { - final key = GlobalKey(); - List? updated; - - await tester.pumpWidget( - wrapWidget( - FilterBar( - key: key, - selectedTypes: const [], - selectedColors: const [CardColor.red], - onTypesChanged: (_) {}, - onColorsChanged: (c) => updated = c, - onClear: () {}, - ), - ), - ); - await tester.pumpAndSettle(); - - key.currentState!.openMenu(); - await tester.pumpAndSettle(); - - // Menu items when selectedColors=[red]: - // 0: "Clear all filters" - // 1: Color section label (disabled) - // 2: Red (selected) ← tapping this removes red - // 3: Green, 4: Purple, 5: Yellow, 6: Blue, 7: Orange - final menuItems = find.byType(PopupMenuItem); - if (menuItems.evaluate().length >= 3) { - await tester.tap(menuItems.at(2)); // Red = selected → removes it - await tester.pumpAndSettle(); - expect(updated, isNotNull); - expect(updated!.contains(CardColor.red), isFalse); - } - }); - - testWidgets('openMenu, tap unselected color adds it', (tester) async { - final key = GlobalKey(); - List? updated; - - await tester.pumpWidget( - wrapWidget( - FilterBar( - key: key, - selectedTypes: const [], - selectedColors: const [], - onTypesChanged: (_) {}, - onColorsChanged: (c) => updated = c, - onClear: () {}, - ), - ), - ); - await tester.pumpAndSettle(); - - key.currentState!.openMenu(); - await tester.pumpAndSettle(); - - // No clear item since no active filters - first PopupMenuItems are label + color - final menuItems = find.byType(PopupMenuItem); - // Skip label item (0), tap color item (1) - if (menuItems.evaluate().length >= 2) { - await tester.tap(menuItems.at(1)); - await tester.pumpAndSettle(); - expect(updated, isNotNull); - expect(updated!.length, 1); - } - }); - - testWidgets('tapping filter button opens menu', (tester) async { - await tester.pumpWidget( - wrapWidget( - FilterBar( - selectedTypes: const [], - selectedColors: const [], - onTypesChanged: (_) {}, - onColorsChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // Tap the InkWell (filter button) - await tester.tap(find.byType(InkWell).first); - await tester.pumpAndSettle(); - - // Menu should be shown (PopupMenuItems rendered) - expect(find.byType(PopupMenuItem), findsAtLeastNWidgets(1)); - }); - }); -} diff --git a/app/test/widgets/filter_tab_bar_test.dart b/app/test/widgets/filter_tab_bar_test.dart deleted file mode 100644 index 60bee74a..00000000 --- a/app/test/widgets/filter_tab_bar_test.dart +++ /dev/null @@ -1,228 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/widgets/filter_tab_bar.dart'; - -import '../helpers/test_wrapper.dart'; - -void main() { - group('FilterTabBar', () { - testWidgets('renders without error', (tester) async { - await tester.pumpWidget( - wrapWidget( - FilterTabBar( - selectedTypes: const [], - isPinnedMode: false, - onTypesChanged: (_) {}, - onPinnedModeChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(FilterTabBar), findsOneWidget); - }); - - testWidgets('shows multiple tab items', (tester) async { - await tester.pumpWidget( - wrapWidget( - FilterTabBar( - selectedTypes: const [], - isPinnedMode: false, - onTypesChanged: (_) {}, - onPinnedModeChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // At least 3 Text widgets for the tabs (All, Pinned, Text, Image, etc.) - expect(find.byType(Text), findsAtLeastNWidgets(3)); - }); - - testWidgets('tapping a type tab fires onTypesChanged', (tester) async { - List? result; - - await tester.pumpWidget( - wrapWidget( - FilterTabBar( - selectedTypes: const [], - isPinnedMode: false, - onTypesChanged: (t) => result = t, - onPinnedModeChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - // Tap on "Text" tab (3rd tab in list) - final textFinder = find.text('Text'); - if (textFinder.evaluate().isNotEmpty) { - await tester.tap(textFinder.first); - await tester.pump(); - expect(result, isNotNull); - expect(result, contains(ClipboardContentType.text)); - } - }); - - testWidgets('tapping Pinned tab fires onPinnedModeChanged with true', ( - tester, - ) async { - bool? pinnedResult; - - await tester.pumpWidget( - wrapWidget( - FilterTabBar( - selectedTypes: const [], - isPinnedMode: false, - onTypesChanged: (_) {}, - onPinnedModeChanged: (p) => pinnedResult = p, - ), - ), - ); - await tester.pumpAndSettle(); - - final pinnedFinder = find.text('Pinned'); - if (pinnedFinder.evaluate().isNotEmpty) { - await tester.tap(pinnedFinder.first); - await tester.pump(); - expect(pinnedResult, isTrue); - } - }); - - testWidgets('tapping active type tab deselects (fires empty list)', ( - tester, - ) async { - List? result; - - await tester.pumpWidget( - wrapWidget( - FilterTabBar( - selectedTypes: const [ - ClipboardContentType.text, - ClipboardContentType.ip, - ClipboardContentType.uuid, - ClipboardContentType.json, - ], - isPinnedMode: false, - onTypesChanged: (t) => result = t, - onPinnedModeChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - final textFinder = find.text('Text'); - if (textFinder.evaluate().isNotEmpty) { - await tester.tap(textFinder.first); - await tester.pump(); - expect(result, isNotNull); - expect(result, isEmpty); - } - }); - - testWidgets('tapping All tab fires onTypesChanged with empty list', ( - tester, - ) async { - List? result; - - await tester.pumpWidget( - wrapWidget( - FilterTabBar( - selectedTypes: const [ClipboardContentType.text], - isPinnedMode: false, - onTypesChanged: (t) => result = t, - onPinnedModeChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - final allFinder = find.text('All'); - if (allFinder.evaluate().isNotEmpty) { - await tester.tap(allFinder.first); - await tester.pump(); - expect(result, isNotNull); - expect(result, isEmpty); - } - }); - - testWidgets('dark mode renders without error', (tester) async { - await tester.pumpWidget( - wrapWidget( - FilterTabBar( - selectedTypes: const [], - isPinnedMode: false, - onTypesChanged: (_) {}, - onPinnedModeChanged: (_) {}, - ), - brightness: Brightness.dark, - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(FilterTabBar), findsOneWidget); - }); - - testWidgets('hovering over an inactive tab changes its appearance', ( - tester, - ) async { - await tester.pumpWidget( - wrapWidget( - FilterTabBar( - selectedTypes: const [], - isPinnedMode: false, - onTypesChanged: (_) {}, - onPinnedModeChanged: (_) {}, - ), - ), - ); - await tester.pumpAndSettle(); - - final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); - - final tabFinder = find.text('All'); - if (tabFinder.evaluate().isNotEmpty) { - await gesture.moveTo(tester.getCenter(tabFinder.first)); - await tester.pumpAndSettle(); - await gesture.moveTo(Offset.zero); - await tester.pumpAndSettle(); - } - expect(find.byType(FilterTabBar), findsOneWidget); - }); - - testWidgets('drag scroll pointer events do not crash', (tester) async { - await tester.pumpWidget( - wrapWidget( - SizedBox( - width: 200, - child: FilterTabBar( - selectedTypes: const [], - isPinnedMode: false, - onTypesChanged: (_) {}, - onPinnedModeChanged: (_) {}, - ), - ), - ), - ); - await tester.pumpAndSettle(); - - final tabBar = find.byType(FilterTabBar); - final center = tester.getCenter(tabBar); - - final gesture = await tester.startGesture(center); - await gesture.moveBy(const Offset(-50, 0)); - await tester.pump(); - await gesture.moveBy(const Offset(-20, 0)); - await tester.pump(); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(find.byType(FilterTabBar), findsOneWidget); - }); - }); -} diff --git a/app/test/widgets/label_color_dialog_test.dart b/app/test/widgets/label_color_dialog_test.dart deleted file mode 100644 index 4cd9ce48..00000000 --- a/app/test/widgets/label_color_dialog_test.dart +++ /dev/null @@ -1,333 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/theme/compact_theme.dart'; -import 'package:copypaste/theme/theme_provider.dart'; -import 'package:copypaste/l10n/app_localizations.dart'; -import 'package:copypaste/widgets/label_color_dialog.dart'; - -Widget _buildApp(Widget child) { - return MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.light(), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold(body: child), - ), - ); -} - -Widget _buildDialogApp({ - required void Function(LabelColorResult?) onResult, - String? currentLabel, - CardColor currentColor = CardColor.none, -}) { - return MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.light(), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: Builder( - builder: (context) => Center( - child: ElevatedButton( - onPressed: () async { - final result = await LabelColorDialog.show( - context, - currentLabel: currentLabel, - currentColor: currentColor, - ); - onResult(result); - }, - child: const Text('Open'), - ), - ), - ), - ), - ), - ); -} - -void main() { - group('LabelColorDialog', () { - testWidgets('renders with title and text field', (tester) async { - await tester.pumpWidget( - _buildApp( - const LabelColorDialog( - currentLabel: 'My Label', - currentColor: CardColor.none, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Label & Color'), findsOneWidget); - expect(find.byType(TextField), findsOneWidget); - }); - - testWidgets('pre-fills existing label in text field', (tester) async { - await tester.pumpWidget( - _buildApp( - const LabelColorDialog( - currentLabel: 'Work', - currentColor: CardColor.red, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Work'), findsOneWidget); - }); - - testWidgets('renders color grid with multiple options', (tester) async { - await tester.pumpWidget( - _buildApp( - const LabelColorDialog( - currentLabel: null, - currentColor: CardColor.none, - ), - ), - ); - await tester.pumpAndSettle(); - - // 7 color chips rendered (none, red, green, purple, yellow, blue, orange) - expect(find.byType(GestureDetector), findsAtLeastNWidgets(3)); - }); - - testWidgets('tapping a color chip selects it', (tester) async { - await tester.pumpWidget( - _buildApp( - const LabelColorDialog( - currentLabel: null, - currentColor: CardColor.none, - ), - ), - ); - await tester.pumpAndSettle(); - - // Find animated containers in the color grid (color circles) - final containers = find.byType(AnimatedContainer); - expect(containers, findsAtLeastNWidgets(2)); - - // Tap colour circle (first one after dialog container) - await tester.tap(containers.at(1)); - await tester.pumpAndSettle(); - - // No crash means the setState worked - expect(find.byType(LabelColorDialog), findsOneWidget); - }); - - testWidgets('Save button returns result with label and color', ( - tester, - ) async { - LabelColorResult? result; - - await tester.pumpWidget( - _buildDialogApp( - onResult: (r) => result = r, - currentLabel: 'Initial', - currentColor: CardColor.blue, - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - // Verify dialog opened - expect(find.text('Label & Color'), findsOneWidget); - - // Clear and enter new text - await tester.enterText(find.byType(TextField), 'New Label'); - await tester.pumpAndSettle(); - - // Tap Save - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(result, isNotNull); - expect(result!.label, equals('New Label')); - }); - - testWidgets('Save with empty label returns null label', (tester) async { - LabelColorResult? result; - - await tester.pumpWidget( - _buildDialogApp(onResult: (r) => result = r, currentLabel: null), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(result, isNotNull); - expect(result!.label, isNull); - }); - - testWidgets('Cancel returns null result', (tester) async { - LabelColorResult? result; - var resultSet = false; - - await tester.pumpWidget( - _buildDialogApp( - onResult: (r) { - result = r; - resultSet = true; - }, - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - expect(find.text('Cancel'), findsOneWidget); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - - expect(resultSet, isTrue); - expect(result, isNull); - }); - - testWidgets('Enter in text field submits the dialog', (tester) async { - LabelColorResult? result; - - await tester.pumpWidget(_buildDialogApp(onResult: (r) => result = r)); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextField), 'Via Enter'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); - - expect(result, isNotNull); - expect(result!.label, equals('Via Enter')); - }); - - testWidgets('returns selected color in result', (tester) async { - LabelColorResult? result; - - await tester.pumpWidget( - _buildDialogApp( - onResult: (r) => result = r, - currentColor: CardColor.none, - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - // Tap a color chip to change selection - final containers = find.byType(AnimatedContainer); - await tester.tap(containers.at(2)); // tap second color chip - await tester.pumpAndSettle(); - - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(result, isNotNull); - // color was changed from none - expect(result!.color, isA()); - }); - - testWidgets('shows with no label and none color by default via show()', ( - tester, - ) async { - LabelColorResult? result; - - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.light(), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold( - body: Builder( - builder: (ctx) => ElevatedButton( - onPressed: () async { - result = await LabelColorDialog.show(ctx); // defaults only - }, - child: const Text('Open'), - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(result, isNotNull); - expect(result!.color, equals(CardColor.none)); - }); - - testWidgets('dark mode renders without error', (tester) async { - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData.dark(), - home: CopyPasteTheme( - themeData: CompactTheme(), - child: const Scaffold( - body: LabelColorDialog( - currentLabel: null, - currentColor: CardColor.none, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byType(LabelColorDialog), findsOneWidget); - }); - - testWidgets('button hover state changes appearance', (tester) async { - await tester.pumpWidget( - _buildApp( - const LabelColorDialog( - currentLabel: null, - currentColor: CardColor.none, - ), - ), - ); - await tester.pumpAndSettle(); - - // Hover over Save button - final saveButton = find.text('Save'); - expect(saveButton, findsOneWidget); - final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); - await gesture.moveTo(tester.getCenter(saveButton)); - await tester.pumpAndSettle(); - - // Hover over Cancel button - final cancelButton = find.text('Cancel'); - await gesture.moveTo(tester.getCenter(cancelButton)); - await tester.pumpAndSettle(); - - expect(find.byType(LabelColorDialog), findsOneWidget); - }); - }); -} diff --git a/app/test/widgets/title_bar_test.dart b/app/test/widgets/title_bar_test.dart deleted file mode 100644 index e904edd0..00000000 --- a/app/test/widgets/title_bar_test.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/widgets/title_bar.dart'; - -import '../helpers/test_wrapper.dart'; - -void _setupWindowManagerMock() { - const channel = MethodChannel('window_manager'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async => null); -} - -void _clearWindowManagerMock() { - const channel = MethodChannel('window_manager'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); -} - -void main() { - setUp(_setupWindowManagerMock); - tearDown(_clearWindowManagerMock); - - group('TitleBar', () { - testWidgets('renders search box', (tester) async { - final controller = TextEditingController(); - final focusNode = FocusNode(); - - await tester.pumpWidget( - wrapWidget( - TitleBar( - searchController: controller, - searchFocusNode: focusNode, - onSearchChanged: (_) {}, - trailing: null, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(TextField), findsOneWidget); - - controller.dispose(); - focusNode.dispose(); - }); - - testWidgets('shows trailing widget when provided', (tester) async { - final controller = TextEditingController(); - final focusNode = FocusNode(); - - await tester.pumpWidget( - wrapWidget( - TitleBar( - searchController: controller, - searchFocusNode: focusNode, - onSearchChanged: (_) {}, - trailing: const Icon(Icons.settings, key: Key('trailing')), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('trailing')), findsOneWidget); - - controller.dispose(); - focusNode.dispose(); - }); - - testWidgets('onSearchChanged fires after 300ms debounce', (tester) async { - final controller = TextEditingController(); - final focusNode = FocusNode(); - final captured = []; - - await tester.pumpWidget( - wrapWidget( - TitleBar( - searchController: controller, - searchFocusNode: focusNode, - onSearchChanged: captured.add, - trailing: null, - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextField), 'hello'); - // Before debounce fires — nothing yet - await tester.pump(const Duration(milliseconds: 100)); - expect(captured, isEmpty); - - // After debounce - await tester.pump(const Duration(milliseconds: 300)); - expect(captured, ['hello']); - - controller.dispose(); - focusNode.dispose(); - }); - - testWidgets('clear button appears when text is non-empty', (tester) async { - final controller = TextEditingController(text: 'abc'); - final focusNode = FocusNode(); - - await tester.pumpWidget( - wrapWidget( - TitleBar( - searchController: controller, - searchFocusNode: focusNode, - onSearchChanged: (_) {}, - trailing: null, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.close_rounded), findsOneWidget); - - controller.dispose(); - focusNode.dispose(); - }); - - testWidgets('tapping clear button clears text and fires onChanged', ( - tester, - ) async { - final controller = TextEditingController(text: 'abc'); - final focusNode = FocusNode(); - final captured = []; - - await tester.pumpWidget( - wrapWidget( - TitleBar( - searchController: controller, - searchFocusNode: focusNode, - onSearchChanged: captured.add, - trailing: null, - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byIcon(Icons.close_rounded)); - // DragToMoveArea has onDoubleTap on Windows, which delays single-tap - // resolution by ~300ms. Advance past that timeout. - await tester.pump(const Duration(milliseconds: 350)); - - expect(controller.text, isEmpty); - expect(captured, contains('')); - - controller.dispose(); - focusNode.dispose(); - }); - - testWidgets('focus changes visual state', (tester) async { - final controller = TextEditingController(); - final focusNode = FocusNode(); - - await tester.pumpWidget( - wrapWidget( - TitleBar( - searchController: controller, - searchFocusNode: focusNode, - onSearchChanged: (_) {}, - trailing: null, - ), - ), - ); - await tester.pumpAndSettle(); - - // Request focus directly — tapping inside DragToMoveArea has a - // double-tap delay on Windows that makes tap-to-focus unreliable. - focusNode.requestFocus(); - await tester.pump(); - - expect(focusNode.hasFocus, isTrue); - - controller.dispose(); - focusNode.dispose(); - }); - - testWidgets('clear button not shown when text is empty', (tester) async { - final controller = TextEditingController(); - final focusNode = FocusNode(); - - await tester.pumpWidget( - wrapWidget( - TitleBar( - searchController: controller, - searchFocusNode: focusNode, - onSearchChanged: (_) {}, - trailing: null, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.close_rounded), findsNothing); - - controller.dispose(); - focusNode.dispose(); - }); - }); -} diff --git a/app/test_output.txt b/app/test_output.txt deleted file mode 100644 index 118b8055..00000000 --- a/app/test_output.txt +++ /dev/null @@ -1,32 +0,0 @@ -Resolving dependencies in `Z:\Code\Personal\CopyPaste`... -Downloading packages... - _fe_analyzer_shared 93.0.0 (100.0.0 available) - analyzer 10.0.1 (13.0.0 available) - build_runner 2.13.1 (2.14.0 available) - cli_util 0.4.2 (0.5.0 available) - dart_style 3.1.7 (3.1.8 available) - file_picker 10.3.10 (11.0.2 available) - hooks 1.0.2 (1.0.3 available) - meta 1.17.0 (1.18.2 available) - native_toolchain_c 0.17.6 (0.18.0 available) - sqlite3_flutter_libs 0.5.42 (0.6.0+eol available) - test 1.30.0 (1.31.0 available) - test_api 0.7.10 (0.7.11 available) - test_core 0.6.16 (0.6.17 available) - vector_math 2.2.0 (2.3.0 available) - win32 5.15.0 (6.1.0 available) -Got dependencies in `Z:\Code\Personal\CopyPaste`! -15 packages have newer versions incompatible with dependency constraints. -Try `flutter pub outdated` for more information. - -Oops; flutter has exited unexpectedly: "PathExistsException: Cannot copy file to 'Z:\Code\Personal\CopyPaste\app\build\native_assets\windows\sqlite3.dll', path = 'Z:\Code\Personal\CopyPaste\.dart_tool\hooks_runner\shared\sqlite3\build\download-41b7e2c\sqlite3.dll' (OS Error: No se puede crear un archivo que ya existe, errno = 183)". -A crash report has been written to Z:\Code\Personal\CopyPaste\app\flutter_12.log -This crash may already be reported. Check GitHub for similar crashes. -https://github.com/flutter/flutter/issues?q=is%3Aissue+PathExistsException%3A+Cannot+copy+file+to+%27Z%3A%5CCode%5CPersonal%5CCopyPaste%5Capp%5Cbuild%5Cnative_assets%5Cwindows%5Csqlite3.dll%27%2C+path+%3D+%27Z%3A%5CCode%5CPersonal%5CCopyPaste%5C.dart_tool%5Chooks_runner%5Cshared%5Csqlite3%5Cbuild%5Cdownload-41b7e2c%5Csqlite3.dll%27+%28OS+Error%3A+No+se+puede+crear+un+archivo+que+ya+existe%2C+errno+%3D+183%29 - -To report your crash to the Flutter team, first read the guide to filing a bug. -https://flutter.dev/to/report-bugs - -Create a new GitHub issue by pasting this link into your browser and completing the issue template. Thank you! -https://github.com/flutter/flutter/issues/new?title=%5Btool_crash%5D+FileSystemException%3A+Cannot+copy+file+to+%27Z%3A%5CCode%5CPersonal%5CCopyPaste%5Capp%5Cbuild%5Cnative_assets%5Cwindows%5Csqlite3.dll%27%2C+OS+Error%3A+No+se+puede+crear+un+archivo+que+ya+existe%2C+errno+%3D+183&body=%23%23+Command%0A%60%60%60sh%0Aflutter+test%0A%60%60%60%0A%0A%23%23+Steps+to+Reproduce%0A1.+...%0A2.+...%0A3.+...%0A%0A%23%23+Logs%0AFileSystemException%3A+Cannot+copy+file+to+%27Z%3A%5CCode%5CPersonal%5CCopyPaste%5Capp%5Cbuild%5Cnative_assets%5Cwindows%5Csqlite3.dll%27%2C+OS+Error%3A+No+se+puede+crear+un+archivo+que+ya+existe%2C+errno+%3D+183%0A%60%60%60console%0A%230++++++_checkForErrorResponse+%28dart%3Aio%2Fcommon.dart%3A58%3A9%29%0A%231++++++_File.copy.%3Canonymous+closure%3E+%28dart%3Aio%2Ffile_impl.dart%3A406%3A7%29%0A%232++++++_rootRunUnary+%28dart%3Aasync%2Fzone_root.dart%3A48%3A47%29%0A%233++++++_CustomZone.runUnary+%28dart%3Aasync%2Fzone.dart%3A733%3A19%29%0A%3Casynchronous+suspension%3E%0A%234++++++ForwardingFile.copy+%28package%3Afile%2Fsrc%2Fforwarding%2Fforwarding_file.dart%3A29%3A51%29%0A%3Casynchronous+suspension%3E%0A%235++++++ForwardingFile.copy+%28package%3Afile%2Fsrc%2Fforwarding%2Fforwarding_file.dart%3A29%3A51%29%0A%3Casynchronous+suspension%3E%0A%236++++++_copyNativeCodeAssetsToBundleOnWindowsLinux+%28package%3Aflutter_tools%2Fsrc%2Fisolated%2Fnative_assets%2Fnative_assets.dart%3A649%3A5%29%0A%3Casynchronous+suspension%3E%0A%237++++++_copyNativeCodeAssetsForOS+%28package%3Aflutter_tools%2Fsrc%2Fisolated%2Fnative_assets%2Fnative_assets.dart%3A463%3A7%29%0A%3Casynchronous+suspension%3E%0A%238++++++installCodeAssets+%28package%3Aflutter_tools%2Fsrc%2Fisolated%2Fnative_assets%2Fnative_assets.dart%3A119%3A3%29%0A%3Casynchronous+suspension%3E%0A%239++++++testCompilerBuildNativeAssets+%28package%3Aflutter_tools%2Fsrc%2Fisolated%2Fnative_assets%2Ftest%2Fnative_assets.dart%3A79%3A3%29%0A%3Casynchronous+suspension%3E%0A%2310+++++TestCommand.runCommand+%28package%3Aflutter_tools%2Fsrc%2Fcommands%2Ftest.dart%3A484%3A11%29%0A%3Casynchronous+suspension%3E%0A%2311+++++FlutterCommand.run.%3Canonymous+closure%3E+%28package%3Aflutter_tools%2Fsrc%2Frunner%2Fflutter_command.dart%3A1590%3A27%29%0A%3Casynchronous+suspension%3E%0A%2312+++++AppContext.run.%3Canonymous+closure%3E+%28package%3Aflutter_tools%2Fsrc%2Fbase%2Fcontext.dart%3A154%3A19%29%0A%3Casynchronous+suspension%3E%0A%2313+++++CommandRunner.runCommand+%28package%3Aargs%2Fcommand_runner.dart%3A212%3A13%29%0A%3Casynchronous+suspension%3E%0A%60%60%60%0A%60%60%60console%0A%5B%E2%9C%93%5D+Flutter+%28Channel+stable%2C+3.41.6%2C+on+Microsoft+Windows+%5BVersi%C2%A2n+10.0.26200.8246%5D%2C+locale+es-CL%29+%5B165ms%5D%0A++++%E2%80%A2+Flutter+version+3.41.6+on+channel+stable+at+Z%3A%5Cflutter%0A++++%E2%80%A2+Upstream+repository+https%3A%2F%2Fgithub.com%2Fflutter%2Fflutter.git%0A++++%E2%80%A2+Framework+revision+db50e20168+%284+weeks+ago%29%2C+2026-03-25+16%3A21%3A00+-0700%0A++++%E2%80%A2+Engine+revision+425cfb54d0%0A++++%E2%80%A2+Dart+version+3.11.4%0A++++%E2%80%A2+DevTools+version+2.54.2%0A++++%E2%80%A2+Feature+flags%3A+enable-web%2C+enable-linux-desktop%2C+enable-macos-desktop%2C+enable-windows-desktop%2C+enable-android%2C+enable-ios%2C+cli-animations%2C+enable-native-assets%2C+omit-legacy-version-file%2C+enable-lldb-debugging%2C+enable-uiscene-migration%0A%0A%5B%E2%9C%93%5D+Windows+Version+%28Windows+11+or+higher%2C+25H2%2C+2009%29+%5B401ms%5D%0A%0A%5B%E2%9C%97%5D+Android+toolchain+-+develop+for+Android+devices+%5B59ms%5D%0A++++%E2%9C%97+Unable+to+locate+Android+SDK.%0A++++++Install+Android+Studio+from%3A+https%3A%2F%2Fdeveloper.android.com%2Fstudio%2Findex.html%0A++++++On+first+launch+it+will+assist+you+in+installing+the+Android+SDK+components.%0A++++++%28or+visit+https%3A%2F%2Fflutter.dev%2Fto%2Fwindows-android-setup+for+detailed+instructions%29.%0A++++++If+the+Android+SDK+has+been+installed+to+a+custom+location%2C+please+use%0A++++++%60flutter+config+--android-sdk%60+to+update+to+that+location.%0A%0A%0A%5B%E2%9C%93%5D+Chrome+-+develop+for+the+web+%5B48ms%5D%0A++++%E2%80%A2+Chrome+at+C%3A%5CProgram+Files%5CGoogle%5CChrome%5CApplication%5Cchrome.exe%0A%0A%5B%E2%9C%93%5D+Visual+Studio+-+develop+Windows+apps+%28Visual+Studio+Community+2026+18.4.4%29+%5B48ms%5D%0A++++%E2%80%A2+Visual+Studio+at+C%3A%5CProgram+Files%5CMicrosoft+Visual+Studio%5C18%5CCommunity%0A++++%E2%80%A2+Visual+Studio+Community+2026+version+18.4.11702.344%0A++++%E2%80%A2+Windows+10+SDK+version+10.0.26100.0%0A%0A%5B%E2%9C%93%5D+Connected+device+%282+available%29+%5B53ms%5D%0A++++%E2%80%A2+Windows+%28desktop%29+%E2%80%A2+windows+%E2%80%A2+windows-x64++++%E2%80%A2+Microsoft+Windows+%5BVersi%C2%A2n+10.0.26200.8246%5D%0A++++%E2%80%A2+Chrome+%28web%29++++++%E2%80%A2+chrome++%E2%80%A2+web-javascript+%E2%80%A2+Google+Chrome+147.0.7727.116%0A%0A%5B%E2%9C%93%5D+Network+resources+%5B261ms%5D%0A++++%E2%80%A2+All+expected+network+resources+are+available.%0A%0A%21+Doctor+found+issues+in+1+category.%0A%0A%60%60%60%0A%0A%23%23+Flutter+Application+Metadata%0A%2A%2AType%2A%2A%3A+app%0A%2A%2AVersion%2A%2A%3A+0.0.0-dev%0A%2A%2AMaterial%2A%2A%3A+true%0A%2A%2AAndroid+X%2A%2A%3A+false%0A%2A%2AModule%2A%2A%3A+false%0A%2A%2APlugin%2A%2A%3A+false%0A%2A%2AAndroid+package%2A%2A%3A+null%0A%2A%2AiOS+bundle+identifier%2A%2A%3A+null%0A%2A%2ACreation+channel%2A%2A%3A+stable%0A%2A%2ACreation+framework+version%2A%2A%3A+48c32af0345e9ad5747f78ddce828c7f795f7159%0A%23%23%23+Plugins%0Afile_picker-10.3.10%0Apath_provider_foundation-2.6.0%0Asqlite3_flutter_libs-0.5.42%0Aflutter_plugin_android_lifecycle-2.0.34%0Ajni-1.0.0%0Ajni_flutter-1.0.1%0Apath_provider_android-2.3.1%0Aauto_updater_macos-1.0.0%0Ahotkey_manager_macos-0.2.0%0Alistener%0Amacos_window_utils-1.9.1%0Ascreen_retriever_macos-0.2.0%0Atray_manager-0.5.2%0Awindow_manager-0.5.1%0Aflutter_acrylic-1.1.4%0Ahotkey_manager_linux-0.2.0%0Apath_provider_linux-2.2.1%0Ascreen_retriever_linux-0.2.0%0Aauto_updater_windows-1.0.0%0Ahotkey_manager_windows-0.2.0%0Apath_provider_windows-2.3.0%0Ascreen_retriever_windows-0.2.0%0A%0A&labels=tool%2Csevere%3A+crash - diff --git a/app/tool/windows_plain_paste_e2e.ps1 b/app/tool/windows_plain_paste_e2e.ps1 deleted file mode 100644 index 6e29cc4d..00000000 --- a/app/tool/windows_plain_paste_e2e.ps1 +++ /dev/null @@ -1,459 +0,0 @@ -param( - [string]$ExpectedText = "CopyPaste plain-text hotkey E2E", - [switch]$BaselineCtrlV, - [switch]$PanelPlainPaste, - [switch]$PanelGlobalPlainPaste, - [ValidateRange(0, 1400)] - [int]$HoldModifiersMs = 400, - [string]$ConfigPath = (Join-Path $env:LOCALAPPDATA 'CopyPaste\config\config.json') -) - -$ErrorActionPreference = 'Stop' -if (($BaselineCtrlV -and $PanelPlainPaste) -or - ($BaselineCtrlV -and $PanelGlobalPlainPaste) -or - ($PanelPlainPaste -and $PanelGlobalPlainPaste)) { - throw 'BaselineCtrlV, PanelPlainPaste, and PanelGlobalPlainPaste are mutually exclusive.' -} -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing -$probeSource = @' -using System; -using System.Drawing; -using System.Runtime.InteropServices; -using System.Windows.Forms; - -public sealed class CopyPasteHotkeyProbeForm : Form { - private const uint KeyUpFlag = 0x0002; - private readonly string expected; - private readonly byte virtualKey; - private readonly bool useCtrl; - private readonly bool useWin; - private readonly bool useAlt; - private readonly bool useShift; - private readonly int holdModifiersMs; - private readonly bool panelPlainPaste; - private readonly bool panelGlobalPlainPaste; - private readonly byte followupVirtualKey; - private readonly bool followupUseCtrl; - private readonly bool followupUseWin; - private readonly bool followupUseAlt; - private readonly bool followupUseShift; - private readonly TextBox input; - private readonly Timer trigger; - private readonly Timer releaseModifiers; - private readonly Timer panelPasteTrigger; - private readonly Timer panelGlobalPasteTrigger; - private readonly Timer verify; - private string actualText = ""; - private string diagnostics = "not triggered"; - private DateTime hotkeyTriggeredAt; - private int pasteElapsedMs = -1; - - [DllImport("user32.dll")] - private static extern bool SetForegroundWindow(IntPtr window); - - [DllImport("user32.dll")] - private static extern IntPtr GetForegroundWindow(); - - [DllImport("user32.dll")] - private static extern uint GetWindowThreadProcessId( - IntPtr window, - IntPtr processId); - - [DllImport("kernel32.dll")] - private static extern uint GetCurrentThreadId(); - - [DllImport("user32.dll")] - private static extern bool AttachThreadInput( - uint attachThread, - uint attachToThread, - bool attach); - - [DllImport("user32.dll")] - private static extern bool BringWindowToTop(IntPtr window); - - [DllImport("user32.dll")] - private static extern IntPtr SetFocus(IntPtr window); - - [DllImport("user32.dll")] - private static extern void keybd_event( - byte virtualKey, - byte scanCode, - uint flags, - UIntPtr extraInfo); - - public CopyPasteHotkeyProbeForm( - string expected, - int virtualKey, - bool useCtrl, - bool useWin, - bool useAlt, - bool useShift, - int holdModifiersMs, - bool panelPlainPaste, - bool panelGlobalPlainPaste, - int followupVirtualKey, - bool followupUseCtrl, - bool followupUseWin, - bool followupUseAlt, - bool followupUseShift) { - if (virtualKey <= 0 || virtualKey > 0xFF) { - throw new ArgumentOutOfRangeException("virtualKey"); - } - this.expected = expected; - this.virtualKey = (byte)virtualKey; - this.useCtrl = useCtrl; - this.useWin = useWin; - this.useAlt = useAlt; - this.useShift = useShift; - this.holdModifiersMs = holdModifiersMs; - this.panelPlainPaste = panelPlainPaste; - this.panelGlobalPlainPaste = panelGlobalPlainPaste; - this.followupVirtualKey = (byte)followupVirtualKey; - this.followupUseCtrl = followupUseCtrl; - this.followupUseWin = followupUseWin; - this.followupUseAlt = followupUseAlt; - this.followupUseShift = followupUseShift; - Text = "CopyPaste hotkey E2E probe"; - Size = new Size(620, 180); - StartPosition = FormStartPosition.CenterScreen; - TopMost = true; - - input = new TextBox { - Multiline = true, - Dock = DockStyle.Fill, - Font = new Font("Segoe UI", 14) - }; - Controls.Add(input); - - trigger = new Timer { Interval = 700 }; - trigger.Tick += TriggerHotkey; - releaseModifiers = new Timer { Interval = Math.Max(1, holdModifiersMs) }; - releaseModifiers.Tick += delegate { - releaseModifiers.Stop(); - ReleaseShortcutModifiers(); - }; - panelPasteTrigger = new Timer { - Interval = Math.Max(800, holdModifiersMs + 200) - }; - panelPasteTrigger.Tick += delegate { - panelPasteTrigger.Stop(); - if (panelPlainPaste) { - hotkeyTriggeredAt = DateTime.UtcNow; - Press(0x10); - Press(0x0D); - Release(0x0D); - Release(0x10); - diagnostics += ", shiftEnterSent=true"; - } - else if (panelGlobalPlainPaste) { - // Move away from the popup so hover cannot outrank the - // keyboard selection. Select the second history item; the - // global follow-up must still paste the current clipboard. - Cursor.Position = new Point(0, 0); - Press(0x28); - Release(0x28); - Press(0x28); - Release(0x28); - panelGlobalPasteTrigger.Start(); - } - }; - panelGlobalPasteTrigger = new Timer { Interval = 250 }; - panelGlobalPasteTrigger.Tick += delegate { - panelGlobalPasteTrigger.Stop(); - hotkeyTriggeredAt = DateTime.UtcNow; - if (followupUseCtrl) Press(0x11); - if (followupUseWin) Press(0x5B); - if (followupUseAlt) Press(0x12); - if (followupUseShift) Press(0x10); - Press(this.followupVirtualKey); - Release(this.followupVirtualKey); - if (followupUseShift) Release(0x10); - if (followupUseAlt) Release(0x12); - if (followupUseWin) Release(0x5B); - if (followupUseCtrl) Release(0x11); - diagnostics += ", secondItemSelected=true, globalPlainSent=true"; - }; - verify = new Timer { Interval = 3200 }; - verify.Tick += delegate { - verify.Stop(); - actualText = input.Text; - Close(); - }; - Shown += delegate { - Clipboard.SetText(expected); - input.Focus(); - trigger.Start(); - verify.Start(); - }; - } - - public string ActualText { get { return actualText; } } - public string Diagnostics { get { return diagnostics; } } - public int PasteElapsedMs { get { return pasteElapsedMs; } } - - private void TriggerHotkey(object sender, EventArgs args) { - trigger.Stop(); - Activate(); - bool accepted = ForceForeground(); - diagnostics = "accepted=" + accepted - + ", form=" + Handle - + ", foreground=" + GetForegroundWindow() - + ", inputFocused=" + input.Focused - + ", clipboard=" + Clipboard.GetText(); - hotkeyTriggeredAt = DateTime.UtcNow; - - if (useCtrl) Press(0x11); - if (useWin) Press(0x5B); - if (useAlt) Press(0x12); - if (useShift) Press(0x10); - Press(virtualKey); - Release(virtualKey); - if (holdModifiersMs > 0) { - releaseModifiers.Start(); - } - else { - ReleaseShortcutModifiers(); - } - if (panelPlainPaste || panelGlobalPlainPaste) { - panelPasteTrigger.Start(); - } - } - - private void ReleaseShortcutModifiers() { - if (useShift) Release(0x10); - if (useAlt) Release(0x12); - if (useWin) Release(0x5B); - if (useCtrl) Release(0x11); - } - - private bool ForceForeground() { - IntPtr foreground = GetForegroundWindow(); - uint foregroundThread = GetWindowThreadProcessId(foreground, IntPtr.Zero); - uint currentThread = GetCurrentThreadId(); - bool attached = foregroundThread != 0 - && foregroundThread != currentThread - && AttachThreadInput(currentThread, foregroundThread, true); - try { - BringWindowToTop(Handle); - bool accepted = SetForegroundWindow(Handle); - SetFocus(input.Handle); - return accepted || GetForegroundWindow() == Handle; - } - finally { - if (attached) AttachThreadInput(currentThread, foregroundThread, false); - } - } - - private static void Press(byte key) { - keybd_event(key, 0, 0, UIntPtr.Zero); - } - - private static void Release(byte key) { - keybd_event(key, 0, KeyUpFlag, UIntPtr.Zero); - } - - protected override bool ProcessCmdKey(ref Message message, Keys keyData) { - if (keyData == (Keys.Control | Keys.V)) { - pasteElapsedMs = (int)(DateTime.UtcNow - hotkeyTriggeredAt).TotalMilliseconds; - input.Text = Clipboard.GetText(); - diagnostics += ", ctrlVReceived=true"; - return true; - } - return base.ProcessCmdKey(ref message, keyData); - } - - protected override void Dispose(bool disposing) { - if (disposing) { - trigger.Dispose(); - releaseModifiers.Dispose(); - panelPasteTrigger.Dispose(); - panelGlobalPasteTrigger.Dispose(); - verify.Dispose(); - input.Dispose(); - } - base.Dispose(disposing); - } -} - -public static class CopyPasteHotkeyProbe { - public static CopyPasteHotkeyProbeResult Run( - string expected, - int virtualKey, - bool useCtrl, - bool useWin, - bool useAlt, - bool useShift, - int holdModifiersMs, - bool panelPlainPaste, - bool panelGlobalPlainPaste, - int followupVirtualKey, - bool followupUseCtrl, - bool followupUseWin, - bool followupUseAlt, - bool followupUseShift) { - using (var form = new CopyPasteHotkeyProbeForm( - expected, virtualKey, useCtrl, useWin, useAlt, useShift, - holdModifiersMs, panelPlainPaste, panelGlobalPlainPaste, - followupVirtualKey, followupUseCtrl, followupUseWin, - followupUseAlt, followupUseShift)) { - Application.Run(form); - return new CopyPasteHotkeyProbeResult { - Actual = form.ActualText, - Diagnostics = form.Diagnostics, - PasteElapsedMs = form.PasteElapsedMs - }; - } - } -} - -public sealed class CopyPasteHotkeyProbeResult { - public string Actual { get; set; } - public string Diagnostics { get; set; } - public int PasteElapsedMs { get; set; } -} -'@ - -if ($PSVersionTable.PSEdition -eq 'Core') { - $references = @( - ([AppContext]::GetData('TRUSTED_PLATFORM_ASSEMBLIES') -split [IO.Path]::PathSeparator) - [System.Windows.Forms.Form].Assembly.Location - [System.Drawing.Font].Assembly.Location - [System.Drawing.Point].Assembly.Location - ) | Select-Object -Unique - Add-Type -TypeDefinition $probeSource -ReferencedAssemblies $references -} -else { - Add-Type -TypeDefinition $probeSource -ReferencedAssemblies System.Windows.Forms,System.Drawing -} - -$virtualKey = 0x56 -$useCtrl = $true -$useWin = $false -$useAlt = $false -$useShift = $false -$followupVirtualKey = 0x56 -$followupUseCtrl = $true -$followupUseWin = $false -$followupUseAlt = $true -$followupUseShift = $false -$binding = 'Ctrl+V (baseline)' -if ($PanelPlainPaste -or $PanelGlobalPlainPaste) { - if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { - throw "CopyPaste config not found: $ConfigPath" - } - $config = Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json - $virtualKey = [int]$config.hotkeyVirtualKey - $useCtrl = [bool]$config.hotkeyUseCtrl - $useWin = [bool]$config.hotkeyUseWin - $useAlt = [bool]$config.hotkeyUseAlt - $useShift = [bool]$config.hotkeyUseShift - $followupVirtualKey = [int]$config.plainPasteHotkeyVirtualKey - $followupUseCtrl = [bool]$config.plainPasteHotkeyUseCtrl - $followupUseWin = [bool]$config.plainPasteHotkeyUseWin - $followupUseAlt = [bool]$config.plainPasteHotkeyUseAlt - $followupUseShift = [bool]$config.plainPasteHotkeyUseShift - $parts = @() - if ($useCtrl) { $parts += 'Ctrl' } - if ($useWin) { $parts += 'Win' } - if ($useAlt) { $parts += 'Alt' } - if ($useShift) { $parts += 'Shift' } - $parts += [string]$config.hotkeyKeyName - $panelAction = if ($PanelPlainPaste) { - ' -> Shift+Enter' - } else { - ' -> select second -> global plain paste' - } - $binding = ($parts -join '+') + $panelAction -} -elseif (-not $BaselineCtrlV) { - if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { - throw "CopyPaste config not found: $ConfigPath" - } - $config = Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json - if ($config.plainPasteHotkeyEnabled -ne $true) { - throw 'The global plain-paste hotkey must be enabled before running this probe.' - } - $virtualKey = [int]$config.plainPasteHotkeyVirtualKey - $useCtrl = [bool]$config.plainPasteHotkeyUseCtrl - $useWin = [bool]$config.plainPasteHotkeyUseWin - $useAlt = [bool]$config.plainPasteHotkeyUseAlt - $useShift = [bool]$config.plainPasteHotkeyUseShift - $parts = @() - if ($useCtrl) { $parts += 'Ctrl' } - if ($useWin) { $parts += 'Win' } - if ($useAlt) { $parts += 'Alt' } - if ($useShift) { $parts += 'Shift' } - $parts += [string]$config.plainPasteHotkeyKeyName - $binding = $parts -join '+' -} - -function Copy-ClipboardDataObject { - $source = [System.Windows.Forms.Clipboard]::GetDataObject() - if ($null -eq $source) { return $null } - $snapshot = [System.Windows.Forms.DataObject]::new() - foreach ($format in $source.GetFormats($false)) { - try { - $data = $source.GetData($format, $false) - if ($null -ne $data) { $snapshot.SetData($format, $false, $data) } - } - catch { - Write-Verbose "Could not snapshot clipboard format '$format'." - } - } - return $snapshot -} - -$originalClipboard = Copy-ClipboardDataObject -try { - $result = [CopyPasteHotkeyProbe]::Run( - $ExpectedText, - $virtualKey, - $useCtrl, - $useWin, - $useAlt, - $useShift, - $HoldModifiersMs, - [bool]$PanelPlainPaste, - [bool]$PanelGlobalPlainPaste, - $followupVirtualKey, - $followupUseCtrl, - $followupUseWin, - $followupUseAlt, - $followupUseShift) - $actual = $result.Actual - $pasteBeforeModifierRelease = $PanelPlainPaste -or - $PanelGlobalPlainPaste -or $HoldModifiersMs -eq 0 -or ( - $result.PasteElapsedMs -ge 0 -and - $result.PasteElapsedMs -lt $HoldModifiersMs - ) - $success = $actual -eq $ExpectedText -and $pasteBeforeModifierRelease - [pscustomobject]@{ - success = $success - expected = $ExpectedText - actual = $actual - binding = $binding - holdModifiersMs = $HoldModifiersMs - pasteElapsedMs = $result.PasteElapsedMs - pasteBeforeModifierRelease = $pasteBeforeModifierRelease - panelPlainPaste = [bool]$PanelPlainPaste - panelGlobalPlainPaste = [bool]$PanelGlobalPlainPaste - diagnostics = $result.Diagnostics - } | ConvertTo-Json -Compress - if (-not $success) { exit 1 } -} -finally { - if ($null -ne $originalClipboard) { - try { - [System.Windows.Forms.Clipboard]::SetDataObject( - $originalClipboard, - $true, - 20, - 150 - ) - } - catch { - Write-Warning 'Could not restore the original clipboard.' - } - } -} diff --git a/app/tools/generate_release_keys.dart b/app/tools/generate_release_keys.dart deleted file mode 100644 index 68f7f9cd..00000000 --- a/app/tools/generate_release_keys.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:cryptography/cryptography.dart'; -import 'package:path/path.dart' as p; - -Future main() async { - final algorithm = Ed25519(); - final keyPair = await algorithm.newKeyPair(); - final pub = await keyPair.extractPublicKey(); - final priv = await keyPair.extractPrivateKeyBytes(); - - final pubB64 = base64Encode(pub.bytes); - final privB64 = base64Encode(priv); - - final repoRoot = Directory.current.path; - final pubFile = File( - p.join(repoRoot, 'app', 'assets', 'keys', 'release_pubkey.txt'), - ); - final distDir = Directory(p.join(repoRoot, 'dist')); - if (!distDir.existsSync()) distDir.createSync(recursive: true); - final privFile = File(p.join(distDir.path, 'release_privkey.txt')); - - pubFile.parent.createSync(recursive: true); - pubFile.writeAsStringSync('$pubB64\n'); - privFile.writeAsStringSync('$privB64\n'); - - if (!Platform.isWindows) { - Process.runSync('chmod', ['600', privFile.path]); - } - - // ignore: avoid_print - print('Public key: ${pubFile.path}'); - // ignore: avoid_print - print('Private key: ${privFile.path}'); - // ignore: avoid_print - print(''); - // ignore: avoid_print - print('Upload the private key as the GitHub secret RELEASE_PRIVATE_KEY:'); - // ignore: avoid_print - print(' gh secret set RELEASE_PRIVATE_KEY < ${privFile.path}'); -} diff --git a/app/tools/sign_manifest.dart b/app/tools/sign_manifest.dart deleted file mode 100644 index 82560d5a..00000000 --- a/app/tools/sign_manifest.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:cryptography/cryptography.dart'; - -Future main(List args) async { - if (args.length < 2) { - stderr.writeln( - 'Usage: dart run app/tools/sign_manifest.dart ', - ); - stderr.writeln('Reads the base64 Ed25519 private key from stdin.'); - exit(64); - } - - final inputPath = args[0]; - final outputPath = args[1]; - - final inputFile = File(inputPath); - if (!inputFile.existsSync()) { - stderr.writeln('Input file not found: $inputPath'); - exit(66); - } - - final privKeyB64 = stdin - .transform(utf8.decoder) - .transform(const LineSplitter()) - .where((l) => l.trim().isNotEmpty); - final privLines = await privKeyB64.toList(); - if (privLines.isEmpty) { - stderr.writeln('No private key on stdin.'); - exit(65); - } - final privBytes = base64Decode(privLines.first.trim()); - - final algorithm = Ed25519(); - final keyPair = await algorithm.newKeyPairFromSeed(privBytes); - final bytes = await inputFile.readAsBytes(); - final signature = await algorithm.sign(bytes, keyPair: keyPair); - - final sigB64 = base64Encode(signature.bytes); - await File(outputPath).writeAsString('$sigB64\n'); - - // ignore: avoid_print - print('Signed $inputPath -> $outputPath'); -} diff --git a/app/windows/.gitignore b/app/windows/.gitignore deleted file mode 100644 index d492d0d9..00000000 --- a/app/windows/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -flutter/ephemeral/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ diff --git a/app/windows/CMakeLists.txt b/app/windows/CMakeLists.txt deleted file mode 100644 index 0d84a858..00000000 --- a/app/windows/CMakeLists.txt +++ /dev/null @@ -1,114 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.14) -project(CopyPaste LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "CopyPaste") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(VERSION 3.14...3.25) - -# Define build configuration option. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() -# Define settings for the Profile build mode. -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - -# Use Unicode for all projects. -add_definitions(-DUNICODE -D_UNICODE) - -# C++/WinRT (used by the runner's startup-task channel) pulls in -# when compiled as C++17. MSVC 14.51+ turned that -# header's deprecation into a hard error (STL1011); silence it until the runner -# moves to the C++20 header. -add_definitions(-D_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS) - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) diff --git a/app/windows/flutter/CMakeLists.txt b/app/windows/flutter/CMakeLists.txt deleted file mode 100644 index 903f4899..00000000 --- a/app/windows/flutter/CMakeLists.txt +++ /dev/null @@ -1,109 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# Set fallback configurations for older versions of the flutter tool. -if (NOT DEFINED FLUTTER_TARGET_PLATFORM) - set(FLUTTER_TARGET_PLATFORM "windows-x64") -endif() - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - ${FLUTTER_TARGET_PLATFORM} $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 02846a29..00000000 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,29 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include -#include -#include -#include -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - FlutterAcrylicPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlutterAcrylicPlugin")); - HotkeyManagerWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi")); - ListenerPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ListenerPluginCApi")); - ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); - TrayManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("TrayManagerPlugin")); - WindowManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WindowManagerPlugin")); -} diff --git a/app/windows/flutter/generated_plugin_registrant.h b/app/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85..00000000 --- a/app/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake deleted file mode 100644 index 5241f1ba..00000000 --- a/app/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,30 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - flutter_acrylic - hotkey_manager_windows - listener - screen_retriever_windows - tray_manager - window_manager -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/app/windows/packaging/exe/make_config.yaml b/app/windows/packaging/exe/make_config.yaml deleted file mode 100644 index 0f45ffae..00000000 --- a/app/windows/packaging/exe/make_config.yaml +++ /dev/null @@ -1,12 +0,0 @@ -app_id: "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" -script_template: setup_template.iss -display_name: CopyPaste -executable_name: CopyPaste -publisher_name: RGDevment -publisher_url: https://github.com/rgdevment/CopyPaste -install_dir_name: "{localappdata}\\CopyPaste" -privileges_required: lowest -setup_icon_file: windows/runner/resources/app_icon.ico -locales: - - en - - es diff --git a/app/windows/packaging/exe/setup_template.iss b/app/windows/packaging/exe/setup_template.iss deleted file mode 100644 index 7ba91edd..00000000 --- a/app/windows/packaging/exe/setup_template.iss +++ /dev/null @@ -1,43 +0,0 @@ -[Setup] -AppId={{APP_ID}} -AppVersion={{APP_VERSION}} -AppName={{DISPLAY_NAME}} -AppPublisher={{PUBLISHER_NAME}} -AppPublisherURL={{PUBLISHER_URL}} -AppSupportURL={{PUBLISHER_URL}} -AppUpdatesURL={{PUBLISHER_URL}} -DefaultDirName={{INSTALL_DIR_NAME}} -DisableProgramGroupPage=yes -DisableWelcomePage=yes -DisableDirPage=yes -DisableReadyPage=yes -DisableFinishedPage=yes -OutputDir=. -OutputBaseFilename={{OUTPUT_BASE_FILENAME}} -Compression=lzma -SolidCompression=yes -SetupIconFile={{SETUP_ICON_FILE}} -WizardStyle=modern -PrivilegesRequired={{PRIVILEGES_REQUIRED}} -ArchitecturesAllowed=x64 -ArchitecturesInstallIn64BitMode=x64 -Uninstallable=yes -CreateUninstallRegKey=yes - -[Languages] -{% for locale in LOCALES %} -{% if locale == 'en' %}Name: "english"; MessagesFile: "compiler:Default.isl"{% endif %} -{% if locale == 'es' %}Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl"{% endif %} -{% endfor %} - -[Files] -Source: "{{SOURCE_DIR}}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs - -[Icons] -Name: "{autoprograms}\{{DISPLAY_NAME}}"; Filename: "{app}\{{EXECUTABLE_NAME}}" - -[Registry] -Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: none; ValueName: "CopyPaste"; Flags: uninsdeletevalue - -[Run] -Filename: "{app}\{{EXECUTABLE_NAME}}"; Flags: nowait postinstall skipifsilent diff --git a/app/windows/packaging/msix/make_config.yaml b/app/windows/packaging/msix/make_config.yaml deleted file mode 100644 index 9d3678c7..00000000 --- a/app/windows/packaging/msix/make_config.yaml +++ /dev/null @@ -1,12 +0,0 @@ -display_name: CopyPaste - Clipboard Manager -publisher_display_name: RGDevment -publisher: CN=AC154EDC-DBFC-482B-AF3B-A1A6FF12DA84 -identity_name: rgdevment.CopyPaste-ClipboardManager -logo_path: assets/icons/icon_app_256.png -store: "true" -build_windows: "false" -languages: en-us, es-es -capabilities: internetClient -startup_task: - task_id: CopyPasteStartup - enabled: true diff --git a/app/windows/runner/CMakeLists.txt b/app/windows/runner/CMakeLists.txt deleted file mode 100644 index a8e04817..00000000 --- a/app/windows/runner/CMakeLists.txt +++ /dev/null @@ -1,42 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "startup_task_channel.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the build version. -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") - -# Disable Windows macros that collide with C++ standard library functions. -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") - -# Add dependency libraries and include directories. Add any application-specific -# dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") -target_link_libraries(${BINARY_NAME} PRIVATE "windowsapp.lib") -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/app/windows/runner/Runner.rc b/app/windows/runner/Runner.rc deleted file mode 100644 index a78a1d2c..00000000 --- a/app/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) -#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD -#else -#define VERSION_AS_NUMBER 1,0,0,0 -#endif - -#if defined(FLUTTER_VERSION) -#define VERSION_AS_STRING FLUTTER_VERSION -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "CopyPaste" "\0" - VALUE "FileDescription", "CopyPaste — Clipboard Manager" "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "CopyPaste" "\0" - VALUE "LegalCopyright", "Copyright (C) 2026 CopyPaste. All rights reserved." "\0" - VALUE "OriginalFilename", "CopyPaste.exe" "\0" - VALUE "ProductName", "CopyPaste" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/app/windows/runner/flutter_window.cpp b/app/windows/runner/flutter_window.cpp deleted file mode 100644 index c5ce3a16..00000000 --- a/app/windows/runner/flutter_window.cpp +++ /dev/null @@ -1,412 +0,0 @@ -#include "flutter_window.h" - -#include - -#include -#include -#include -#include - -#include "flutter/generated_plugin_registrant.h" -#include "startup_task_channel.h" - -namespace { - -constexpr char kHotkeyChannelName[] = "copypaste/windows_hotkeys"; -constexpr int kOpenHotkeyId = 0x4301; -constexpr int kPlainPasteHotkeyId = 0x4302; -constexpr UINT kModNoRepeat = 0x4000; - -const flutter::EncodableValue* FindArgument( - const flutter::EncodableMap& arguments, const char* name) { - const auto it = arguments.find(flutter::EncodableValue(name)); - return it == arguments.end() ? nullptr : &it->second; -} - -bool ReadBool(const flutter::EncodableMap& arguments, const char* name) { - const auto* value = FindArgument(arguments, name); - const auto* boolean = value == nullptr ? nullptr : std::get_if(value); - return boolean != nullptr && *boolean; -} - -bool ReadInt(const flutter::EncodableMap& arguments, const char* name, - int* result) { - const auto* value = FindArgument(arguments, name); - if (value == nullptr) return false; - if (const auto* int32 = std::get_if(value)) { - *result = *int32; - return true; - } - if (const auto* int64 = std::get_if(value)) { - *result = static_cast(*int64); - return true; - } - return false; -} - -int64_t ReadInt64(const flutter::EncodableMap& arguments, const char* name) { - const auto* value = FindArgument(arguments, name); - if (value == nullptr) return 0; - if (const auto* int32 = std::get_if(value)) return *int32; - if (const auto* int64 = std::get_if(value)) return *int64; - return 0; -} - -std::string ReadString(const flutter::EncodableMap& arguments, - const char* name) { - const auto* value = FindArgument(arguments, name); - const auto* string = - value == nullptr ? nullptr : std::get_if(value); - return string == nullptr ? std::string() : *string; -} - -flutter::EncodableValue RegistrationResponse(bool success, - DWORD win32_error = ERROR_SUCCESS) { - flutter::EncodableMap response; - response[flutter::EncodableValue("success")] = - flutter::EncodableValue(success); - if (!success) { - response[flutter::EncodableValue("errorCode")] = - flutter::EncodableValue("registerFailed"); - response[flutter::EncodableValue("win32Error")] = - flutter::EncodableValue(static_cast(win32_error)); - } - return flutter::EncodableValue(response); -} - -// Mandatory integrity level of a process, or 0 when it cannot be read. -DWORD ProcessIntegrityLevel(DWORD pid) { - if (pid == 0) return 0; - HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); - if (process == nullptr) return 0; - - DWORD level = 0; - HANDLE token = nullptr; - if (OpenProcessToken(process, TOKEN_QUERY, &token)) { - DWORD size = 0; - GetTokenInformation(token, TokenIntegrityLevel, nullptr, 0, &size); - if (size > 0) { - std::vector buffer(size); - if (GetTokenInformation(token, TokenIntegrityLevel, buffer.data(), size, - &size)) { - auto* label = reinterpret_cast(buffer.data()); - const UCHAR* count = GetSidSubAuthorityCount(label->Label.Sid); - if (count != nullptr && *count > 0) { - level = *GetSidSubAuthority(label->Label.Sid, *count - 1); - } - } - } - CloseHandle(token); - } - CloseHandle(process); - return level; -} - -// An active window is not the same as a usable focus: Chromium hosts (VS Code -// webviews) and XAML-island hosts (Windows Terminal) restore the focus of their -// inner child HWND asynchronously after WM_ACTIVATE, so a Ctrl+V timed on -// activation alone lands nowhere while every call still reports success. -// Attaching to the destination's input queue lets SetFocus target that child -// directly; the detach must happen after SendInput, because detaching resets -// the keyboard focus Windows had just restored. -flutter::EncodableValue SendPasteInput(HWND target, HWND target_focus, - DWORD target_thread) { - flutter::EncodableMap response; - if (target != nullptr && GetForegroundWindow() != target) { - response[flutter::EncodableValue("success")] = - flutter::EncodableValue(false); - response[flutter::EncodableValue("errorCode")] = - flutter::EncodableValue("targetNotForeground"); - return flutter::EncodableValue(response); - } - - // UIPI drops injected input at a higher integrity level and reports nothing: - // SendInput still returns the full count. Without this check an elevated - // destination is a permanent, undiagnosable "paste does nothing". - if (target != nullptr) { - DWORD target_pid = 0; - GetWindowThreadProcessId(target, &target_pid); - const DWORD target_level = ProcessIntegrityLevel(target_pid); - const DWORD self_level = ProcessIntegrityLevel(GetCurrentProcessId()); - if (target_level != 0 && self_level != 0 && target_level > self_level) { - response[flutter::EncodableValue("success")] = - flutter::EncodableValue(false); - response[flutter::EncodableValue("errorCode")] = - flutter::EncodableValue("targetElevated"); - return flutter::EncodableValue(response); - } - } - - const DWORD self_thread = GetCurrentThreadId(); - const bool attached = target_thread != 0 && target_thread != self_thread && - AttachThreadInput(self_thread, target_thread, TRUE); - - bool focus_repaired = false; - HWND focus_before = nullptr; - if (attached) { - GUITHREADINFO gui = {}; - gui.cbSize = sizeof(gui); - if (GetGUIThreadInfo(target_thread, &gui)) { - focus_before = gui.hwndFocus; - } - if (target_focus != nullptr && focus_before != target_focus && - IsWindow(target_focus)) { - // SetFocus on a foreign HWND is a blocking cross-thread send, so probe - // the destination first: a hung target would otherwise freeze our UI. - DWORD_PTR probe = 0; - if (SendMessageTimeoutW(target_focus, WM_NULL, 0, 0, - SMTO_ABORTIFHUNG | SMTO_BLOCK, 200, - &probe) != 0) { - focus_repaired = SetFocus(target_focus) != nullptr; - } - } - } - - // WM_HOTKEY arrives on key-down, so physical shortcut modifiers may still - // be held. Release every contaminating modifier before Ctrl+V. SendInput - // inserts this array atomically; later physical key-up events are harmless. - INPUT inputs[9] = {}; - inputs[0].type = INPUT_KEYBOARD; - inputs[0].ki.wVk = VK_MENU; - inputs[0].ki.dwFlags = KEYEVENTF_KEYUP; - inputs[1].type = INPUT_KEYBOARD; - inputs[1].ki.wVk = VK_SHIFT; - inputs[1].ki.dwFlags = KEYEVENTF_KEYUP; - inputs[2].type = INPUT_KEYBOARD; - inputs[2].ki.wVk = VK_LWIN; - inputs[2].ki.dwFlags = KEYEVENTF_KEYUP; - inputs[3].type = INPUT_KEYBOARD; - inputs[3].ki.wVk = VK_RWIN; - inputs[3].ki.dwFlags = KEYEVENTF_KEYUP; - inputs[4].type = INPUT_KEYBOARD; - inputs[4].ki.wVk = VK_CONTROL; - inputs[4].ki.dwFlags = KEYEVENTF_KEYUP; - inputs[5].type = INPUT_KEYBOARD; - inputs[5].ki.wVk = VK_CONTROL; - inputs[6].type = INPUT_KEYBOARD; - inputs[6].ki.wVk = 'V'; - inputs[7].type = INPUT_KEYBOARD; - inputs[7].ki.wVk = 'V'; - inputs[7].ki.dwFlags = KEYEVENTF_KEYUP; - inputs[8].type = INPUT_KEYBOARD; - inputs[8].ki.wVk = VK_CONTROL; - inputs[8].ki.dwFlags = KEYEVENTF_KEYUP; - - constexpr UINT kInputCount = sizeof(inputs) / sizeof(inputs[0]); - - SetLastError(ERROR_SUCCESS); - const UINT sent = SendInput(kInputCount, inputs, sizeof(INPUT)); - const DWORD send_error = GetLastError(); - - if (attached) { - AttachThreadInput(self_thread, target_thread, FALSE); - } - - response[flutter::EncodableValue("success")] = - flutter::EncodableValue(sent == kInputCount); - response[flutter::EncodableValue("sentInputs")] = - flutter::EncodableValue(static_cast(sent)); - response[flutter::EncodableValue("expectedInputs")] = - flutter::EncodableValue(static_cast(kInputCount)); - response[flutter::EncodableValue("attached")] = - flutter::EncodableValue(attached); - response[flutter::EncodableValue("focusRepaired")] = - flutter::EncodableValue(focus_repaired); - response[flutter::EncodableValue("focusBefore")] = flutter::EncodableValue( - static_cast(reinterpret_cast(focus_before))); - if (sent != kInputCount) { - response[flutter::EncodableValue("errorCode")] = - flutter::EncodableValue("sendInputFailed"); - response[flutter::EncodableValue("win32Error")] = - flutter::EncodableValue(static_cast(send_error)); - } - return flutter::EncodableValue(response); -} - -} // namespace - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - RegisterStartupTaskChannel(flutter_controller_.get()); - RegisterHotkeyChannel(); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - - flutter_controller_->engine()->SetNextFrameCallback([&]() { - // Window visibility is managed by window_manager plugin (Dart side). - // Do NOT call Show() here — it causes a visible flash on startup. - }); - - // Flutter can complete the first frame before the "show window" callback is - // registered. The following call ensures a frame is pending to ensure the - // window is shown. It is a no-op if the first frame hasn't completed yet. - flutter_controller_->ForceRedraw(); - - return true; -} - -void FlutterWindow::OnDestroy() { - UnregisterAllHotkeys(); - if (hotkey_channel_) { - // MethodChannel destruction does not unregister its messenger callback. - // Remove the handler before releasing the lambda that captures this. - hotkey_channel_->SetMethodCallHandler(nullptr); - } - hotkey_channel_.reset(); - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_HOTKEY && hotkey_channel_) { - const char* id = nullptr; - if (static_cast(wparam) == kOpenHotkeyId) { - id = "open"; - } else if (static_cast(wparam) == kPlainPasteHotkeyId) { - id = "plainPaste"; - } - if (id != nullptr) { - hotkey_channel_->InvokeMethod( - "hotkeyPressed", - std::make_unique(std::string(id))); - return 0; - } - } - - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - if (flutter_controller_) { - flutter_controller_->engine()->ReloadSystemFonts(); - } - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} - -void FlutterWindow::RegisterHotkeyChannel() { - hotkey_channel_ = - std::make_unique>( - flutter_controller_->engine()->messenger(), kHotkeyChannelName, - &flutter::StandardMethodCodec::GetInstance()); - - hotkey_channel_->SetMethodCallHandler( - [this](const flutter::MethodCall& call, - std::unique_ptr> - result) { - if (call.method_name() == "sendPaste") { - const auto* paste_args = - std::get_if(call.arguments()); - HWND target = nullptr; - HWND target_focus = nullptr; - DWORD target_thread = 0; - if (paste_args != nullptr) { - target = reinterpret_cast( - static_cast(ReadInt64(*paste_args, "targetHwnd"))); - target_focus = reinterpret_cast(static_cast( - ReadInt64(*paste_args, "targetFocusHwnd"))); - target_thread = static_cast( - ReadInt64(*paste_args, "targetThreadId")); - } - result->Success(SendPasteInput(target, target_focus, target_thread)); - return; - } - if (call.method_name() == "unregisterAll") { - UnregisterAllHotkeys(); - result->Success(); - return; - } - if (call.method_name() != "register") { - result->NotImplemented(); - return; - } - - const auto* arguments = - std::get_if(call.arguments()); - int virtual_key = 0; - if (arguments == nullptr || - !ReadInt(*arguments, "virtualKey", &virtual_key) || - virtual_key <= 0 || virtual_key > 0xFF) { - result->Error("invalid_args", "A valid virtualKey is required"); - return; - } - - const std::string id = ReadString(*arguments, "id"); - int native_id = 0; - bool* is_registered = nullptr; - if (id == "open") { - native_id = kOpenHotkeyId; - is_registered = &open_hotkey_registered_; - } else if (id == "plainPaste") { - native_id = kPlainPasteHotkeyId; - is_registered = &plain_paste_hotkey_registered_; - } else { - result->Error("invalid_args", "Unknown hotkey id"); - return; - } - - if (*is_registered) { - UnregisterHotKey(GetHandle(), native_id); - *is_registered = false; - } - - UINT modifiers = kModNoRepeat; - if (ReadBool(*arguments, "useCtrl")) modifiers |= MOD_CONTROL; - if (ReadBool(*arguments, "useWin")) modifiers |= MOD_WIN; - if (ReadBool(*arguments, "useAlt")) modifiers |= MOD_ALT; - if (ReadBool(*arguments, "useShift")) modifiers |= MOD_SHIFT; - - if (!RegisterHotKey(GetHandle(), native_id, modifiers, - static_cast(virtual_key))) { - result->Success(RegistrationResponse(false, GetLastError())); - return; - } - *is_registered = true; - result->Success(RegistrationResponse(true)); - }); -} - -void FlutterWindow::UnregisterAllHotkeys() { - if (open_hotkey_registered_) { - UnregisterHotKey(GetHandle(), kOpenHotkeyId); - open_hotkey_registered_ = false; - } - if (plain_paste_hotkey_registered_) { - UnregisterHotKey(GetHandle(), kPlainPasteHotkeyId); - plain_paste_hotkey_registered_ = false; - } -} diff --git a/app/windows/runner/flutter_window.h b/app/windows/runner/flutter_window.h deleted file mode 100644 index 6822ae79..00000000 --- a/app/windows/runner/flutter_window.h +++ /dev/null @@ -1,45 +0,0 @@ -#ifndef RUNNER_FLUTTER_WINDOW_H_ -#define RUNNER_FLUTTER_WINDOW_H_ - -#include -#include -#include -#include - -#include - -#include "win32_window.h" - -// A window that does nothing but host a Flutter view. -class FlutterWindow : public Win32Window { - public: - // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); - virtual ~FlutterWindow(); - - protected: - // Win32Window: - bool OnCreate() override; - void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; - - private: - void RegisterHotkeyChannel(); - void UnregisterAllHotkeys(); - - // The project to run. - flutter::DartProject project_; - - // The Flutter instance hosted by this window. - std::unique_ptr flutter_controller_; - - // Owned for the full engine lifetime so neither the platform channel nor - // its callbacks leak when the runner shuts down. - std::unique_ptr> - hotkey_channel_; - bool open_hotkey_registered_ = false; - bool plain_paste_hotkey_registered_ = false; -}; - -#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/app/windows/runner/main.cpp b/app/windows/runner/main.cpp deleted file mode 100644 index 623467ec..00000000 --- a/app/windows/runner/main.cpp +++ /dev/null @@ -1,82 +0,0 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" - -// Signals the running instance so it can show its window. -// Best-effort: failures are silently ignored. -static void SignalRunningInstance() { - ::AllowSetForegroundWindow(ASFW_ANY); - - HANDLE hPipe = ::CreateFileW( - L"\\\\.\\pipe\\CopyPasteSingleInstance", - GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); - if (hPipe != INVALID_HANDLE_VALUE) { - DWORD written = 0; - ::WriteFile(hPipe, "wakeup", 6, &written, nullptr); - ::CloseHandle(hPipe); - } else { - wchar_t tempPath[MAX_PATH]; - if (::GetTempPathW(MAX_PATH, tempPath) > 0) { - wchar_t wakeupPath[MAX_PATH]; - swprintf_s(wakeupPath, MAX_PATH, L"%scopypaste.wakeup", tempPath); - HANDLE hFile = ::CreateFileW(wakeupPath, GENERIC_WRITE, 0, nullptr, - CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); - if (hFile != INVALID_HANDLE_VALUE) { - DWORD written = 0; - ::WriteFile(hFile, "wakeup", 6, &written, nullptr); - ::CloseHandle(hFile); - } - } - } -} - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Native single-instance guard: detect an existing instance BEFORE creating - // the Flutter window so the user never sees a second window at all. - // Uses OpenMutexW (check-only); the authoritative mutex is managed by Dart. - HANDLE hExisting = ::OpenMutexW(SYNCHRONIZE, FALSE, - L"Local\\CopyPaste_SingleInstance_Mutex"); - if (hExisting != nullptr) { - ::CloseHandle(hExisting); - SignalRunningInstance(); - return 0; - } - - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.Create(L"CopyPaste", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} diff --git a/app/windows/runner/resource.h b/app/windows/runner/resource.h deleted file mode 100644 index 66a65d1e..00000000 --- a/app/windows/runner/resource.h +++ /dev/null @@ -1,16 +0,0 @@ -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by Runner.rc -// -#define IDI_APP_ICON 101 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -#ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 -#endif -#endif diff --git a/app/windows/runner/resources/app_icon.ico b/app/windows/runner/resources/app_icon.ico deleted file mode 100644 index 88367a83..00000000 Binary files a/app/windows/runner/resources/app_icon.ico and /dev/null differ diff --git a/app/windows/runner/runner.exe.manifest b/app/windows/runner/runner.exe.manifest deleted file mode 100644 index 153653e8..00000000 --- a/app/windows/runner/runner.exe.manifest +++ /dev/null @@ -1,14 +0,0 @@ - - - - - PerMonitorV2 - - - - - - - - - diff --git a/app/windows/runner/startup_task_channel.cpp b/app/windows/runner/startup_task_channel.cpp deleted file mode 100644 index 409a1906..00000000 --- a/app/windows/runner/startup_task_channel.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include "startup_task_channel.h" - -#include -#include - -#include -#include - -#include -#include -#include - -namespace { - -constexpr const char kChannelName[] = "copypaste/startup_task"; - -std::string StateToString(winrt::Windows::ApplicationModel::StartupTaskState state) { - using winrt::Windows::ApplicationModel::StartupTaskState; - switch (state) { - case StartupTaskState::Disabled: - return "disabled"; - case StartupTaskState::DisabledByUser: - return "disabledByUser"; - case StartupTaskState::DisabledByPolicy: - return "disabledByPolicy"; - case StartupTaskState::Enabled: - return "enabled"; - case StartupTaskState::EnabledByPolicy: - return "enabledByPolicy"; - } - return "unknown"; -} - -std::wstring Utf8ToWide(const std::string& input) { - if (input.empty()) return L""; - int wlen = MultiByteToWideChar(CP_UTF8, 0, input.c_str(), - static_cast(input.size()), nullptr, 0); - std::wstring result(wlen, L'\0'); - MultiByteToWideChar(CP_UTF8, 0, input.c_str(), static_cast(input.size()), - result.data(), wlen); - return result; -} - -} // namespace - -void RegisterStartupTaskChannel(flutter::FlutterViewController* controller) { - auto channel = - std::make_unique>( - controller->engine()->messenger(), kChannelName, - &flutter::StandardMethodCodec::GetInstance()); - - // The channel must outlive the engine. Leak intentionally. - auto* leaked_channel = channel.release(); - - leaked_channel->SetMethodCallHandler( - [](const flutter::MethodCall& call, - std::unique_ptr> - result) { - const std::string method = call.method_name(); - - std::string task_id; - if (const auto* args = - std::get_if(call.arguments())) { - auto it = args->find(flutter::EncodableValue("taskId")); - if (it != args->end()) { - if (const auto* s = std::get_if(&it->second)) { - task_id = *s; - } - } - } - - if (task_id.empty()) { - result->Error("invalid_args", "taskId required"); - return; - } - - std::wstring wtask_id = Utf8ToWide(task_id); - std::shared_ptr> - shared_result(result.release()); - - std::thread([method, wtask_id, shared_result]() { - try { - winrt::init_apartment(); - using namespace winrt::Windows::ApplicationModel; - auto task = StartupTask::GetAsync(wtask_id).get(); - if (!task) { - shared_result->Error( - "task_not_found", - "StartupTask not found in manifest", - flutter::EncodableValue( - "No startup task with id '" + - winrt::to_string(wtask_id) + - "' is declared in the AppxManifest.")); - return; - } - if (method == "getState") { - shared_result->Success( - flutter::EncodableValue(StateToString(task.State()))); - } else if (method == "enable") { - auto state = task.RequestEnableAsync().get(); - shared_result->Success( - flutter::EncodableValue(StateToString(state))); - } else if (method == "disable") { - task.Disable(); - shared_result->Success( - flutter::EncodableValue(StateToString(task.State()))); - } else { - shared_result->NotImplemented(); - } - } catch (const winrt::hresult_error& e) { - char code_buf[32]; - snprintf(code_buf, sizeof(code_buf), "0x%08X", - static_cast(e.code())); - // E_INVALIDARG (0x80070057) from GetAsync means the TaskId is not - // declared in the AppxManifest. Ensure windows.startupTask is - // present in the manifest with a matching TaskId attribute. - shared_result->Error("winrt_error", code_buf, - flutter::EncodableValue( - winrt::to_string(e.message()))); - } catch (...) { - shared_result->Error("unknown_error", "Unknown failure"); - } - }).detach(); - }); -} diff --git a/app/windows/runner/startup_task_channel.h b/app/windows/runner/startup_task_channel.h deleted file mode 100644 index d666a0a9..00000000 --- a/app/windows/runner/startup_task_channel.h +++ /dev/null @@ -1,8 +0,0 @@ -#ifndef RUNNER_STARTUP_TASK_CHANNEL_H_ -#define RUNNER_STARTUP_TASK_CHANNEL_H_ - -#include - -void RegisterStartupTaskChannel(flutter::FlutterViewController* controller); - -#endif // RUNNER_STARTUP_TASK_CHANNEL_H_ diff --git a/app/windows/runner/utils.cpp b/app/windows/runner/utils.cpp deleted file mode 100644 index 3a0b4651..00000000 --- a/app/windows/runner/utils.cpp +++ /dev/null @@ -1,65 +0,0 @@ -#include "utils.h" - -#include -#include -#include -#include - -#include - -void CreateAndAttachConsole() { - if (::AllocConsole()) { - FILE *unused; - if (freopen_s(&unused, "CONOUT$", "w", stdout)) { - _dup2(_fileno(stdout), 1); - } - if (freopen_s(&unused, "CONOUT$", "w", stderr)) { - _dup2(_fileno(stdout), 2); - } - std::ios::sync_with_stdio(); - FlutterDesktopResyncOutputStreams(); - } -} - -std::vector GetCommandLineArguments() { - // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. - int argc; - wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); - if (argv == nullptr) { - return std::vector(); - } - - std::vector command_line_arguments; - - // Skip the first argument as it's the binary name. - for (int i = 1; i < argc; i++) { - command_line_arguments.push_back(Utf8FromUtf16(argv[i])); - } - - ::LocalFree(argv); - - return command_line_arguments; -} - -std::string Utf8FromUtf16(const wchar_t* utf16_string) { - if (utf16_string == nullptr) { - return std::string(); - } - unsigned int target_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, nullptr, 0, nullptr, nullptr) - -1; // remove the trailing null character - int input_length = (int)wcslen(utf16_string); - std::string utf8_string; - if (target_length == 0 || target_length > utf8_string.max_size()) { - return utf8_string; - } - utf8_string.resize(target_length); - int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, utf8_string.data(), target_length, nullptr, nullptr); - if (converted_length == 0) { - return std::string(); - } - return utf8_string; -} diff --git a/app/windows/runner/utils.h b/app/windows/runner/utils.h deleted file mode 100644 index 3879d547..00000000 --- a/app/windows/runner/utils.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef RUNNER_UTILS_H_ -#define RUNNER_UTILS_H_ - -#include -#include - -// Creates a console for the process, and redirects stdout and stderr to -// it for both the runner and the Flutter library. -void CreateAndAttachConsole(); - -// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string -// encoded in UTF-8. Returns an empty std::string on failure. -std::string Utf8FromUtf16(const wchar_t* utf16_string); - -// Gets the command line arguments passed in as a std::vector, -// encoded in UTF-8. Returns an empty std::vector on failure. -std::vector GetCommandLineArguments(); - -#endif // RUNNER_UTILS_H_ diff --git a/app/windows/runner/win32_window.cpp b/app/windows/runner/win32_window.cpp deleted file mode 100644 index 60608d0f..00000000 --- a/app/windows/runner/win32_window.cpp +++ /dev/null @@ -1,288 +0,0 @@ -#include "win32_window.h" - -#include -#include - -#include "resource.h" - -namespace { - -/// Window attribute that enables dark mode window decorations. -/// -/// Redefined in case the developer's machine has a Windows SDK older than -/// version 10.0.22000.0. -/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute -#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE -#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 -#endif - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -/// Registry key for app theme preference. -/// -/// A value of 0 indicates apps should use dark mode. A non-zero or missing -/// value indicates apps should use light mode. -constexpr const wchar_t kGetPreferredBrightnessRegKey[] = - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; -constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - } - FreeLibrary(user32_module); -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registrar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::Create(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - UpdateTheme(window); - - return OnCreate(); -} - -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - - case WM_DWMCOLORIZATIONCOLORCHANGED: - UpdateTheme(hwnd); - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} - -void Win32Window::UpdateTheme(HWND const window) { - DWORD light_mode; - DWORD light_mode_size = sizeof(light_mode); - LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, - kGetPreferredBrightnessRegValue, - RRF_RT_REG_DWORD, nullptr, &light_mode, - &light_mode_size); - - if (result == ERROR_SUCCESS) { - BOOL enable_dark_mode = light_mode == 0; - DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, - &enable_dark_mode, sizeof(enable_dark_mode)); - } -} diff --git a/app/windows/runner/win32_window.h b/app/windows/runner/win32_window.h deleted file mode 100644 index e901dde6..00000000 --- a/app/windows/runner/win32_window.h +++ /dev/null @@ -1,102 +0,0 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates a win32 window with |title| that is positioned and sized using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size this function will scale the inputted width and height as - // as appropriate for the default monitor. The window is invisible until - // |Show| is called. Returns true if the window was created successfully. - bool Create(const std::wstring& title, const Point& origin, const Size& size); - - // Show the current window. Returns true if the window was successfully shown. - bool Show(); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - // Update the window frame's theme to match the system theme. - static void UpdateTheme(HWND const window); - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index c48a7627..00000000 --- a/codecov.yml +++ /dev/null @@ -1,31 +0,0 @@ -coverage: - precision: 2 - round: down - status: - project: - default: - target: auto - threshold: 0% - removed_code_behavior: adjust_base - patch: - default: - target: auto - ignore: - - "core/lib/repository/sqlite_repository.g.dart" - - "app/lib/l10n/app_localizations_en.dart" - - "app/lib/l10n/app_localizations_es.dart" - - "app/lib/shell" - - "app/lib/services" - - "app/lib/screens/settings_screen.dart" - - "app/lib/main.dart" - - "app/lib/helpers/url_helper.dart" - -codecov: - notify: - after_n_builds: 3 - -comment: - layout: "reach,diff,flags,files" - behavior: default - require_changes: true - after_n_builds: 3 diff --git a/core/.gitignore b/core/.gitignore deleted file mode 100644 index dd5eb989..00000000 --- a/core/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. -/pubspec.lock -**/doc/api/ -.dart_tool/ -.flutter-plugins-dependencies -/build/ -/coverage/ diff --git a/core/.metadata b/core/.metadata deleted file mode 100644 index 45e8bdaf..00000000 --- a/core/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "48c32af0345e9ad5747f78ddce828c7f795f7159" - channel: "stable" - -project_type: package diff --git a/core/analysis_options.yaml b/core/analysis_options.yaml deleted file mode 100644 index 5e2133eb..00000000 --- a/core/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../analysis_options.yaml diff --git a/core/lib/config/app_config.dart b/core/lib/config/app_config.dart deleted file mode 100644 index 6e27424b..00000000 --- a/core/lib/config/app_config.dart +++ /dev/null @@ -1,601 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import '../services/app_logger.dart'; - -const _sentinel = Object(); - -class AppConfig { - const AppConfig({ - this.preferredLanguage = 'auto', - this.runOnStartup = true, - this.hotkeyUseCtrl = true, - this.hotkeyUseWin = false, - this.hotkeyUseAlt = true, - this.hotkeyUseShift = false, - this.hotkeyVirtualKey = 0x56, - this.hotkeyKeyName = 'V', - this.plainPasteHotkeyEnabled = false, - this.plainPasteHotkeyUseCtrl = true, - this.plainPasteHotkeyUseWin = false, - this.plainPasteHotkeyUseAlt = true, - this.plainPasteHotkeyUseShift = true, - this.plainPasteHotkeyVirtualKey = 0x56, - this.plainPasteHotkeyKeyName = 'V', - this.pageSize = 30, - this.maxItemsBeforeCleanup = 100, - this.scrollLoadThreshold = 400, - this.retentionDays = 30, - this.keepBrokenItemsDays = 30, - this.colorLabels = const {}, - this.duplicateIgnoreWindowMs = 450, - this.delayBeforeFocusMs = 100, - this.delayBeforePasteMs = 180, - this.maxFocusVerifyAttempts = 15, - this.lastBackupDateUtc, - this.popupWidth = 380, - this.popupHeight = 500, - this.cardMinLines = 2, - this.cardMaxLines = 5, - this.hideOnDeactivate = true, - this.resetScrollOnShow = true, - this.resetSearchOnShow = true, - this.resetFiltersOnShow = true, - this.hasSeenHint = false, - this.themeMode = 'dark', - this.accessibilityWasGranted = false, - this.lastRunVersion = '', - this.hasSeenOnboarding = false, - this.generateImageThumbnails = true, - this.generateVideoThumbnails = true, - this.generateAudioThumbnails = true, - this.maxImageProcessingSizeMB = 25, - this.imagesQuotaMB = 0, - this.rememberWindowPosition = false, - this.lastWindowX, - this.lastWindowY, - }); - - /// [platform] overrides the host OS so migrations can be exercised off the - /// platform they target; coverage runs on the Linux CI runner, where the - /// Windows branches would otherwise never execute. - factory AppConfig.fromJson(Map json, {String? platform}) { - final os = platform ?? Platform.operatingSystem; - final isWindows = os == 'windows'; - final defaults = defaultForPlatform(os); - final hotkeyUseCtrl = - json['hotkeyUseCtrl'] as bool? ?? defaults.hotkeyUseCtrl; - final hotkeyUseWin = json['hotkeyUseWin'] as bool? ?? defaults.hotkeyUseWin; - final hotkeyUseAlt = json['hotkeyUseAlt'] as bool? ?? defaults.hotkeyUseAlt; - final hotkeyUseShift = - json['hotkeyUseShift'] as bool? ?? defaults.hotkeyUseShift; - var hotkeyVirtualKey = - json['hotkeyVirtualKey'] as int? ?? defaults.hotkeyVirtualKey; - var hotkeyKeyName = - json['hotkeyKeyName'] as String? ?? defaults.hotkeyKeyName; - var plainPasteHotkeyEnabled = - // Missing means this is a pre-feature config. Do not unexpectedly - // claim a new global shortcut for an existing user. - json['plainPasteHotkeyEnabled'] as bool? ?? false; - var plainPasteHotkeyUseCtrl = - json['plainPasteHotkeyUseCtrl'] as bool? ?? - defaults.plainPasteHotkeyUseCtrl; - var plainPasteHotkeyUseWin = - json['plainPasteHotkeyUseWin'] as bool? ?? - defaults.plainPasteHotkeyUseWin; - var plainPasteHotkeyUseAlt = - json['plainPasteHotkeyUseAlt'] as bool? ?? - defaults.plainPasteHotkeyUseAlt; - var plainPasteHotkeyUseShift = - json['plainPasteHotkeyUseShift'] as bool? ?? - defaults.plainPasteHotkeyUseShift; - var plainPasteHotkeyVirtualKey = - json['plainPasteHotkeyVirtualKey'] as int? ?? - defaults.plainPasteHotkeyVirtualKey; - var plainPasteHotkeyKeyName = - json['plainPasteHotkeyKeyName'] as String? ?? - defaults.plainPasteHotkeyKeyName; - - final shortcutDefaultsVersion = - json['shortcutDefaultsVersion'] as int? ?? 1; - if (shortcutDefaultsVersion < 2) { - final legacyPlainBinding = - plainPasteHotkeyEnabled && - plainPasteHotkeyVirtualKey == 0x56 && - ((Platform.isMacOS && - !plainPasteHotkeyUseCtrl && - plainPasteHotkeyUseWin && - plainPasteHotkeyUseAlt && - plainPasteHotkeyUseShift) || - (!Platform.isMacOS && - plainPasteHotkeyUseCtrl && - !plainPasteHotkeyUseWin && - !plainPasteHotkeyUseAlt && - plainPasteHotkeyUseShift)); - if (legacyPlainBinding) { - plainPasteHotkeyEnabled = false; - plainPasteHotkeyUseCtrl = defaults.plainPasteHotkeyUseCtrl; - plainPasteHotkeyUseWin = defaults.plainPasteHotkeyUseWin; - plainPasteHotkeyUseAlt = defaults.plainPasteHotkeyUseAlt; - plainPasteHotkeyUseShift = defaults.plainPasteHotkeyUseShift; - plainPasteHotkeyVirtualKey = defaults.plainPasteHotkeyVirtualKey; - plainPasteHotkeyKeyName = defaults.plainPasteHotkeyKeyName; - } - if (legacyPlainBinding) { - AppLogger.info('Migrated the legacy plain-paste shortcut default'); - } - } - - // Version 2 briefly changed the Windows opening default from Ctrl+Alt+C - // to Ctrl+Alt+V. Revert only that exact automatic binding; version 1 - // custom bindings and every other versioned combination remain untouched. - final versionTwoWindowsOpen = - isWindows && - shortcutDefaultsVersion == 2 && - hotkeyUseCtrl && - !hotkeyUseWin && - hotkeyUseAlt && - !hotkeyUseShift && - hotkeyVirtualKey == 0x56; - if (versionTwoWindowsOpen) { - hotkeyVirtualKey = 0x43; - hotkeyKeyName = 'C'; - AppLogger.info('Restored the Windows opening shortcut to Ctrl+Alt+C'); - } - - // Ctrl+Alt+Shift+V proved uncomfortable. Version 4 briefly tried the - // application-level Ctrl+Shift+V convention, but a global registration - // would shadow it in VS Code, terminals, and other apps. Migrate only - // those two exact automatic Windows bindings to Ctrl+Alt+V; bindings from - // versions where they could have been user-defined remain untouched. - final legacyWindowsPlainPaste = - isWindows && - plainPasteHotkeyUseCtrl && - !plainPasteHotkeyUseWin && - plainPasteHotkeyVirtualKey == 0x56 && - ((shortcutDefaultsVersion == 3 && - plainPasteHotkeyUseAlt && - plainPasteHotkeyUseShift) || - (shortcutDefaultsVersion == 4 && - !plainPasteHotkeyUseAlt && - plainPasteHotkeyUseShift)); - if (legacyWindowsPlainPaste) { - plainPasteHotkeyUseAlt = true; - plainPasteHotkeyUseShift = false; - AppLogger.info('Updated the Windows plain-paste shortcut to Ctrl+Alt+V'); - } - - var duplicateIgnoreWindowMs = - json['duplicateIgnoreWindowMs'] as int? ?? - defaults.duplicateIgnoreWindowMs; - var delayBeforeFocusMs = - json['delayBeforeFocusMs'] as int? ?? defaults.delayBeforeFocusMs; - var delayBeforePasteMs = - json['delayBeforePasteMs'] as int? ?? defaults.delayBeforePasteMs; - var maxFocusVerifyAttempts = - json['maxFocusVerifyAttempts'] as int? ?? - defaults.maxFocusVerifyAttempts; - final storedPasteDefaultsVersion = - json['pasteDefaultsVersion'] as int? ?? 1; - - // v2 moved Windows onto the Instant preset, assuming native focus - // verification made fixed delays unnecessary. It does not: the check only - // proves the destination is the active top-level window, so the paste can - // still outrun apps that route keyboard focus internally. Undo it for - // anyone left on those exact values; tuned tuples are preserved. - final untouchedInstantPaste = - isWindows && - storedPasteDefaultsVersion < pasteDefaultsVersion && - duplicateIgnoreWindowMs == 300 && - delayBeforeFocusMs == 0 && - delayBeforePasteMs == 20 && - maxFocusVerifyAttempts == 15; - if (untouchedInstantPaste) { - duplicateIgnoreWindowMs = 350; - delayBeforeFocusMs = 80; - delayBeforePasteMs = 120; - maxFocusVerifyAttempts = 12; - AppLogger.info('Updated Windows paste timing to the Normal preset'); - } - - return AppConfig( - preferredLanguage: - json['preferredLanguage'] as String? ?? defaults.preferredLanguage, - runOnStartup: json['runOnStartup'] as bool? ?? defaults.runOnStartup, - hotkeyUseCtrl: hotkeyUseCtrl, - hotkeyUseWin: hotkeyUseWin, - hotkeyUseAlt: hotkeyUseAlt, - hotkeyUseShift: hotkeyUseShift, - hotkeyVirtualKey: hotkeyVirtualKey, - hotkeyKeyName: hotkeyKeyName, - plainPasteHotkeyEnabled: plainPasteHotkeyEnabled, - plainPasteHotkeyUseCtrl: plainPasteHotkeyUseCtrl, - plainPasteHotkeyUseWin: plainPasteHotkeyUseWin, - plainPasteHotkeyUseAlt: plainPasteHotkeyUseAlt, - plainPasteHotkeyUseShift: plainPasteHotkeyUseShift, - plainPasteHotkeyVirtualKey: plainPasteHotkeyVirtualKey, - plainPasteHotkeyKeyName: plainPasteHotkeyKeyName, - pageSize: json['pageSize'] as int? ?? defaults.pageSize, - maxItemsBeforeCleanup: - json['maxItemsBeforeCleanup'] as int? ?? - defaults.maxItemsBeforeCleanup, - scrollLoadThreshold: - json['scrollLoadThreshold'] as int? ?? defaults.scrollLoadThreshold, - retentionDays: json['retentionDays'] as int? ?? defaults.retentionDays, - keepBrokenItemsDays: - json['keepBrokenItemsDays'] as int? ?? defaults.keepBrokenItemsDays, - colorLabels: - (json['colorLabels'] as Map?)?.map( - (k, v) => MapEntry(k, v as String), - ) ?? - const {}, - duplicateIgnoreWindowMs: duplicateIgnoreWindowMs, - delayBeforeFocusMs: delayBeforeFocusMs, - delayBeforePasteMs: delayBeforePasteMs, - maxFocusVerifyAttempts: maxFocusVerifyAttempts, - lastBackupDateUtc: json['lastBackupDateUtc'] != null - ? DateTime.tryParse(json['lastBackupDateUtc'] as String) - : null, - popupWidth: json['popupWidth'] as int? ?? defaults.popupWidth, - popupHeight: json['popupHeight'] as int? ?? defaults.popupHeight, - cardMinLines: json['cardMinLines'] as int? ?? defaults.cardMinLines, - cardMaxLines: json['cardMaxLines'] as int? ?? defaults.cardMaxLines, - hideOnDeactivate: - json['hideOnDeactivate'] as bool? ?? defaults.hideOnDeactivate, - resetScrollOnShow: - json['resetScrollOnShow'] as bool? ?? defaults.resetScrollOnShow, - resetSearchOnShow: - json['resetSearchOnShow'] as bool? ?? defaults.resetSearchOnShow, - resetFiltersOnShow: - json['resetFiltersOnShow'] as bool? ?? defaults.resetFiltersOnShow, - hasSeenHint: json['hasSeenHint'] as bool? ?? defaults.hasSeenHint, - themeMode: json['themeMode'] as String? ?? defaults.themeMode, - accessibilityWasGranted: - json['accessibilityWasGranted'] as bool? ?? - defaults.accessibilityWasGranted, - lastRunVersion: - json['lastRunVersion'] as String? ?? defaults.lastRunVersion, - hasSeenOnboarding: - json['hasSeenOnboarding'] as bool? ?? - json['hasSeenWindowsOnboarding'] as bool? ?? - defaults.hasSeenOnboarding, - generateImageThumbnails: - json['generateImageThumbnails'] as bool? ?? - defaults.generateImageThumbnails, - generateVideoThumbnails: - json['generateVideoThumbnails'] as bool? ?? - defaults.generateVideoThumbnails, - generateAudioThumbnails: - json['generateAudioThumbnails'] as bool? ?? - defaults.generateAudioThumbnails, - maxImageProcessingSizeMB: - json['maxImageProcessingSizeMB'] as int? ?? - defaults.maxImageProcessingSizeMB, - imagesQuotaMB: json['imagesQuotaMB'] as int? ?? defaults.imagesQuotaMB, - rememberWindowPosition: - json['rememberWindowPosition'] as bool? ?? - defaults.rememberWindowPosition, - lastWindowX: (json['lastWindowX'] as num?)?.toDouble(), - lastWindowY: (json['lastWindowY'] as num?)?.toDouble(), - ); - } - - static const int shortcutDefaultsVersion = 5; - static const int pasteDefaultsVersion = 3; - - static AppConfig defaultForCurrentPlatform() => - defaultForPlatform(Platform.operatingSystem); - - // Kept for tests that pass a platform string explicitly. - static AppConfig defaultForPlatform(String platform) => switch (platform) { - // Ctrl+Alt+C keeps the established CopyPaste opening gesture. The optional - // system-wide plain-paste shortcut shares the modifiers for muscle memory - // without shadowing the common application-level Ctrl+Shift+V gesture. - 'windows' => const AppConfig( - hotkeyUseCtrl: true, - hotkeyUseAlt: true, - hotkeyUseShift: false, - hotkeyVirtualKey: 0x43, - hotkeyKeyName: 'C', - plainPasteHotkeyEnabled: false, - plainPasteHotkeyUseCtrl: true, - plainPasteHotkeyUseAlt: true, - plainPasteHotkeyUseShift: false, - duplicateIgnoreWindowMs: 350, - delayBeforeFocusMs: 80, - delayBeforePasteMs: 120, - maxFocusVerifyAttempts: 12, - ), - // Control+Shift+V opens the panel. The optional global plain-paste binding - // includes every modifier and stays disabled until explicitly enabled. - 'macos' => const AppConfig( - hotkeyUseCtrl: true, - hotkeyUseAlt: false, - hotkeyUseShift: true, - plainPasteHotkeyEnabled: false, - plainPasteHotkeyUseCtrl: true, - plainPasteHotkeyUseWin: true, - plainPasteHotkeyUseAlt: true, - plainPasteHotkeyUseShift: true, - ), - _ => const AppConfig(), - }; - - static const String fileName = 'config.json'; - static const String appVersion = String.fromEnvironment( - 'APP_VERSION', - defaultValue: '2.0.0', - ); - - // Language & Startup - final String preferredLanguage; - final bool runOnStartup; - - // Hotkey - final bool hotkeyUseCtrl; - final bool hotkeyUseWin; - final bool hotkeyUseAlt; - final bool hotkeyUseShift; - final int hotkeyVirtualKey; - final String hotkeyKeyName; - final bool plainPasteHotkeyEnabled; - final bool plainPasteHotkeyUseCtrl; - final bool plainPasteHotkeyUseWin; - final bool plainPasteHotkeyUseAlt; - final bool plainPasteHotkeyUseShift; - final int plainPasteHotkeyVirtualKey; - final String plainPasteHotkeyKeyName; - - // Performance - final int pageSize; - final int maxItemsBeforeCleanup; - final int scrollLoadThreshold; - - // Storage - final int retentionDays; - final int keepBrokenItemsDays; - final Map colorLabels; - - // Paste behavior - final int duplicateIgnoreWindowMs; - final int delayBeforeFocusMs; - final int delayBeforePasteMs; - final int maxFocusVerifyAttempts; - - // Backup - final DateTime? lastBackupDateUtc; - - // Appearance - final int popupWidth; - final int popupHeight; - final int cardMinLines; - final int cardMaxLines; - final bool hideOnDeactivate; - final bool resetScrollOnShow; - final bool resetSearchOnShow; - final bool resetFiltersOnShow; - final bool hasSeenHint; - final String themeMode; - final bool accessibilityWasGranted; - final String lastRunVersion; - final bool hasSeenOnboarding; - - // Multimedia & thumbnails - final bool generateImageThumbnails; - final bool generateVideoThumbnails; - final bool generateAudioThumbnails; - final int maxImageProcessingSizeMB; - - // Storage quota (total bytes allowed under images/). 0 disables the cap; - // anything > 0 triggers an LRU purge during the periodic cleanup until the - // owned bytes drop back below the limit. Pinned items are never purged. - final int imagesQuotaMB; - - final bool rememberWindowPosition; - final double? lastWindowX; - final double? lastWindowY; - - AppConfig copyWith({ - String? preferredLanguage, - bool? runOnStartup, - bool? hotkeyUseCtrl, - bool? hotkeyUseWin, - bool? hotkeyUseAlt, - bool? hotkeyUseShift, - int? hotkeyVirtualKey, - String? hotkeyKeyName, - bool? plainPasteHotkeyEnabled, - bool? plainPasteHotkeyUseCtrl, - bool? plainPasteHotkeyUseWin, - bool? plainPasteHotkeyUseAlt, - bool? plainPasteHotkeyUseShift, - int? plainPasteHotkeyVirtualKey, - String? plainPasteHotkeyKeyName, - int? pageSize, - int? maxItemsBeforeCleanup, - int? scrollLoadThreshold, - int? retentionDays, - int? keepBrokenItemsDays, - Map? colorLabels, - int? duplicateIgnoreWindowMs, - int? delayBeforeFocusMs, - int? delayBeforePasteMs, - int? maxFocusVerifyAttempts, - Object? lastBackupDateUtc = _sentinel, - int? popupWidth, - int? popupHeight, - int? cardMinLines, - int? cardMaxLines, - bool? hideOnDeactivate, - bool? resetScrollOnShow, - bool? resetSearchOnShow, - bool? resetFiltersOnShow, - bool? hasSeenHint, - String? themeMode, - bool? accessibilityWasGranted, - String? lastRunVersion, - bool? hasSeenOnboarding, - bool? generateImageThumbnails, - bool? generateVideoThumbnails, - bool? generateAudioThumbnails, - int? maxImageProcessingSizeMB, - int? imagesQuotaMB, - bool? rememberWindowPosition, - Object? lastWindowX = _sentinel, - Object? lastWindowY = _sentinel, - }) => AppConfig( - preferredLanguage: preferredLanguage ?? this.preferredLanguage, - runOnStartup: runOnStartup ?? this.runOnStartup, - hotkeyUseCtrl: hotkeyUseCtrl ?? this.hotkeyUseCtrl, - hotkeyUseWin: hotkeyUseWin ?? this.hotkeyUseWin, - hotkeyUseAlt: hotkeyUseAlt ?? this.hotkeyUseAlt, - hotkeyUseShift: hotkeyUseShift ?? this.hotkeyUseShift, - hotkeyVirtualKey: hotkeyVirtualKey ?? this.hotkeyVirtualKey, - hotkeyKeyName: hotkeyKeyName ?? this.hotkeyKeyName, - plainPasteHotkeyEnabled: - plainPasteHotkeyEnabled ?? this.plainPasteHotkeyEnabled, - plainPasteHotkeyUseCtrl: - plainPasteHotkeyUseCtrl ?? this.plainPasteHotkeyUseCtrl, - plainPasteHotkeyUseWin: - plainPasteHotkeyUseWin ?? this.plainPasteHotkeyUseWin, - plainPasteHotkeyUseAlt: - plainPasteHotkeyUseAlt ?? this.plainPasteHotkeyUseAlt, - plainPasteHotkeyUseShift: - plainPasteHotkeyUseShift ?? this.plainPasteHotkeyUseShift, - plainPasteHotkeyVirtualKey: - plainPasteHotkeyVirtualKey ?? this.plainPasteHotkeyVirtualKey, - plainPasteHotkeyKeyName: - plainPasteHotkeyKeyName ?? this.plainPasteHotkeyKeyName, - pageSize: pageSize ?? this.pageSize, - maxItemsBeforeCleanup: maxItemsBeforeCleanup ?? this.maxItemsBeforeCleanup, - scrollLoadThreshold: scrollLoadThreshold ?? this.scrollLoadThreshold, - retentionDays: retentionDays ?? this.retentionDays, - keepBrokenItemsDays: keepBrokenItemsDays ?? this.keepBrokenItemsDays, - colorLabels: colorLabels ?? this.colorLabels, - duplicateIgnoreWindowMs: - duplicateIgnoreWindowMs ?? this.duplicateIgnoreWindowMs, - delayBeforeFocusMs: delayBeforeFocusMs ?? this.delayBeforeFocusMs, - delayBeforePasteMs: delayBeforePasteMs ?? this.delayBeforePasteMs, - maxFocusVerifyAttempts: - maxFocusVerifyAttempts ?? this.maxFocusVerifyAttempts, - lastBackupDateUtc: lastBackupDateUtc == _sentinel - ? this.lastBackupDateUtc - : lastBackupDateUtc as DateTime?, - popupWidth: popupWidth ?? this.popupWidth, - popupHeight: popupHeight ?? this.popupHeight, - cardMinLines: cardMinLines ?? this.cardMinLines, - cardMaxLines: cardMaxLines ?? this.cardMaxLines, - hideOnDeactivate: hideOnDeactivate ?? this.hideOnDeactivate, - resetScrollOnShow: resetScrollOnShow ?? this.resetScrollOnShow, - resetSearchOnShow: resetSearchOnShow ?? this.resetSearchOnShow, - resetFiltersOnShow: resetFiltersOnShow ?? this.resetFiltersOnShow, - hasSeenHint: hasSeenHint ?? this.hasSeenHint, - themeMode: themeMode ?? this.themeMode, - accessibilityWasGranted: - accessibilityWasGranted ?? this.accessibilityWasGranted, - lastRunVersion: lastRunVersion ?? this.lastRunVersion, - hasSeenOnboarding: hasSeenOnboarding ?? this.hasSeenOnboarding, - generateImageThumbnails: - generateImageThumbnails ?? this.generateImageThumbnails, - generateVideoThumbnails: - generateVideoThumbnails ?? this.generateVideoThumbnails, - generateAudioThumbnails: - generateAudioThumbnails ?? this.generateAudioThumbnails, - maxImageProcessingSizeMB: - maxImageProcessingSizeMB ?? this.maxImageProcessingSizeMB, - imagesQuotaMB: imagesQuotaMB ?? this.imagesQuotaMB, - rememberWindowPosition: - rememberWindowPosition ?? this.rememberWindowPosition, - lastWindowX: lastWindowX == _sentinel - ? this.lastWindowX - : lastWindowX as double?, - lastWindowY: lastWindowY == _sentinel - ? this.lastWindowY - : lastWindowY as double?, - ); - - Map toJson() => { - 'shortcutDefaultsVersion': shortcutDefaultsVersion, - 'pasteDefaultsVersion': pasteDefaultsVersion, - 'preferredLanguage': preferredLanguage, - 'runOnStartup': runOnStartup, - 'hotkeyUseCtrl': hotkeyUseCtrl, - 'hotkeyUseWin': hotkeyUseWin, - 'hotkeyUseAlt': hotkeyUseAlt, - 'hotkeyUseShift': hotkeyUseShift, - 'hotkeyVirtualKey': hotkeyVirtualKey, - 'hotkeyKeyName': hotkeyKeyName, - 'plainPasteHotkeyEnabled': plainPasteHotkeyEnabled, - 'plainPasteHotkeyUseCtrl': plainPasteHotkeyUseCtrl, - 'plainPasteHotkeyUseWin': plainPasteHotkeyUseWin, - 'plainPasteHotkeyUseAlt': plainPasteHotkeyUseAlt, - 'plainPasteHotkeyUseShift': plainPasteHotkeyUseShift, - 'plainPasteHotkeyVirtualKey': plainPasteHotkeyVirtualKey, - 'plainPasteHotkeyKeyName': plainPasteHotkeyKeyName, - 'pageSize': pageSize, - 'maxItemsBeforeCleanup': maxItemsBeforeCleanup, - 'scrollLoadThreshold': scrollLoadThreshold, - 'retentionDays': retentionDays, - 'keepBrokenItemsDays': keepBrokenItemsDays, - 'colorLabels': colorLabels, - 'duplicateIgnoreWindowMs': duplicateIgnoreWindowMs, - 'delayBeforeFocusMs': delayBeforeFocusMs, - 'delayBeforePasteMs': delayBeforePasteMs, - 'maxFocusVerifyAttempts': maxFocusVerifyAttempts, - if (lastBackupDateUtc != null) - 'lastBackupDateUtc': lastBackupDateUtc!.toIso8601String(), - 'popupWidth': popupWidth, - 'popupHeight': popupHeight, - 'cardMinLines': cardMinLines, - 'cardMaxLines': cardMaxLines, - 'hideOnDeactivate': hideOnDeactivate, - 'resetScrollOnShow': resetScrollOnShow, - 'resetSearchOnShow': resetSearchOnShow, - 'resetFiltersOnShow': resetFiltersOnShow, - 'hasSeenHint': hasSeenHint, - 'themeMode': themeMode, - 'accessibilityWasGranted': accessibilityWasGranted, - 'lastRunVersion': lastRunVersion, - 'hasSeenOnboarding': hasSeenOnboarding, - 'generateImageThumbnails': generateImageThumbnails, - 'generateVideoThumbnails': generateVideoThumbnails, - 'generateAudioThumbnails': generateAudioThumbnails, - 'maxImageProcessingSizeMB': maxImageProcessingSizeMB, - 'imagesQuotaMB': imagesQuotaMB, - 'rememberWindowPosition': rememberWindowPosition, - if (lastWindowX != null) 'lastWindowX': lastWindowX, - if (lastWindowY != null) 'lastWindowY': lastWindowY, - }; - - static Future load(String configPath) async { - final file = File(configPath); - if (!file.existsSync()) return AppConfig.defaultForCurrentPlatform(); - try { - final json = jsonDecode(file.readAsStringSync()) as Map; - final config = AppConfig.fromJson(json); - final storedShortcutVersion = - json['shortcutDefaultsVersion'] as int? ?? 1; - final storedPasteVersion = json['pasteDefaultsVersion'] as int? ?? 1; - if (storedShortcutVersion < shortcutDefaultsVersion || - storedPasteVersion < pasteDefaultsVersion) { - try { - await config.save(configPath); - } catch (e) { - AppLogger.warn('Failed to persist config migration: $e'); - } - } - return config; - } catch (e) { - AppLogger.error('Failed to load config: $e'); - return AppConfig.defaultForCurrentPlatform(); - } - } - - Future save(String configPath) async { - final file = File(configPath); - await file.create(recursive: true); - await file.writeAsString( - const JsonEncoder.withIndent(' ').convert(toJson()), - ); - } -} diff --git a/core/lib/config/storage_config.dart b/core/lib/config/storage_config.dart deleted file mode 100644 index 2e89f3e5..00000000 --- a/core/lib/config/storage_config.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'dart:io'; - -import '../services/app_logger.dart'; -import 'app_config.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 configFilePath => p.join(configPath, AppConfig.fileName); - - 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'); - } - } - - void clearInitialized() { - try { - final f = File(_initFlagPath); - if (f.existsSync()) f.deleteSync(); - // coverage:ignore-start - } catch (e) { - AppLogger.error('Failed to clear initialized flag: $e'); - // coverage:ignore-end - } - } - - 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(); - // coverage:ignore-start - } catch (e) { - AppLogger.error('Failed to delete orphan file: $e'); - // coverage:ignore-end - } - } - } - } -} diff --git a/core/lib/core.dart b/core/lib/core.dart deleted file mode 100644 index 3bc0b050..00000000 --- a/core/lib/core.dart +++ /dev/null @@ -1,16 +0,0 @@ -export 'config/app_config.dart'; -export 'config/storage_config.dart'; -export 'models/card_color.dart'; -export 'models/clipboard_content_type.dart'; -export 'models/clipboard_item.dart'; -export 'repository/sqlite_repository.dart'; -export 'services/app_logger.dart'; -export 'services/backup_service.dart'; -export 'services/cleanup_service.dart'; -export 'services/clipboard_service.dart'; -export 'services/crash_logger.dart'; -export 'services/native_thumbnail_provider.dart'; -export 'services/support_service.dart'; -export 'services/text_classifier.dart'; -export 'services/thumbnail_queue.dart'; -export 'services/thumbnail_service.dart'; diff --git a/core/lib/models/.gitkeep b/core/lib/models/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/core/lib/models/card_color.dart b/core/lib/models/card_color.dart deleted file mode 100644 index 370877cf..00000000 --- a/core/lib/models/card_color.dart +++ /dev/null @@ -1,17 +0,0 @@ -enum CardColor { - none(0, 0x00000000), - red(1, 0xFFE74C3C), - green(2, 0xFF2ECC71), - purple(3, 0xFF9B59B6), - yellow(4, 0xFFF1C40F), - blue(5, 0xFF3498DB), - orange(6, 0xFFE67E22); - - const CardColor(this.value, this.argb); - - final int value; - final int argb; - - static CardColor fromValue(int value) => - CardColor.values.firstWhere((c) => c.value == value, orElse: () => none); -} diff --git a/core/lib/models/clipboard_content_type.dart b/core/lib/models/clipboard_content_type.dart deleted file mode 100644 index 46857a21..00000000 --- a/core/lib/models/clipboard_content_type.dart +++ /dev/null @@ -1,50 +0,0 @@ -enum ClipboardContentType { - unknown, - text, - image, - file, - folder, - link, - audio, - video, - email, - phone, - color, - ip, - uuid, - json; - - static ClipboardContentType fromValue(int value) => switch (value) { - 0 => text, - 1 => image, - 2 => file, - 3 => folder, - 4 => link, - 5 => audio, - 6 => video, - 7 => email, - 8 => phone, - 9 => color, - 10 => ip, - 11 => uuid, - 12 => json, - _ => unknown, - }; - - int get value => switch (this) { - unknown => -1, - text => 0, - image => 1, - file => 2, - folder => 3, - link => 4, - audio => 5, - video => 6, - email => 7, - phone => 8, - color => 9, - ip => 10, - uuid => 11, - json => 12, - }; -} diff --git a/core/lib/models/clipboard_item.dart b/core/lib/models/clipboard_item.dart deleted file mode 100644 index 6b774356..00000000 --- a/core/lib/models/clipboard_item.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:uuid/uuid.dart'; - -import 'card_color.dart'; -import 'clipboard_content_type.dart'; - -const _uuid = Uuid(); -const _sentinel = Object(); - -class ClipboardItem { - ClipboardItem({ - String? id, - required this.content, - required this.type, - DateTime? createdAt, - DateTime? modifiedAt, - this.appSource, - this.isPinned = false, - this.label, - this.cardColor = CardColor.none, - this.metadata, - this.pasteCount = 0, - this.contentHash, - this.thumbPath, - this.sourceModifiedAt, - this.brokenSince, - }) : id = id ?? _uuid.v4(), - createdAt = createdAt ?? DateTime.now().toUtc(), - modifiedAt = modifiedAt ?? DateTime.now().toUtc(); - - static const int maxLabelLength = 50; - - final String id; - final String content; - final ClipboardContentType type; - final DateTime createdAt; - final DateTime modifiedAt; - final String? appSource; - final bool isPinned; - final String? label; - final CardColor cardColor; - final String? metadata; - final int pasteCount; - final String? contentHash; - - final String? thumbPath; - - final DateTime? sourceModifiedAt; - - final DateTime? brokenSince; - - bool get isFileBasedType => - type == ClipboardContentType.file || - type == ClipboardContentType.folder || - type == ClipboardContentType.audio || - type == ClipboardContentType.video; - - bool get hasRichText => _hasMetadataPayload('rtf'); - - bool get hasFormatting => - _hasMetadataPayload('rtf') || _hasMetadataPayload('html'); - - bool _hasMetadataPayload(String key) { - final raw = metadata; - if (raw == null || raw.isEmpty) return false; - try { - final decoded = jsonDecode(raw); - if (decoded is! Map) return false; - final value = decoded[key]; - return value is String && value.isNotEmpty; - } catch (_) { - return false; - } - } - - ClipboardItem copyWith({ - String? content, - ClipboardContentType? type, - DateTime? createdAt, - DateTime? modifiedAt, - Object? appSource = _sentinel, - bool? isPinned, - Object? label = _sentinel, - CardColor? cardColor, - Object? metadata = _sentinel, - int? pasteCount, - Object? contentHash = _sentinel, - Object? thumbPath = _sentinel, - Object? sourceModifiedAt = _sentinel, - Object? brokenSince = _sentinel, - }) => ClipboardItem( - id: id, - content: content ?? this.content, - type: type ?? this.type, - createdAt: createdAt ?? this.createdAt, - modifiedAt: modifiedAt ?? this.modifiedAt, - appSource: appSource == _sentinel ? this.appSource : appSource as String?, - isPinned: isPinned ?? this.isPinned, - label: label == _sentinel ? this.label : label as String?, - cardColor: cardColor ?? this.cardColor, - metadata: metadata == _sentinel ? this.metadata : metadata as String?, - pasteCount: pasteCount ?? this.pasteCount, - contentHash: contentHash == _sentinel - ? this.contentHash - : contentHash as String?, - thumbPath: thumbPath == _sentinel ? this.thumbPath : thumbPath as String?, - sourceModifiedAt: sourceModifiedAt == _sentinel - ? this.sourceModifiedAt - : sourceModifiedAt as DateTime?, - brokenSince: brokenSince == _sentinel - ? this.brokenSince - : brokenSince as DateTime?, - ); - - bool isFileAvailable() { - if (!isFileBasedType) return true; - if (content.isEmpty) return false; - final paths = content.split('\n').where((s) => s.isNotEmpty).toList(); - if (paths.isEmpty) return false; - return paths.every( - (p) => File(p).existsSync() || Directory(p).existsSync(), - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || other is ClipboardItem && id == other.id; - - @override - int get hashCode => id.hashCode; -} diff --git a/core/lib/repository/.gitkeep b/core/lib/repository/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/core/lib/repository/i_clipboard_repository.dart b/core/lib/repository/i_clipboard_repository.dart deleted file mode 100644 index 8d69f815..00000000 --- a/core/lib/repository/i_clipboard_repository.dart +++ /dev/null @@ -1,37 +0,0 @@ -import '../models/card_color.dart'; -import '../models/clipboard_content_type.dart'; -import '../models/clipboard_item.dart'; - -abstract interface class IClipboardRepository { - Future save(ClipboardItem item); - Future update(ClipboardItem item); - Future getById(String id); - Future getLatest(); - Future findByContentAndType( - String content, - ClipboardContentType type, - ); - Future findByContentHash(String contentHash); - Future> getAll(); - Future delete(String id); - Future clearOldItems(int days, {bool excludePinned = true}); - Future deleteAllUnpinned(); - Future count(); - Future> search( - String query, { - int limit = 50, - int skip = 0, - }); - Future> searchAdvanced({ - String? query, - List? types, - List? colors, - bool? isPinned, - required int limit, - required int skip, - }); - Future> getImagePaths(); - Future> getThumbPaths(); - Future walCheckpoint(); - Future close(); -} diff --git a/core/lib/repository/sqlite_repository.dart b/core/lib/repository/sqlite_repository.dart deleted file mode 100644 index 7d8ae89c..00000000 --- a/core/lib/repository/sqlite_repository.dart +++ /dev/null @@ -1,588 +0,0 @@ -import 'dart:io'; - -import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; - -import '../models/card_color.dart'; -import '../models/clipboard_content_type.dart'; -import '../models/clipboard_item.dart'; -import '../search/search_helper.dart'; -import '../services/app_logger.dart'; -import 'i_clipboard_repository.dart'; - -part 'sqlite_repository.g.dart'; - -@DataClassName('ClipboardRow') -class ClipboardItems extends Table { - TextColumn get id => text()(); - TextColumn get content => text()(); - IntColumn get type => integer()(); - DateTimeColumn get createdAt => dateTime()(); - DateTimeColumn get modifiedAt => dateTime()(); - TextColumn get appSource => text().nullable()(); - BoolColumn get isPinned => boolean().withDefault(const Constant(false))(); - TextColumn get label => text().nullable()(); - IntColumn get cardColor => integer().withDefault(const Constant(0))(); - TextColumn get metadata => text().nullable()(); - IntColumn get pasteCount => integer().withDefault(const Constant(0))(); - TextColumn get contentHash => text().nullable()(); - TextColumn get thumbPath => text().nullable()(); - DateTimeColumn get sourceModifiedAt => dateTime().nullable()(); - DateTimeColumn get brokenSince => dateTime().nullable()(); - - @override - Set get primaryKey => {id}; -} - -@DriftDatabase(tables: [ClipboardItems]) -class _AppDatabase extends _$_AppDatabase { - _AppDatabase(super.e); - - @override - int get schemaVersion => 4; - - @override - MigrationStrategy get migration => MigrationStrategy( - onCreate: (m) async { - await m.createAll(); - await _createIndexes(); - }, - onUpgrade: (m, from, to) async { - if (from < 2) { - await _createIndexes(); - } - if (from < 3) { - await m.addColumn(clipboardItems, clipboardItems.thumbPath); - await m.addColumn(clipboardItems, clipboardItems.sourceModifiedAt); - } - if (from < 4) { - await m.addColumn(clipboardItems, clipboardItems.brokenSince); - } - }, - beforeOpen: (details) async { - await customStatement('PRAGMA journal_mode = WAL'); - await customStatement('PRAGMA synchronous = NORMAL'); - await customStatement('PRAGMA cache_size = -2000'); - // Overwrite freed pages instead of leaving copied passwords readable in - // the file after a delete. Unlike auto_vacuum this applies at any time. - await customStatement('PRAGMA secure_delete = ON'); - await _ensureIncrementalVacuum(); - - await customStatement(''' - CREATE VIRTUAL TABLE IF NOT EXISTS ClipboardItems_fts USING fts5( - content, - app_source, - label, - content='clipboard_items', - content_rowid='rowid' - ) - '''); - - await customStatement(''' - CREATE TRIGGER IF NOT EXISTS clipboard_items_ai AFTER INSERT ON clipboard_items BEGIN - INSERT INTO ClipboardItems_fts(rowid, content, app_source, label) - VALUES (NEW.rowid, NEW.content, NEW.app_source, NEW.label); - END - '''); - - await customStatement(''' - CREATE TRIGGER IF NOT EXISTS clipboard_items_ad AFTER DELETE ON clipboard_items BEGIN - INSERT INTO ClipboardItems_fts(ClipboardItems_fts, rowid, content, app_source, label) - VALUES ('delete', OLD.rowid, OLD.content, OLD.app_source, OLD.label); - END - '''); - - await customStatement(''' - CREATE TRIGGER IF NOT EXISTS clipboard_items_au AFTER UPDATE ON clipboard_items BEGIN - INSERT INTO ClipboardItems_fts(ClipboardItems_fts, rowid, content, app_source, label) - VALUES ('delete', OLD.rowid, OLD.content, OLD.app_source, OLD.label); - INSERT INTO ClipboardItems_fts(rowid, content, app_source, label) - VALUES (NEW.rowid, NEW.content, NEW.app_source, NEW.label); - END - '''); - }, - ); - - /// SQLite silently ignores `auto_vacuum` on a database that already has - /// tables, and beforeOpen runs after onCreate — so the pragma never took and - /// every `incremental_vacuum` below was a no-op. Switching it needs a full - /// VACUUM, which is why this only runs when the mode is still NONE. - Future _ensureIncrementalVacuum() async { - try { - final rows = await customSelect('PRAGMA auto_vacuum').get(); - final mode = rows.isEmpty - ? null - : rows.first.data.values.first as int? ?? 0; - if (mode == 2) return; - await customStatement('PRAGMA auto_vacuum = INCREMENTAL'); - if (mode == 0) await customStatement('VACUUM'); - // coverage:ignore-start - } catch (e) { - AppLogger.warn('auto_vacuum setup failed: $e'); - // coverage:ignore-end - } - } - - Future _createIndexes() async { - await customStatement( - 'CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard_items(content_hash)', - ); - await customStatement( - 'CREATE INDEX IF NOT EXISTS idx_content_type ON clipboard_items(content, type)', - ); - await customStatement( - 'CREATE INDEX IF NOT EXISTS idx_modified_at ON clipboard_items(modified_at DESC)', - ); - await customStatement( - 'CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_items(created_at)', - ); - await customStatement( - 'CREATE INDEX IF NOT EXISTS idx_is_pinned ON clipboard_items(is_pinned)', - ); - await customStatement( - 'CREATE INDEX IF NOT EXISTS idx_card_color ON clipboard_items(card_color)', - ); - await customStatement( - 'CREATE INDEX IF NOT EXISTS idx_type_modified ON clipboard_items(type, modified_at DESC)', - ); - } -} - -class SqliteRepository implements IClipboardRepository { - SqliteRepository._(this._db); - - factory SqliteRepository.fromPath(String dbPath) { - final db = _AppDatabase( - LazyDatabase(() async { - try { - return NativeDatabase.createInBackground(File(dbPath)); - } catch (e, s) { - AppLogger.exception( - e, - s, - 'SqliteRepository.fromPath — attempting recovery', - ); - _handleCorruptDatabase(dbPath); - return NativeDatabase.createInBackground(File(dbPath)); - } - }), - ); - return SqliteRepository._(db); - } - - factory SqliteRepository.inMemory() => - SqliteRepository._(_AppDatabase(NativeDatabase.memory())); - - final _AppDatabase _db; - - static void _handleCorruptDatabase(String dbPath) { - final file = File(dbPath); - if (!file.existsSync()) return; - try { - final timestamp = DateTime.now().toUtc().millisecondsSinceEpoch; - final backupPath = '$dbPath.backup.$timestamp'; - file.renameSync(backupPath); - AppLogger.warn( - '_handleCorruptDatabase: renamed corrupt DB to $backupPath', - ); - } catch (e) { - AppLogger.error('_handleCorruptDatabase: could not rename DB: $e'); - } - try { - File('$dbPath-wal').deleteSync(); - } catch (e) { - AppLogger.warn('_handleCorruptDatabase: could not delete WAL: $e'); - } - try { - File('$dbPath-shm').deleteSync(); - } catch (e) { - AppLogger.warn('_handleCorruptDatabase: could not delete SHM: $e'); - } - } - - ClipboardItem _fromRow(ClipboardRow row) => ClipboardItem( - id: row.id, - content: row.content, - type: ClipboardContentType.fromValue(row.type), - createdAt: row.createdAt, - modifiedAt: row.modifiedAt, - appSource: row.appSource, - isPinned: row.isPinned, - label: row.label, - cardColor: CardColor.fromValue(row.cardColor), - metadata: row.metadata, - pasteCount: row.pasteCount, - contentHash: row.contentHash, - thumbPath: row.thumbPath, - sourceModifiedAt: row.sourceModifiedAt, - brokenSince: row.brokenSince, - ); - - ClipboardItemsCompanion _toCompanion(ClipboardItem item) => - ClipboardItemsCompanion( - id: Value(item.id), - content: Value(item.content), - type: Value(item.type.value), - createdAt: Value(item.createdAt), - modifiedAt: Value(item.modifiedAt), - appSource: Value(item.appSource), - isPinned: Value(item.isPinned), - label: Value(item.label), - cardColor: Value(item.cardColor.value), - metadata: Value(item.metadata), - pasteCount: Value(item.pasteCount), - contentHash: Value(item.contentHash), - thumbPath: Value(item.thumbPath), - sourceModifiedAt: Value(item.sourceModifiedAt), - brokenSince: Value(item.brokenSince), - ); - - ClipboardItem _fromQueryRow(QueryRow row) => ClipboardItem( - id: row.read('id'), - content: row.read('content'), - type: ClipboardContentType.fromValue(row.read('type')), - createdAt: row.read('created_at'), - modifiedAt: row.read('modified_at'), - appSource: row.readNullable('app_source'), - isPinned: row.read('is_pinned'), - label: row.readNullable('label'), - cardColor: CardColor.fromValue(row.read('card_color')), - metadata: row.readNullable('metadata'), - pasteCount: row.read('paste_count'), - contentHash: row.readNullable('content_hash'), - thumbPath: row.readNullable('thumb_path'), - sourceModifiedAt: row.readNullable('source_modified_at'), - brokenSince: row.readNullable('broken_since'), - ); - - @override - Future save(ClipboardItem item) => - _db.into(_db.clipboardItems).insert(_toCompanion(item)); - - @override - Future update(ClipboardItem item) async { - await (_db.update( - _db.clipboardItems, - )..where((t) => t.id.equals(item.id))).write(_toCompanion(item)); - } - - @override - Future getById(String id) async { - final row = await (_db.select( - _db.clipboardItems, - )..where((t) => t.id.equals(id))).getSingleOrNull(); - return row == null ? null : _fromRow(row); - } - - @override - Future getLatest() async { - final row = - await (_db.select(_db.clipboardItems) - ..orderBy([(t) => OrderingTerm.desc(t.modifiedAt)]) - ..limit(1)) - .getSingleOrNull(); - return row == null ? null : _fromRow(row); - } - - @override - Future findByContentAndType( - String content, - ClipboardContentType type, - ) async { - final row = - await (_db.select(_db.clipboardItems)..where( - (t) => t.content.equals(content) & t.type.equals(type.value), - )) - .getSingleOrNull(); - return row == null ? null : _fromRow(row); - } - - @override - Future findByContentHash(String contentHash) async { - final row = await (_db.select( - _db.clipboardItems, - )..where((t) => t.contentHash.equals(contentHash))).getSingleOrNull(); - return row == null ? null : _fromRow(row); - } - - @override - Future> getAll() async { - final rows = await (_db.select( - _db.clipboardItems, - )..orderBy([(t) => OrderingTerm.desc(t.modifiedAt)])).get(); - return rows.map(_fromRow).toList(); - } - - @override - Future delete(String id) => - (_db.delete(_db.clipboardItems)..where((t) => t.id.equals(id))).go(); - - @override - Future clearOldItems(int days, {bool excludePinned = true}) async { - final cutoff = DateTime.now().toUtc().subtract(Duration(days: days)); - final deleted = - await (_db.delete(_db.clipboardItems)..where((t) { - final isOld = t.createdAt.isSmallerThanValue(cutoff); - return excludePinned ? isOld & t.isPinned.equals(false) : isOld; - })) - .go(); - - if (deleted > 50) { - try { - await _db.customStatement('PRAGMA incremental_vacuum'); - } catch (e) { - AppLogger.error('incremental_vacuum failed: $e'); - } - // The deleted rows survive in the -wal until it is truncated. - await walCheckpoint(); - } - - return deleted; - } - - @override - Future deleteAllUnpinned() async { - final deleted = await (_db.delete( - _db.clipboardItems, - )..where((t) => t.isPinned.equals(false))).go(); - if (deleted > 50) { - try { - await _db.customStatement('PRAGMA incremental_vacuum'); - } catch (e) { - AppLogger.error('incremental_vacuum failed: $e'); - } - // The deleted rows survive in the -wal until it is truncated. - await walCheckpoint(); - } - return deleted; - } - - @override - Future count() async { - final result = await _db - .customSelect('SELECT COUNT(*) AS c FROM clipboard_items') - .getSingle(); - return result.read('c'); - } - - @override - Future> search( - String query, { - int limit = 50, - int skip = 0, - }) => searchAdvanced(query: query, limit: limit, skip: skip); - - @override - Future> searchAdvanced({ - String? query, - List? types, - List? colors, - bool? isPinned, - required int limit, - required int skip, - }) async { - final normalized = (query != null && query.isNotEmpty) - ? SearchHelper.normalize(query) - : null; - - final hasTextQuery = normalized != null && normalized.isNotEmpty; - final hasTypeFilter = types != null && types.isNotEmpty; - final hasColorFilter = colors != null && colors.isNotEmpty; - - final conditions = []; - final variables = []; - - if (isPinned != null) { - conditions.add('c.is_pinned = ?'); - variables.add(Variable.withBool(isPinned)); - } - - final effectiveTypes = hasTypeFilter ? types : null; - if (effectiveTypes != null) { - final placeholders = List.filled(effectiveTypes.length, '?').join(', '); - conditions.add('c.type IN ($placeholders)'); - for (final t in effectiveTypes) { - variables.add(Variable.withInt(t.value)); - } - } - - final effectiveColors = hasColorFilter ? colors : null; - if (effectiveColors != null) { - final placeholders = List.filled(effectiveColors.length, '?').join(', '); - conditions.add('c.card_color IN ($placeholders)'); - for (final c in effectiveColors) { - variables.add(Variable.withInt(c.value)); - } - } - - final filterClause = conditions.isEmpty ? '1=1' : conditions.join(' AND '); - - if (hasTextQuery) { - // Strip everything that isn't alphanumeric or whitespace for the FTS - // query. FTS5 unicode61 tokenizer treats punctuation as delimiters, so - // passing ".pdf*" or "@*" causes a syntax error or zero matches. - // The full normalized string (with symbols) is kept for the LIKE pattern. - final ftsSafe = normalized - .replaceAll(RegExp(r'[^a-zA-Z0-9\s]'), '') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - final likePattern = '%$normalized%'; - - // If nothing alphanumeric remains, skip FTS entirely and use LIKE only. - final hasFtsTokens = ftsSafe.isNotEmpty; - - if (hasFtsTokens) { - final ftsQuery = '$ftsSafe*'; - final ftsFilterVars = [...variables]; - final likeFilterVars = [...variables]; - - // LIKE rows always sort after FTS rows, so only the newest (skip+limit) - // of them can reach the page; bounding the CTE keeps the result exact - // while avoiding a full-table scan into memory on every keystroke. - final likeBound = skip + limit; - final allVariables = [ - Variable.withString(ftsQuery), - ...ftsFilterVars, - ...likeFilterVars, - Variable.withString(likePattern), - Variable.withString(likePattern), - Variable.withString(likePattern), - Variable.withInt(likeBound), - Variable.withInt(limit), - Variable.withInt(skip), - ]; - - final sql = - ''' - WITH fts_results AS ( - SELECT c.*, bm25(ClipboardItems_fts) AS rank, 1 AS source - FROM clipboard_items c - INNER JOIN ClipboardItems_fts fts ON c.rowid = fts.rowid - WHERE ClipboardItems_fts MATCH ? AND $filterClause - ), - like_results AS ( - SELECT c.*, 0.0 AS rank, 2 AS source - FROM clipboard_items c - WHERE $filterClause - AND (LOWER(c.content) LIKE ? OR LOWER(c.label) LIKE ? OR LOWER(c.app_source) LIKE ?) - AND c.id NOT IN (SELECT id FROM fts_results) - ORDER BY c.modified_at DESC - LIMIT ? - ) - SELECT * FROM ( - SELECT * FROM fts_results - UNION ALL - SELECT * FROM like_results - ) - ORDER BY source ASC, rank ASC, modified_at DESC - LIMIT ? OFFSET ? - '''; - - final expectedCount = '?'.allMatches(sql).length; - assert( - allVariables.length == expectedCount, - 'SQL variable count mismatch: expected $expectedCount, got ${allVariables.length}', - ); - - final results = await _db - .customSelect( - sql, - variables: allVariables, - readsFrom: {_db.clipboardItems}, - ) - .get(); - - return results.map((row) => _fromQueryRow(row)).toList(); - } - - // LIKE-only path: query is pure punctuation/symbols (e.g. "@", ".", "+"). - // FTS5 would produce no results for these, so skip it entirely. - final allVariables = [ - ...variables, - Variable.withString(likePattern), - Variable.withString(likePattern), - Variable.withString(likePattern), - Variable.withInt(limit), - Variable.withInt(skip), - ]; - - final sql = - ''' - SELECT c.*, 0.0 AS rank - FROM clipboard_items c - WHERE $filterClause - AND (LOWER(c.content) LIKE ? OR LOWER(c.label) LIKE ? OR LOWER(c.app_source) LIKE ?) - ORDER BY c.modified_at DESC - LIMIT ? OFFSET ? - '''; - - final expectedCount = '?'.allMatches(sql).length; - assert( - allVariables.length == expectedCount, - 'SQL variable count mismatch: expected $expectedCount, got ${allVariables.length}', - ); - - final results = await _db - .customSelect( - sql, - variables: allVariables, - readsFrom: {_db.clipboardItems}, - ) - .get(); - - return results.map((row) => _fromQueryRow(row)).toList(); - } - - final results = await _db - .customSelect( - ''' - SELECT c.* FROM clipboard_items c - WHERE $filterClause - ORDER BY c.modified_at DESC - LIMIT ? OFFSET ? - ''', - variables: [ - ...variables, - Variable.withInt(limit), - Variable.withInt(skip), - ], - readsFrom: {_db.clipboardItems}, - ) - .get(); - - return results.map((row) => _fromQueryRow(row)).toList(); - } - - @override - Future> getImagePaths() async { - final rows = - await (_db.select(_db.clipboardItems) - ..where((t) => t.type.equals(ClipboardContentType.image.value)) - ..where((t) => t.content.length.isBiggerThanValue(0))) - .get(); - return rows.map((r) => r.content).toList(); - } - - @override - Future> getThumbPaths() async { - final rows = await (_db.select( - _db.clipboardItems, - )..where((t) => t.thumbPath.isNotNull())).get(); - return [ - for (final r in rows) - if (r.thumbPath != null && r.thumbPath!.isNotEmpty) r.thumbPath!, - ]; - } - - @override - Future walCheckpoint() async { - try { - await _db.customStatement('PRAGMA wal_checkpoint(TRUNCATE)'); - } catch (e) { - AppLogger.error('walCheckpoint failed: $e'); - } - } - - @override - Future close() => _db.close(); -} diff --git a/core/lib/repository/sqlite_repository.g.dart b/core/lib/repository/sqlite_repository.g.dart deleted file mode 100644 index 7c286480..00000000 --- a/core/lib/repository/sqlite_repository.g.dart +++ /dev/null @@ -1,1297 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'sqlite_repository.dart'; - -// ignore_for_file: type=lint -class $ClipboardItemsTable extends ClipboardItems - with TableInfo<$ClipboardItemsTable, ClipboardRow> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $ClipboardItemsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _contentMeta = const VerificationMeta( - 'content', - ); - @override - late final GeneratedColumn content = GeneratedColumn( - 'content', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _typeMeta = const VerificationMeta('type'); - @override - late final GeneratedColumn type = GeneratedColumn( - 'type', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - ); - static const VerificationMeta _createdAtMeta = const VerificationMeta( - 'createdAt', - ); - @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', - aliasedName, - false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - ); - static const VerificationMeta _modifiedAtMeta = const VerificationMeta( - 'modifiedAt', - ); - @override - late final GeneratedColumn modifiedAt = GeneratedColumn( - 'modified_at', - aliasedName, - false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - ); - static const VerificationMeta _appSourceMeta = const VerificationMeta( - 'appSource', - ); - @override - late final GeneratedColumn appSource = GeneratedColumn( - 'app_source', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _isPinnedMeta = const VerificationMeta( - 'isPinned', - ); - @override - late final GeneratedColumn isPinned = GeneratedColumn( - 'is_pinned', - aliasedName, - false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("is_pinned" IN (0, 1))', - ), - defaultValue: const Constant(false), - ); - static const VerificationMeta _labelMeta = const VerificationMeta('label'); - @override - late final GeneratedColumn label = GeneratedColumn( - 'label', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _cardColorMeta = const VerificationMeta( - 'cardColor', - ); - @override - late final GeneratedColumn cardColor = GeneratedColumn( - 'card_color', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0), - ); - static const VerificationMeta _metadataMeta = const VerificationMeta( - 'metadata', - ); - @override - late final GeneratedColumn metadata = GeneratedColumn( - 'metadata', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _pasteCountMeta = const VerificationMeta( - 'pasteCount', - ); - @override - late final GeneratedColumn pasteCount = GeneratedColumn( - 'paste_count', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0), - ); - static const VerificationMeta _contentHashMeta = const VerificationMeta( - 'contentHash', - ); - @override - late final GeneratedColumn contentHash = GeneratedColumn( - 'content_hash', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _thumbPathMeta = const VerificationMeta( - 'thumbPath', - ); - @override - late final GeneratedColumn thumbPath = GeneratedColumn( - 'thumb_path', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _sourceModifiedAtMeta = const VerificationMeta( - 'sourceModifiedAt', - ); - @override - late final GeneratedColumn sourceModifiedAt = - GeneratedColumn( - 'source_modified_at', - aliasedName, - true, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - ); - static const VerificationMeta _brokenSinceMeta = const VerificationMeta( - 'brokenSince', - ); - @override - late final GeneratedColumn brokenSince = GeneratedColumn( - 'broken_since', - aliasedName, - true, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - ); - @override - List get $columns => [ - id, - content, - type, - createdAt, - modifiedAt, - appSource, - isPinned, - label, - cardColor, - metadata, - pasteCount, - contentHash, - thumbPath, - sourceModifiedAt, - brokenSince, - ]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'clipboard_items'; - @override - VerificationContext validateIntegrity( - Insertable instance, { - bool isInserting = false, - }) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } else if (isInserting) { - context.missing(_idMeta); - } - if (data.containsKey('content')) { - context.handle( - _contentMeta, - content.isAcceptableOrUnknown(data['content']!, _contentMeta), - ); - } else if (isInserting) { - context.missing(_contentMeta); - } - if (data.containsKey('type')) { - context.handle( - _typeMeta, - type.isAcceptableOrUnknown(data['type']!, _typeMeta), - ); - } else if (isInserting) { - context.missing(_typeMeta); - } - if (data.containsKey('created_at')) { - context.handle( - _createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), - ); - } else if (isInserting) { - context.missing(_createdAtMeta); - } - if (data.containsKey('modified_at')) { - context.handle( - _modifiedAtMeta, - modifiedAt.isAcceptableOrUnknown(data['modified_at']!, _modifiedAtMeta), - ); - } else if (isInserting) { - context.missing(_modifiedAtMeta); - } - if (data.containsKey('app_source')) { - context.handle( - _appSourceMeta, - appSource.isAcceptableOrUnknown(data['app_source']!, _appSourceMeta), - ); - } - if (data.containsKey('is_pinned')) { - context.handle( - _isPinnedMeta, - isPinned.isAcceptableOrUnknown(data['is_pinned']!, _isPinnedMeta), - ); - } - if (data.containsKey('label')) { - context.handle( - _labelMeta, - label.isAcceptableOrUnknown(data['label']!, _labelMeta), - ); - } - if (data.containsKey('card_color')) { - context.handle( - _cardColorMeta, - cardColor.isAcceptableOrUnknown(data['card_color']!, _cardColorMeta), - ); - } - if (data.containsKey('metadata')) { - context.handle( - _metadataMeta, - metadata.isAcceptableOrUnknown(data['metadata']!, _metadataMeta), - ); - } - if (data.containsKey('paste_count')) { - context.handle( - _pasteCountMeta, - pasteCount.isAcceptableOrUnknown(data['paste_count']!, _pasteCountMeta), - ); - } - if (data.containsKey('content_hash')) { - context.handle( - _contentHashMeta, - contentHash.isAcceptableOrUnknown( - data['content_hash']!, - _contentHashMeta, - ), - ); - } - if (data.containsKey('thumb_path')) { - context.handle( - _thumbPathMeta, - thumbPath.isAcceptableOrUnknown(data['thumb_path']!, _thumbPathMeta), - ); - } - if (data.containsKey('source_modified_at')) { - context.handle( - _sourceModifiedAtMeta, - sourceModifiedAt.isAcceptableOrUnknown( - data['source_modified_at']!, - _sourceModifiedAtMeta, - ), - ); - } - if (data.containsKey('broken_since')) { - context.handle( - _brokenSinceMeta, - brokenSince.isAcceptableOrUnknown( - data['broken_since']!, - _brokenSinceMeta, - ), - ); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - ClipboardRow map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return ClipboardRow( - id: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}id'], - )!, - content: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}content'], - )!, - type: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}type'], - )!, - createdAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}created_at'], - )!, - modifiedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}modified_at'], - )!, - appSource: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}app_source'], - ), - isPinned: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}is_pinned'], - )!, - label: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}label'], - ), - cardColor: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}card_color'], - )!, - metadata: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}metadata'], - ), - pasteCount: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}paste_count'], - )!, - contentHash: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}content_hash'], - ), - thumbPath: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}thumb_path'], - ), - sourceModifiedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}source_modified_at'], - ), - brokenSince: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}broken_since'], - ), - ); - } - - @override - $ClipboardItemsTable createAlias(String alias) { - return $ClipboardItemsTable(attachedDatabase, alias); - } -} - -class ClipboardRow extends DataClass implements Insertable { - final String id; - final String content; - final int type; - final DateTime createdAt; - final DateTime modifiedAt; - final String? appSource; - final bool isPinned; - final String? label; - final int cardColor; - final String? metadata; - final int pasteCount; - final String? contentHash; - final String? thumbPath; - final DateTime? sourceModifiedAt; - final DateTime? brokenSince; - const ClipboardRow({ - required this.id, - required this.content, - required this.type, - required this.createdAt, - required this.modifiedAt, - this.appSource, - required this.isPinned, - this.label, - required this.cardColor, - this.metadata, - required this.pasteCount, - this.contentHash, - this.thumbPath, - this.sourceModifiedAt, - this.brokenSince, - }); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['content'] = Variable(content); - map['type'] = Variable(type); - map['created_at'] = Variable(createdAt); - map['modified_at'] = Variable(modifiedAt); - if (!nullToAbsent || appSource != null) { - map['app_source'] = Variable(appSource); - } - map['is_pinned'] = Variable(isPinned); - if (!nullToAbsent || label != null) { - map['label'] = Variable(label); - } - map['card_color'] = Variable(cardColor); - if (!nullToAbsent || metadata != null) { - map['metadata'] = Variable(metadata); - } - map['paste_count'] = Variable(pasteCount); - if (!nullToAbsent || contentHash != null) { - map['content_hash'] = Variable(contentHash); - } - if (!nullToAbsent || thumbPath != null) { - map['thumb_path'] = Variable(thumbPath); - } - if (!nullToAbsent || sourceModifiedAt != null) { - map['source_modified_at'] = Variable(sourceModifiedAt); - } - if (!nullToAbsent || brokenSince != null) { - map['broken_since'] = Variable(brokenSince); - } - return map; - } - - ClipboardItemsCompanion toCompanion(bool nullToAbsent) { - return ClipboardItemsCompanion( - id: Value(id), - content: Value(content), - type: Value(type), - createdAt: Value(createdAt), - modifiedAt: Value(modifiedAt), - appSource: appSource == null && nullToAbsent - ? const Value.absent() - : Value(appSource), - isPinned: Value(isPinned), - label: label == null && nullToAbsent - ? const Value.absent() - : Value(label), - cardColor: Value(cardColor), - metadata: metadata == null && nullToAbsent - ? const Value.absent() - : Value(metadata), - pasteCount: Value(pasteCount), - contentHash: contentHash == null && nullToAbsent - ? const Value.absent() - : Value(contentHash), - thumbPath: thumbPath == null && nullToAbsent - ? const Value.absent() - : Value(thumbPath), - sourceModifiedAt: sourceModifiedAt == null && nullToAbsent - ? const Value.absent() - : Value(sourceModifiedAt), - brokenSince: brokenSince == null && nullToAbsent - ? const Value.absent() - : Value(brokenSince), - ); - } - - factory ClipboardRow.fromJson( - Map json, { - ValueSerializer? serializer, - }) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return ClipboardRow( - id: serializer.fromJson(json['id']), - content: serializer.fromJson(json['content']), - type: serializer.fromJson(json['type']), - createdAt: serializer.fromJson(json['createdAt']), - modifiedAt: serializer.fromJson(json['modifiedAt']), - appSource: serializer.fromJson(json['appSource']), - isPinned: serializer.fromJson(json['isPinned']), - label: serializer.fromJson(json['label']), - cardColor: serializer.fromJson(json['cardColor']), - metadata: serializer.fromJson(json['metadata']), - pasteCount: serializer.fromJson(json['pasteCount']), - contentHash: serializer.fromJson(json['contentHash']), - thumbPath: serializer.fromJson(json['thumbPath']), - sourceModifiedAt: serializer.fromJson( - json['sourceModifiedAt'], - ), - brokenSince: serializer.fromJson(json['brokenSince']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'content': serializer.toJson(content), - 'type': serializer.toJson(type), - 'createdAt': serializer.toJson(createdAt), - 'modifiedAt': serializer.toJson(modifiedAt), - 'appSource': serializer.toJson(appSource), - 'isPinned': serializer.toJson(isPinned), - 'label': serializer.toJson(label), - 'cardColor': serializer.toJson(cardColor), - 'metadata': serializer.toJson(metadata), - 'pasteCount': serializer.toJson(pasteCount), - 'contentHash': serializer.toJson(contentHash), - 'thumbPath': serializer.toJson(thumbPath), - 'sourceModifiedAt': serializer.toJson(sourceModifiedAt), - 'brokenSince': serializer.toJson(brokenSince), - }; - } - - ClipboardRow copyWith({ - String? id, - String? content, - int? type, - DateTime? createdAt, - DateTime? modifiedAt, - Value appSource = const Value.absent(), - bool? isPinned, - Value label = const Value.absent(), - int? cardColor, - Value metadata = const Value.absent(), - int? pasteCount, - Value contentHash = const Value.absent(), - Value thumbPath = const Value.absent(), - Value sourceModifiedAt = const Value.absent(), - Value brokenSince = const Value.absent(), - }) => ClipboardRow( - id: id ?? this.id, - content: content ?? this.content, - type: type ?? this.type, - createdAt: createdAt ?? this.createdAt, - modifiedAt: modifiedAt ?? this.modifiedAt, - appSource: appSource.present ? appSource.value : this.appSource, - isPinned: isPinned ?? this.isPinned, - label: label.present ? label.value : this.label, - cardColor: cardColor ?? this.cardColor, - metadata: metadata.present ? metadata.value : this.metadata, - pasteCount: pasteCount ?? this.pasteCount, - contentHash: contentHash.present ? contentHash.value : this.contentHash, - thumbPath: thumbPath.present ? thumbPath.value : this.thumbPath, - sourceModifiedAt: sourceModifiedAt.present - ? sourceModifiedAt.value - : this.sourceModifiedAt, - brokenSince: brokenSince.present ? brokenSince.value : this.brokenSince, - ); - ClipboardRow copyWithCompanion(ClipboardItemsCompanion data) { - return ClipboardRow( - id: data.id.present ? data.id.value : this.id, - content: data.content.present ? data.content.value : this.content, - type: data.type.present ? data.type.value : this.type, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - modifiedAt: data.modifiedAt.present - ? data.modifiedAt.value - : this.modifiedAt, - appSource: data.appSource.present ? data.appSource.value : this.appSource, - isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, - label: data.label.present ? data.label.value : this.label, - cardColor: data.cardColor.present ? data.cardColor.value : this.cardColor, - metadata: data.metadata.present ? data.metadata.value : this.metadata, - pasteCount: data.pasteCount.present - ? data.pasteCount.value - : this.pasteCount, - contentHash: data.contentHash.present - ? data.contentHash.value - : this.contentHash, - thumbPath: data.thumbPath.present ? data.thumbPath.value : this.thumbPath, - sourceModifiedAt: data.sourceModifiedAt.present - ? data.sourceModifiedAt.value - : this.sourceModifiedAt, - brokenSince: data.brokenSince.present - ? data.brokenSince.value - : this.brokenSince, - ); - } - - @override - String toString() { - return (StringBuffer('ClipboardRow(') - ..write('id: $id, ') - ..write('content: $content, ') - ..write('type: $type, ') - ..write('createdAt: $createdAt, ') - ..write('modifiedAt: $modifiedAt, ') - ..write('appSource: $appSource, ') - ..write('isPinned: $isPinned, ') - ..write('label: $label, ') - ..write('cardColor: $cardColor, ') - ..write('metadata: $metadata, ') - ..write('pasteCount: $pasteCount, ') - ..write('contentHash: $contentHash, ') - ..write('thumbPath: $thumbPath, ') - ..write('sourceModifiedAt: $sourceModifiedAt, ') - ..write('brokenSince: $brokenSince') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash( - id, - content, - type, - createdAt, - modifiedAt, - appSource, - isPinned, - label, - cardColor, - metadata, - pasteCount, - contentHash, - thumbPath, - sourceModifiedAt, - brokenSince, - ); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ClipboardRow && - other.id == this.id && - other.content == this.content && - other.type == this.type && - other.createdAt == this.createdAt && - other.modifiedAt == this.modifiedAt && - other.appSource == this.appSource && - other.isPinned == this.isPinned && - other.label == this.label && - other.cardColor == this.cardColor && - other.metadata == this.metadata && - other.pasteCount == this.pasteCount && - other.contentHash == this.contentHash && - other.thumbPath == this.thumbPath && - other.sourceModifiedAt == this.sourceModifiedAt && - other.brokenSince == this.brokenSince); -} - -class ClipboardItemsCompanion extends UpdateCompanion { - final Value id; - final Value content; - final Value type; - final Value createdAt; - final Value modifiedAt; - final Value appSource; - final Value isPinned; - final Value label; - final Value cardColor; - final Value metadata; - final Value pasteCount; - final Value contentHash; - final Value thumbPath; - final Value sourceModifiedAt; - final Value brokenSince; - final Value rowid; - const ClipboardItemsCompanion({ - this.id = const Value.absent(), - this.content = const Value.absent(), - this.type = const Value.absent(), - this.createdAt = const Value.absent(), - this.modifiedAt = const Value.absent(), - this.appSource = const Value.absent(), - this.isPinned = const Value.absent(), - this.label = const Value.absent(), - this.cardColor = const Value.absent(), - this.metadata = const Value.absent(), - this.pasteCount = const Value.absent(), - this.contentHash = const Value.absent(), - this.thumbPath = const Value.absent(), - this.sourceModifiedAt = const Value.absent(), - this.brokenSince = const Value.absent(), - this.rowid = const Value.absent(), - }); - ClipboardItemsCompanion.insert({ - required String id, - required String content, - required int type, - required DateTime createdAt, - required DateTime modifiedAt, - this.appSource = const Value.absent(), - this.isPinned = const Value.absent(), - this.label = const Value.absent(), - this.cardColor = const Value.absent(), - this.metadata = const Value.absent(), - this.pasteCount = const Value.absent(), - this.contentHash = const Value.absent(), - this.thumbPath = const Value.absent(), - this.sourceModifiedAt = const Value.absent(), - this.brokenSince = const Value.absent(), - this.rowid = const Value.absent(), - }) : id = Value(id), - content = Value(content), - type = Value(type), - createdAt = Value(createdAt), - modifiedAt = Value(modifiedAt); - static Insertable custom({ - Expression? id, - Expression? content, - Expression? type, - Expression? createdAt, - Expression? modifiedAt, - Expression? appSource, - Expression? isPinned, - Expression? label, - Expression? cardColor, - Expression? metadata, - Expression? pasteCount, - Expression? contentHash, - Expression? thumbPath, - Expression? sourceModifiedAt, - Expression? brokenSince, - Expression? rowid, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (content != null) 'content': content, - if (type != null) 'type': type, - if (createdAt != null) 'created_at': createdAt, - if (modifiedAt != null) 'modified_at': modifiedAt, - if (appSource != null) 'app_source': appSource, - if (isPinned != null) 'is_pinned': isPinned, - if (label != null) 'label': label, - if (cardColor != null) 'card_color': cardColor, - if (metadata != null) 'metadata': metadata, - if (pasteCount != null) 'paste_count': pasteCount, - if (contentHash != null) 'content_hash': contentHash, - if (thumbPath != null) 'thumb_path': thumbPath, - if (sourceModifiedAt != null) 'source_modified_at': sourceModifiedAt, - if (brokenSince != null) 'broken_since': brokenSince, - if (rowid != null) 'rowid': rowid, - }); - } - - ClipboardItemsCompanion copyWith({ - Value? id, - Value? content, - Value? type, - Value? createdAt, - Value? modifiedAt, - Value? appSource, - Value? isPinned, - Value? label, - Value? cardColor, - Value? metadata, - Value? pasteCount, - Value? contentHash, - Value? thumbPath, - Value? sourceModifiedAt, - Value? brokenSince, - Value? rowid, - }) { - return ClipboardItemsCompanion( - id: id ?? this.id, - content: content ?? this.content, - type: type ?? this.type, - createdAt: createdAt ?? this.createdAt, - modifiedAt: modifiedAt ?? this.modifiedAt, - appSource: appSource ?? this.appSource, - isPinned: isPinned ?? this.isPinned, - label: label ?? this.label, - cardColor: cardColor ?? this.cardColor, - metadata: metadata ?? this.metadata, - pasteCount: pasteCount ?? this.pasteCount, - contentHash: contentHash ?? this.contentHash, - thumbPath: thumbPath ?? this.thumbPath, - sourceModifiedAt: sourceModifiedAt ?? this.sourceModifiedAt, - brokenSince: brokenSince ?? this.brokenSince, - rowid: rowid ?? this.rowid, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (content.present) { - map['content'] = Variable(content.value); - } - if (type.present) { - map['type'] = Variable(type.value); - } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } - if (modifiedAt.present) { - map['modified_at'] = Variable(modifiedAt.value); - } - if (appSource.present) { - map['app_source'] = Variable(appSource.value); - } - if (isPinned.present) { - map['is_pinned'] = Variable(isPinned.value); - } - if (label.present) { - map['label'] = Variable(label.value); - } - if (cardColor.present) { - map['card_color'] = Variable(cardColor.value); - } - if (metadata.present) { - map['metadata'] = Variable(metadata.value); - } - if (pasteCount.present) { - map['paste_count'] = Variable(pasteCount.value); - } - if (contentHash.present) { - map['content_hash'] = Variable(contentHash.value); - } - if (thumbPath.present) { - map['thumb_path'] = Variable(thumbPath.value); - } - if (sourceModifiedAt.present) { - map['source_modified_at'] = Variable(sourceModifiedAt.value); - } - if (brokenSince.present) { - map['broken_since'] = Variable(brokenSince.value); - } - if (rowid.present) { - map['rowid'] = Variable(rowid.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('ClipboardItemsCompanion(') - ..write('id: $id, ') - ..write('content: $content, ') - ..write('type: $type, ') - ..write('createdAt: $createdAt, ') - ..write('modifiedAt: $modifiedAt, ') - ..write('appSource: $appSource, ') - ..write('isPinned: $isPinned, ') - ..write('label: $label, ') - ..write('cardColor: $cardColor, ') - ..write('metadata: $metadata, ') - ..write('pasteCount: $pasteCount, ') - ..write('contentHash: $contentHash, ') - ..write('thumbPath: $thumbPath, ') - ..write('sourceModifiedAt: $sourceModifiedAt, ') - ..write('brokenSince: $brokenSince, ') - ..write('rowid: $rowid') - ..write(')')) - .toString(); - } -} - -abstract class _$_AppDatabase extends GeneratedDatabase { - _$_AppDatabase(QueryExecutor e) : super(e); - $_AppDatabaseManager get managers => $_AppDatabaseManager(this); - late final $ClipboardItemsTable clipboardItems = $ClipboardItemsTable(this); - @override - Iterable> get allTables => - allSchemaEntities.whereType>(); - @override - List get allSchemaEntities => [clipboardItems]; -} - -typedef $$ClipboardItemsTableCreateCompanionBuilder = - ClipboardItemsCompanion Function({ - required String id, - required String content, - required int type, - required DateTime createdAt, - required DateTime modifiedAt, - Value appSource, - Value isPinned, - Value label, - Value cardColor, - Value metadata, - Value pasteCount, - Value contentHash, - Value thumbPath, - Value sourceModifiedAt, - Value brokenSince, - Value rowid, - }); -typedef $$ClipboardItemsTableUpdateCompanionBuilder = - ClipboardItemsCompanion Function({ - Value id, - Value content, - Value type, - Value createdAt, - Value modifiedAt, - Value appSource, - Value isPinned, - Value label, - Value cardColor, - Value metadata, - Value pasteCount, - Value contentHash, - Value thumbPath, - Value sourceModifiedAt, - Value brokenSince, - Value rowid, - }); - -class $$ClipboardItemsTableFilterComposer - extends Composer<_$_AppDatabase, $ClipboardItemsTable> { - $$ClipboardItemsTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get content => $composableBuilder( - column: $table.content, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get type => $composableBuilder( - column: $table.type, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get modifiedAt => $composableBuilder( - column: $table.modifiedAt, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get appSource => $composableBuilder( - column: $table.appSource, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get isPinned => $composableBuilder( - column: $table.isPinned, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get label => $composableBuilder( - column: $table.label, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get cardColor => $composableBuilder( - column: $table.cardColor, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get metadata => $composableBuilder( - column: $table.metadata, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get pasteCount => $composableBuilder( - column: $table.pasteCount, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get contentHash => $composableBuilder( - column: $table.contentHash, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get thumbPath => $composableBuilder( - column: $table.thumbPath, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get sourceModifiedAt => $composableBuilder( - column: $table.sourceModifiedAt, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get brokenSince => $composableBuilder( - column: $table.brokenSince, - builder: (column) => ColumnFilters(column), - ); -} - -class $$ClipboardItemsTableOrderingComposer - extends Composer<_$_AppDatabase, $ClipboardItemsTable> { - $$ClipboardItemsTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get content => $composableBuilder( - column: $table.content, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get type => $composableBuilder( - column: $table.type, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get modifiedAt => $composableBuilder( - column: $table.modifiedAt, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get appSource => $composableBuilder( - column: $table.appSource, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get isPinned => $composableBuilder( - column: $table.isPinned, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get label => $composableBuilder( - column: $table.label, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get cardColor => $composableBuilder( - column: $table.cardColor, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get metadata => $composableBuilder( - column: $table.metadata, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get pasteCount => $composableBuilder( - column: $table.pasteCount, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get contentHash => $composableBuilder( - column: $table.contentHash, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get thumbPath => $composableBuilder( - column: $table.thumbPath, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get sourceModifiedAt => $composableBuilder( - column: $table.sourceModifiedAt, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get brokenSince => $composableBuilder( - column: $table.brokenSince, - builder: (column) => ColumnOrderings(column), - ); -} - -class $$ClipboardItemsTableAnnotationComposer - extends Composer<_$_AppDatabase, $ClipboardItemsTable> { - $$ClipboardItemsTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get content => - $composableBuilder(column: $table.content, builder: (column) => column); - - GeneratedColumn get type => - $composableBuilder(column: $table.type, builder: (column) => column); - - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); - - GeneratedColumn get modifiedAt => $composableBuilder( - column: $table.modifiedAt, - builder: (column) => column, - ); - - GeneratedColumn get appSource => - $composableBuilder(column: $table.appSource, builder: (column) => column); - - GeneratedColumn get isPinned => - $composableBuilder(column: $table.isPinned, builder: (column) => column); - - GeneratedColumn get label => - $composableBuilder(column: $table.label, builder: (column) => column); - - GeneratedColumn get cardColor => - $composableBuilder(column: $table.cardColor, builder: (column) => column); - - GeneratedColumn get metadata => - $composableBuilder(column: $table.metadata, builder: (column) => column); - - GeneratedColumn get pasteCount => $composableBuilder( - column: $table.pasteCount, - builder: (column) => column, - ); - - GeneratedColumn get contentHash => $composableBuilder( - column: $table.contentHash, - builder: (column) => column, - ); - - GeneratedColumn get thumbPath => - $composableBuilder(column: $table.thumbPath, builder: (column) => column); - - GeneratedColumn get sourceModifiedAt => $composableBuilder( - column: $table.sourceModifiedAt, - builder: (column) => column, - ); - - GeneratedColumn get brokenSince => $composableBuilder( - column: $table.brokenSince, - builder: (column) => column, - ); -} - -class $$ClipboardItemsTableTableManager - extends - RootTableManager< - _$_AppDatabase, - $ClipboardItemsTable, - ClipboardRow, - $$ClipboardItemsTableFilterComposer, - $$ClipboardItemsTableOrderingComposer, - $$ClipboardItemsTableAnnotationComposer, - $$ClipboardItemsTableCreateCompanionBuilder, - $$ClipboardItemsTableUpdateCompanionBuilder, - ( - ClipboardRow, - BaseReferences<_$_AppDatabase, $ClipboardItemsTable, ClipboardRow>, - ), - ClipboardRow, - PrefetchHooks Function() - > { - $$ClipboardItemsTableTableManager( - _$_AppDatabase db, - $ClipboardItemsTable table, - ) : super( - TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$ClipboardItemsTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$ClipboardItemsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$ClipboardItemsTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: - ({ - Value id = const Value.absent(), - Value content = const Value.absent(), - Value type = const Value.absent(), - Value createdAt = const Value.absent(), - Value modifiedAt = const Value.absent(), - Value appSource = const Value.absent(), - Value isPinned = const Value.absent(), - Value label = const Value.absent(), - Value cardColor = const Value.absent(), - Value metadata = const Value.absent(), - Value pasteCount = const Value.absent(), - Value contentHash = const Value.absent(), - Value thumbPath = const Value.absent(), - Value sourceModifiedAt = const Value.absent(), - Value brokenSince = const Value.absent(), - Value rowid = const Value.absent(), - }) => ClipboardItemsCompanion( - id: id, - content: content, - type: type, - createdAt: createdAt, - modifiedAt: modifiedAt, - appSource: appSource, - isPinned: isPinned, - label: label, - cardColor: cardColor, - metadata: metadata, - pasteCount: pasteCount, - contentHash: contentHash, - thumbPath: thumbPath, - sourceModifiedAt: sourceModifiedAt, - brokenSince: brokenSince, - rowid: rowid, - ), - createCompanionCallback: - ({ - required String id, - required String content, - required int type, - required DateTime createdAt, - required DateTime modifiedAt, - Value appSource = const Value.absent(), - Value isPinned = const Value.absent(), - Value label = const Value.absent(), - Value cardColor = const Value.absent(), - Value metadata = const Value.absent(), - Value pasteCount = const Value.absent(), - Value contentHash = const Value.absent(), - Value thumbPath = const Value.absent(), - Value sourceModifiedAt = const Value.absent(), - Value brokenSince = const Value.absent(), - Value rowid = const Value.absent(), - }) => ClipboardItemsCompanion.insert( - id: id, - content: content, - type: type, - createdAt: createdAt, - modifiedAt: modifiedAt, - appSource: appSource, - isPinned: isPinned, - label: label, - cardColor: cardColor, - metadata: metadata, - pasteCount: pasteCount, - contentHash: contentHash, - thumbPath: thumbPath, - sourceModifiedAt: sourceModifiedAt, - brokenSince: brokenSince, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - ), - ); -} - -typedef $$ClipboardItemsTableProcessedTableManager = - ProcessedTableManager< - _$_AppDatabase, - $ClipboardItemsTable, - ClipboardRow, - $$ClipboardItemsTableFilterComposer, - $$ClipboardItemsTableOrderingComposer, - $$ClipboardItemsTableAnnotationComposer, - $$ClipboardItemsTableCreateCompanionBuilder, - $$ClipboardItemsTableUpdateCompanionBuilder, - ( - ClipboardRow, - BaseReferences<_$_AppDatabase, $ClipboardItemsTable, ClipboardRow>, - ), - ClipboardRow, - PrefetchHooks Function() - >; - -class $_AppDatabaseManager { - final _$_AppDatabase _db; - $_AppDatabaseManager(this._db); - $$ClipboardItemsTableTableManager get clipboardItems => - $$ClipboardItemsTableTableManager(_db, _db.clipboardItems); -} diff --git a/core/lib/search/.gitkeep b/core/lib/search/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/core/lib/search/search_helper.dart b/core/lib/search/search_helper.dart deleted file mode 100644 index 983df96d..00000000 --- a/core/lib/search/search_helper.dart +++ /dev/null @@ -1,138 +0,0 @@ -class SearchHelper { - SearchHelper._(); - - static final _combiningDiacritics = RegExp(r'[\u0300-\u036f]'); - - static String normalize(String text) { - // Unicode NFD decomposition splits base chars from combining marks, - // then we strip the combining diacritical marks (U+0300–U+036F). - // Finally, handle special ligatures that NFD doesn't decompose. - var result = text.toLowerCase(); - - // NFD decomposes e.g. 'é' → 'e' + '\u0301' - // Dart strings are UTF-16; we can approximate NFD by using - // the runes-based approach with the combining marks regex. - // Dart doesn't have a built-in normalize(), so we use a manual - // decomposition for the most common cases plus strip combining marks. - result = _expandLigatures(result); - result = _decomposeToNfd(result); - result = result.replaceAll(_combiningDiacritics, ''); - - return result; - } - - static String _expandLigatures(String text) { - return text - .replaceAll('ß', 'ss') - .replaceAll('æ', 'ae') - .replaceAll('œ', 'oe') - .replaceAll('ð', 'd') - .replaceAll('þ', 'th') - .replaceAll('ł', 'l') - .replaceAll('đ', 'd'); - } - - static String _decomposeToNfd(String text) { - final buffer = StringBuffer(); - for (final rune in text.runes) { - final decomposed = _nfdMap[rune]; - if (decomposed != null) { - buffer.write(decomposed); - } else { - buffer.writeCharCode(rune); - } - } - return buffer.toString(); - } - - // NFD decomposition map for common accented characters. - // Maps composed Unicode codepoint → base char + combining mark(s). - // The combining marks are then stripped by _combiningDiacritics regex. - static final Map _nfdMap = { - // Latin lowercase - 0x00E0: 'a\u0300', // à - 0x00E1: 'a\u0301', // á - 0x00E2: 'a\u0302', // â - 0x00E3: 'a\u0303', // ã - 0x00E4: 'a\u0308', // ä - 0x00E5: 'a\u030A', // å - 0x00E7: 'c\u0327', // ç - 0x00E8: 'e\u0300', // è - 0x00E9: 'e\u0301', // é - 0x00EA: 'e\u0302', // ê - 0x00EB: 'e\u0308', // ë - 0x00EC: 'i\u0300', // ì - 0x00ED: 'i\u0301', // í - 0x00EE: 'i\u0302', // î - 0x00EF: 'i\u0308', // ï - 0x00F1: 'n\u0303', // ñ - 0x00F2: 'o\u0300', // ò - 0x00F3: 'o\u0301', // ó - 0x00F4: 'o\u0302', // ô - 0x00F5: 'o\u0303', // õ - 0x00F6: 'o\u0308', // ö - 0x00F8: 'o', // ø (no combining mark, just strip) - 0x00F9: 'u\u0300', // ù - 0x00FA: 'u\u0301', // ú - 0x00FB: 'u\u0302', // û - 0x00FC: 'u\u0308', // ü - 0x00FD: 'y\u0301', // ý - 0x00FF: 'y\u0308', // ÿ - // Extended Latin - 0x0100: 'a', // Ā - 0x0101: 'a', // ā - 0x0102: 'a', // Ă - 0x0103: 'a', // ă - 0x0104: 'a', // Ą - 0x0105: 'a', // ą - 0x0106: 'c', // Ć - 0x0107: 'c', // ć - 0x010C: 'c', // Č - 0x010D: 'c', // č - 0x010E: 'd', // Ď - 0x010F: 'd', // ď - 0x0112: 'e', // Ē - 0x0113: 'e', // ē - 0x0116: 'e', // Ė - 0x0117: 'e', // ė - 0x0118: 'e', // Ę - 0x0119: 'e', // ę - 0x011A: 'e', // Ě - 0x011B: 'e', // ě - 0x011E: 'g', // Ğ - 0x011F: 'g', // ğ - 0x0130: 'i', // İ - 0x0131: 'i', // ı - - 0x0143: 'n', // Ń - 0x0144: 'n', // ń - 0x0147: 'n', // Ň - 0x0148: 'n', // ň - 0x0150: 'o', // Ő - 0x0151: 'o', // ő - 0x0154: 'r', // Ŕ - 0x0155: 'r', // ŕ - 0x0158: 'r', // Ř - 0x0159: 'r', // ř - 0x015A: 's', // Ś - 0x015B: 's', // ś - 0x015E: 's', // Ş - 0x015F: 's', // ş - 0x0160: 's', // Š - 0x0161: 's', // š - 0x0162: 't', // Ţ - 0x0163: 't', // ţ - 0x0164: 't', // Ť - 0x0165: 't', // ť - 0x016E: 'u', // Ů - 0x016F: 'u', // ů - 0x0170: 'u', // Ű - 0x0171: 'u', // ű - 0x017D: 'z', // Ž - 0x017E: 'z', // ž - 0x0179: 'z', // Ź - 0x017A: 'z', // ź - 0x017B: 'z', // Ż - 0x017C: 'z', // ż - }; -} diff --git a/core/lib/services/.gitkeep b/core/lib/services/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/core/lib/services/app_logger.dart b/core/lib/services/app_logger.dart deleted file mode 100644 index b6574a26..00000000 --- a/core/lib/services/app_logger.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; - -class AppLogger { - AppLogger._(); - - static String? _logDirectory; - static String? _logFilePath; - static bool _isInitialized = false; - static bool isEnabled = true; - - static const int _maxLogAgeDays = 7; - static const int _maxLogSizeBytes = 10 * 1024 * 1024; - - static String? get logFilePath => _logFilePath; - static String? get logDirectory => _logDirectory; - - static void initialize(String logsPath) { - if (_isInitialized) return; - try { - _logDirectory = logsPath; - final now = DateTime.now(); - final dateStr = '${now.year}-${_pad(now.month)}-${_pad(now.day)}'; - _logFilePath = p.join(logsPath, 'copypaste_$dateStr.log'); - Directory(logsPath).createSync(recursive: true); - _cleanOldLogs(); - _isInitialized = true; - info('Logger initialized'); - } catch (e) { - isEnabled = false; - // Can't use AppLogger here — write a fallback stderr line so the failure - // is at least visible when running in debug mode. - // ignore: avoid_print - print('[AppLogger] initialization failed, logging disabled: $e'); - } - } - - static void info(String message) => _log('INFO', message); - - static void warn(String message) => _log('WARN', message); - - static void error(String message) => _log('ERROR', message); - - static void exception( - Object? error, [ - StackTrace? stackTrace, - String context = '', - ]) { - if (!isEnabled || !_isInitialized || error == null) return; - final sb = StringBuffer(); - if (context.isNotEmpty) sb.write('$context - '); - sb.write(error.toString()); - if (stackTrace != null) sb.write('\n$stackTrace'); - _log('ERROR', sb.toString()); - } - - static void _log(String level, String message) { - if (!isEnabled || !_isInitialized || _logFilePath == null) return; - try { - final now = DateTime.now(); - final timestamp = - '${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}.${_pad3(now.millisecond)}'; - final entry = '[$timestamp] [$level] $message\n'; - final file = File(_logFilePath!); - if (file.existsSync() && file.lengthSync() > _maxLogSizeBytes) { - _rotateLog(); - } - file.writeAsStringSync(entry, mode: FileMode.append); - } catch (_) {} - } - - static void _rotateLog() { - if (_logFilePath == null || _logDirectory == null) return; - try { - final now = DateTime.now(); - final rotatedName = - 'copypaste_${now.year}-${_pad(now.month)}-${_pad(now.day)}_${_pad(now.hour)}${_pad(now.minute)}${_pad(now.second)}.log'; - File(_logFilePath!).renameSync(p.join(_logDirectory!, rotatedName)); - } catch (_) { - try { - File(_logFilePath!).deleteSync(); - } catch (_) {} - } - } - - static void _cleanOldLogs() { - if (_logDirectory == null) return; - try { - final cutoff = DateTime.now().subtract( - const Duration(days: _maxLogAgeDays), - ); - final dir = Directory(_logDirectory!); - if (!dir.existsSync()) return; - for (final file in dir.listSync().whereType()) { - if (p.basename(file.path).startsWith('copypaste_') && - file.path.endsWith('.log')) { - if (file.lastModifiedSync().isBefore(cutoff)) { - file.deleteSync(); - } - } - } - } catch (_) {} - } - - static String _pad(int n) => n.toString().padLeft(2, '0'); - static String _pad3(int n) => n.toString().padLeft(3, '0'); -} diff --git a/core/lib/services/backup_service.dart b/core/lib/services/backup_service.dart deleted file mode 100644 index 84b8b204..00000000 --- a/core/lib/services/backup_service.dart +++ /dev/null @@ -1,314 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:archive/archive.dart'; -import 'package:path/path.dart' as p; - -import '../config/storage_config.dart'; -import 'app_logger.dart'; - -class BackupManifest { - const BackupManifest({ - required this.version, - required this.appVersion, - required this.createdAtUtc, - required this.itemCount, - required this.imageCount, - required this.hasPinnedItems, - required this.machineName, - }); - - factory BackupManifest.fromJson(Map json) => BackupManifest( - version: json['version'] as int? ?? 1, - appVersion: json['appVersion'] as String? ?? '', - createdAtUtc: - DateTime.tryParse(json['createdAtUtc'] as String? ?? '') ?? - DateTime.now().toUtc(), - itemCount: json['itemCount'] as int? ?? 0, - imageCount: json['imageCount'] as int? ?? 0, - hasPinnedItems: json['hasPinnedItems'] as bool? ?? false, - machineName: json['machineName'] as String? ?? '', - ); - - static const int currentVersion = 1; - - final int version; - final String appVersion; - final DateTime createdAtUtc; - final int itemCount; - final int imageCount; - final bool hasPinnedItems; - final String machineName; - - Map toJson() => { - 'version': version, - 'appVersion': appVersion, - 'createdAtUtc': createdAtUtc.toIso8601String(), - 'itemCount': itemCount, - 'imageCount': imageCount, - 'hasPinnedItems': hasPinnedItems, - 'machineName': machineName, - }; -} - -class BackupService { - BackupService._(); - - static Future createBackup( - String outputPath, - StorageConfig storage, - String appVersion, { - int itemCount = 0, - bool hasPinnedItems = false, - Future Function()? walCheckpoint, - }) async { - if (walCheckpoint != null) { - await walCheckpoint(); - } - - final archive = Archive(); - var imageCount = 0; - - final dbFile = File(storage.databasePath); - if (dbFile.existsSync()) { - archive.addFile( - ArchiveFile( - 'clipboard.db', - dbFile.lengthSync(), - dbFile.readAsBytesSync(), - ), - ); - } - - final imagesDir = Directory(storage.imagesPath); - if (imagesDir.existsSync()) { - for (final file in imagesDir.listSync().whereType()) { - archive.addFile( - ArchiveFile( - 'images/${file.uri.pathSegments.last}', - file.lengthSync(), - file.readAsBytesSync(), - ), - ); - imageCount++; - } - } - - final configDir = Directory(storage.configPath); - if (configDir.existsSync()) { - for (final file in configDir.listSync().whereType()) { - archive.addFile( - ArchiveFile( - 'config/${file.uri.pathSegments.last}', - file.lengthSync(), - file.readAsBytesSync(), - ), - ); - } - } - - final manifest = BackupManifest( - version: BackupManifest.currentVersion, - appVersion: appVersion, - createdAtUtc: DateTime.now().toUtc(), - itemCount: itemCount, - imageCount: imageCount, - hasPinnedItems: hasPinnedItems, - machineName: _hostName(), - ); - - final manifestBytes = utf8.encode( - const JsonEncoder.withIndent(' ').convert(manifest.toJson()), - ); - archive.addFile( - ArchiveFile('manifest.json', manifestBytes.length, manifestBytes), - ); - - final zipData = ZipEncoder().encode(archive); - - final tempDir = Directory.systemTemp.createTempSync('copypaste_backup_'); - final tempFile = File(p.join(tempDir.path, 'backup.zip')); - await tempFile.writeAsBytes(zipData); - try { - await tempFile.copy(outputPath); - } finally { - try { - tempDir.deleteSync(recursive: true); - } catch (_) {} - } - - return manifest; - } - - static Future restoreBackup( - String backupPath, - StorageConfig storage, { - Future Function()? onBeforeRestore, - }) async { - final backupFile = File(backupPath); - if (!backupFile.existsSync()) return null; - - String? snapshotDir; - - try { - final archive = ZipDecoder().decodeBytes(backupFile.readAsBytesSync()); - - final manifestEntry = archive.findFile('manifest.json'); - if (manifestEntry == null) return null; - - final manifestJson = - jsonDecode(utf8.decode(manifestEntry.content as List)) - as Map; - - final manifest = BackupManifest.fromJson(manifestJson); - if (manifest.version > BackupManifest.currentVersion) return null; - - if (onBeforeRestore != null) { - await onBeforeRestore(); - } - - snapshotDir = await _createPreRestoreSnapshot(storage); - - _deleteWalFiles(storage.databasePath); - - await storage.ensureDirectories(); - - for (final file in archive) { - if (file.isFile && file.name != 'manifest.json') { - if (file.name.contains('..')) continue; - final outPath = p.normalize(p.join(storage.baseDir, file.name)); - final baseWithSep = storage.baseDir.endsWith(p.separator) - ? storage.baseDir - : '${storage.baseDir}${p.separator}'; - if (!outPath.startsWith(baseWithSep)) continue; - final outFile = File(outPath); - await outFile.create(recursive: true); - await outFile.writeAsBytes(file.content as List); - } - } - - _cleanupSnapshot(snapshotDir); - return manifest; - } catch (e) { - AppLogger.error('restoreBackup failed: $e'); - if (snapshotDir != null) { - await _rollbackFromSnapshot(snapshotDir, storage); - } - return null; - } - } - - static Future validateBackup(String backupPath) async { - final backupFile = File(backupPath); - if (!backupFile.existsSync()) return null; - - try { - final archive = ZipDecoder().decodeBytes(backupFile.readAsBytesSync()); - - final manifestEntry = archive.findFile('manifest.json'); - if (manifestEntry == null) return null; - - final manifestJson = - jsonDecode(utf8.decode(manifestEntry.content as List)) - as Map; - - final manifest = BackupManifest.fromJson(manifestJson); - if (manifest.version > BackupManifest.currentVersion) return null; - - return manifest; - } catch (e) { - AppLogger.error('validateBackup failed: $e'); - return null; - } - } - - static void _deleteWalFiles(String dbPath) { - try { - final walFile = File('$dbPath-wal'); - final shmFile = File('$dbPath-shm'); - if (walFile.existsSync()) walFile.deleteSync(); - if (shmFile.existsSync()) shmFile.deleteSync(); - } catch (e) { - AppLogger.error('deleteWalFiles failed: $e'); - } - } - - static Future _createPreRestoreSnapshot(StorageConfig storage) async { - final timestamp = DateTime.now().toUtc().millisecondsSinceEpoch; - final snapshotDir = p.join(storage.baseDir, '.pre-restore-$timestamp'); - final dir = Directory(snapshotDir); - await dir.create(recursive: true); - - final dbFile = File(storage.databasePath); - if (dbFile.existsSync()) { - await dbFile.copy(p.join(snapshotDir, 'clipboard.db')); - } - - final imagesDir = Directory(storage.imagesPath); - if (imagesDir.existsSync()) { - final snapImagesDir = Directory(p.join(snapshotDir, 'images')); - await snapImagesDir.create(); - for (final file in imagesDir.listSync().whereType()) { - await file.copy(p.join(snapImagesDir.path, p.basename(file.path))); - } - } - - final configDir = Directory(storage.configPath); - if (configDir.existsSync()) { - final snapConfigDir = Directory(p.join(snapshotDir, 'config')); - await snapConfigDir.create(); - for (final file in configDir.listSync().whereType()) { - await file.copy(p.join(snapConfigDir.path, p.basename(file.path))); - } - } - - return snapshotDir; - } - - static Future _rollbackFromSnapshot( - String snapshotDir, - StorageConfig storage, - ) async { - try { - final snapDb = File(p.join(snapshotDir, 'clipboard.db')); - if (snapDb.existsSync()) { - await snapDb.copy(storage.databasePath); - } - - final snapImages = Directory(p.join(snapshotDir, 'images')); - if (snapImages.existsSync()) { - for (final file in snapImages.listSync().whereType()) { - await file.copy(p.join(storage.imagesPath, p.basename(file.path))); - } - } - - final snapConfig = Directory(p.join(snapshotDir, 'config')); - if (snapConfig.existsSync()) { - for (final file in snapConfig.listSync().whereType()) { - await file.copy(p.join(storage.configPath, p.basename(file.path))); - } - } - } catch (e) { - AppLogger.error('rollbackFromSnapshot failed: $e'); - } finally { - _cleanupSnapshot(snapshotDir); - } - } - - static void _cleanupSnapshot(String snapshotDir) { - try { - Directory(snapshotDir).deleteSync(recursive: true); - } catch (e) { - AppLogger.error('cleanupSnapshot failed: $e'); - } - } - - static String _hostName() { - try { - return Platform.localHostname; - } catch (e) { - AppLogger.error('hostName failed: $e'); - return 'unknown'; - } - } -} diff --git a/core/lib/services/cleanup_service.dart b/core/lib/services/cleanup_service.dart deleted file mode 100644 index a7e25085..00000000 --- a/core/lib/services/cleanup_service.dart +++ /dev/null @@ -1,440 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:path/path.dart' as p; - -import '../config/storage_config.dart'; -import '../models/clipboard_item.dart'; -import '../repository/i_clipboard_repository.dart'; -import 'app_logger.dart'; - -class CleanupService { - CleanupService( - this._repository, - this._getRetentionDays, { - StorageConfig? storage, - int Function()? getKeepBrokenDays, - int Function()? getImagesQuotaMB, - bool Function(String)? probePath, - }) : _storage = storage, - _getKeepBrokenDays = getKeepBrokenDays ?? (() => 30), - _getImagesQuotaMB = getImagesQuotaMB ?? (() => 0), - _probePath = probePath ?? _probePathOnDisk; - - static const Duration _checkInterval = Duration(hours: 18); - static const String _lastCleanupFileName = 'last_cleanup.txt'; - static const String _tempDirPrefix = 'copypaste_'; - static const Duration _tempDirMaxAge = Duration(days: 1); - - final IClipboardRepository _repository; - int Function() _getRetentionDays; - int Function() _getKeepBrokenDays; - int Function() _getImagesQuotaMB; - final StorageConfig? _storage; - final bool Function(String) _probePath; - Timer? _timer; - bool _disposed = false; - - String? _baseDirPath; - - String get _cleanupFilePath => - p.join(_baseDirPath ?? '', _lastCleanupFileName); - - void start(String baseDirPath) { - _baseDirPath = baseDirPath; - _timer = Timer.periodic(_checkInterval, (_) => runCleanupIfNeeded()); - runCleanupIfNeeded(); - } - - Future runCleanupIfNeeded() async { - if (_disposed) return; - - final lastCleanup = _loadLastCleanupDate(); - final now = DateTime.now().toUtc(); - if (lastCleanup.year == now.year && - lastCleanup.month == now.month && - lastCleanup.day == now.day) { - return; - } - - try { - final retentionDays = _getRetentionDays(); - if (retentionDays > 0) { - await _repository.clearOldItems(retentionDays, excludePinned: true); - } - _saveLastCleanupDate(now); - await _cleanOrphanImages(); - await _enforceImagesQuota(); - _cleanStaleTempDirs(); - } catch (e) { - AppLogger.error('Cleanup failed: $e'); - } - } - - /// Removes leftover `copypaste_*` temp dirs created when opening an image in - /// an external viewer. Only dirs older than [_tempDirMaxAge] are touched, so - /// a viewer that still holds a freshly-copied file open is never disturbed. - void _cleanStaleTempDirs() { - try { - final tempRoot = Directory.systemTemp; - if (!tempRoot.existsSync()) return; - final cutoff = DateTime.now().subtract(_tempDirMaxAge); - for (final entity in tempRoot.listSync(followLinks: false)) { - if (entity is! Directory) continue; - if (!p.basename(entity.path).startsWith(_tempDirPrefix)) continue; - try { - if (entity.statSync().modified.isAfter(cutoff)) continue; - entity.deleteSync(recursive: true); - } catch (_) {} - } - } catch (e) { - AppLogger.warn('[CleanupService] temp dir cleanup failed: $e'); - } - } - - DateTime _loadLastCleanupDate() { - try { - final file = File(_cleanupFilePath); - if (file.existsSync()) { - final content = file.readAsStringSync().trim(); - final parsed = DateTime.tryParse(content); - if (parsed != null) return parsed.toUtc(); - } - } catch (_) {} - return DateTime.utc(2000); - } - - void _saveLastCleanupDate(DateTime date) { - try { - final file = File(_cleanupFilePath); - file.parent.createSync(recursive: true); - file.writeAsStringSync(date.toIso8601String()); - } catch (_) {} - } - - void updateRetentionCallback(int Function() getter) { - _getRetentionDays = getter; - } - - void updateKeepBrokenCallback(int Function() getter) { - _getKeepBrokenDays = getter; - } - - void updateImagesQuotaCallback(int Function() getter) { - _getImagesQuotaMB = getter; - } - - void dispose() { - _disposed = true; - _timer?.cancel(); - } - - Future _cleanOrphanImages() async { - final storage = _storage; - if (storage == null) return; - // Kept apart from the sweep below: tracking walks user-supplied paths that - // can live on flaky volumes, and its failure must not strand orphan files - // on disk forever. - try { - await _trackBrokenExternalRefs(); - } catch (e) { - AppLogger.error('Broken reference tracking failed: $e'); - } - - try { - final allImageItems = await _repository.getImagePaths(); - final allThumbPaths = await _repository.getThumbPaths(); - final canonicalImagesDir = p.canonicalize(storage.imagesPath); - final baseWithSep = canonicalImagesDir.endsWith(p.separator) - ? canonicalImagesDir - : '$canonicalImagesDir${p.separator}'; - - // Items whose content is inside images/ (own captures): pass to orphan - // cleanup so files without a matching item get deleted. - // Items with external paths are never deleted — only logged if broken. - final ownedPaths = []; - for (final path in allImageItems) { - if (p.canonicalize(path).startsWith(baseWithSep)) { - ownedPaths.add(path); - } - } - - // Own thumbnails generated by ThumbnailService live inside images/ as - // `_thumb.png`. They must be preserved by the orphan sweep. - for (final tp in allThumbPaths) { - if (p.canonicalize(tp).startsWith(baseWithSep)) { - ownedPaths.add(tp); - } - } - - storage.cleanOrphanImages(ownedPaths); - } catch (e) { - AppLogger.error('Orphan image cleanup failed: $e'); - } - } - - /// Walks all items with external file references; updates `brokenSince` - /// when the source disappears and the volume is present, clears it when - /// the source comes back, and purges items whose `brokenSince` exceeds - /// `keepBrokenItemsDays`. The external file is never touched. - Future _trackBrokenExternalRefs() async { - final storage = _storage; - if (storage == null) return; - final keepDays = _getKeepBrokenDays(); - final now = DateTime.now().toUtc(); - final cutoff = now.subtract(Duration(days: keepDays)); - final canonicalImagesDir = p.canonicalize(storage.imagesPath); - final baseWithSep = canonicalImagesDir.endsWith(p.separator) - ? canonicalImagesDir - : '$canonicalImagesDir${p.separator}'; - - final all = await _repository.getAll(); - for (final item in all) { - final path = _externalPathForCheck(item, baseWithSep); - if (path == null) continue; - - if (!isVolumePresent(path)) { - // Volume offline: assume the file still exists. Keep brokenSince as - // is so a temporary disconnection does not advance the purge clock. - continue; - } - - final exists = _pathExists(path); - // Probe failed rather than reported absence: same meaning as an offline - // volume, so leave brokenSince untouched instead of starting the clock. - if (exists == null) continue; - if (exists) { - if (item.brokenSince != null) { - await _repository.update(item.copyWith(brokenSince: null)); - } - continue; - } - - // File missing with volume present. - if (item.brokenSince == null) { - AppLogger.warn('[CleanupService] broken external reference: "$path"'); - await _repository.update(item.copyWith(brokenSince: now)); - continue; - } - if (keepDays > 0 && item.brokenSince!.isBefore(cutoff)) { - await _purgeBrokenItem(item); - } - } - } - - /// Returns the external path to validate, or null when the item has no - /// external file reference (own captures live inside `images/`). - String? _externalPathForCheck(ClipboardItem item, String baseWithSep) { - if (item.isPinned) return null; - if (item.content.isEmpty) return null; - final paths = item.content.split('\n').where((s) => s.isNotEmpty).toList(); - if (paths.length != 1) return null; - final candidate = paths.first; - if (!item.isFileBasedType) { - // Image type: only treat as external when the path is outside images/. - // Plain text/code/etc. never carry filesystem references. - if (!_looksLikeFilesystemPath(candidate)) return null; - } - try { - if (p.canonicalize(candidate).startsWith(baseWithSep)) return null; - } catch (_) { - return null; - } - return candidate; - } - - bool _looksLikeFilesystemPath(String value) { - if (value.length < 2) return false; - if (value.contains('\n')) return false; - if (Platform.isWindows) { - return RegExp(r'^[a-zA-Z]:[\\/]').hasMatch(value) || - value.startsWith(r'\\'); - } - return value.startsWith('/'); - } - - Future _purgeBrokenItem(ClipboardItem item) async { - AppLogger.warn( - '[CleanupService] purging item with broken external reference ' - 'beyond keepBrokenItemsDays: id=${item.id}', - ); - final storage = _storage; - if (storage != null) { - final thumb = item.thumbPath; - if (thumb != null) { - try { - final f = File(thumb); - if (f.existsSync()) f.deleteSync(); - } catch (e) { - AppLogger.warn('[CleanupService] could not delete thumb: $e'); - } - } - } - await _repository.delete(item.id); - } - - /// Enforces the user-configured `imagesQuotaMB` cap. When the total bytes - /// stored under `images/` exceed the limit, deletes items from oldest to - /// newest (by `createdAt`) until the directory drops back below the cap. - /// Pinned items are never purged. Owned files are removed via the same - /// canonical path validation used elsewhere — external paths referenced by - /// items are never touched. - Future _enforceImagesQuota() async { - final storage = _storage; - if (storage == null) return; - final quotaMB = _getImagesQuotaMB(); - if (quotaMB <= 0) return; - final quotaBytes = quotaMB * 1024 * 1024; - - final canonicalImagesDir = p.canonicalize(storage.imagesPath); - final baseWithSep = canonicalImagesDir.endsWith(p.separator) - ? canonicalImagesDir - : '$canonicalImagesDir${p.separator}'; - - int currentBytes = _measureDirectoryBytes(canonicalImagesDir); - if (currentBytes <= quotaBytes) return; - - AppLogger.warn( - '[CleanupService] images/ ${(currentBytes / 1024 / 1024).toStringAsFixed(1)}MB ' - 'exceeds quota ${quotaMB}MB; starting LRU purge', - ); - - final all = await _repository.getAll(); - final eligible = all.where((it) => !it.isPinned).toList() - ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); - - var purged = 0; - for (final item in eligible) { - if (currentBytes <= quotaBytes) break; - final freed = await _purgeOwnedFiles(item, baseWithSep); - if (freed <= 0) continue; - try { - await _repository.delete(item.id); - } catch (e) { - AppLogger.warn('[CleanupService] quota: delete row failed: $e'); - continue; - } - currentBytes -= freed; - purged++; - } - - if (purged > 0) { - AppLogger.warn( - '[CleanupService] quota purge done: removed $purged items, ' - 'now ${(currentBytes / 1024 / 1024).toStringAsFixed(1)}MB', - ); - } - } - - /// Sums the byte size of regular files directly inside [dir]. Recursion - /// is intentionally omitted — `images/` is flat by design. - int _measureDirectoryBytes(String dir) { - try { - final d = Directory(dir); - if (!d.existsSync()) return 0; - var total = 0; - for (final entity in d.listSync(followLinks: false)) { - if (entity is File) { - try { - total += entity.lengthSync(); - } catch (_) {} - } - } - return total; - } catch (_) { - return 0; - } - } - - /// Deletes the per-item files this app owns (`.png`, `.bmp`, - /// `_thumb.png`, plus any path declared by the item that resolves - /// inside `images/`). Returns the freed bytes. The external file pointed - /// to by an item is never touched. - Future _purgeOwnedFiles(ClipboardItem item, String baseWithSep) async { - final storage = _storage; - if (storage == null) return 0; - final candidates = { - p.join(storage.imagesPath, '${item.id}.png'), - p.join(storage.imagesPath, '${item.id}.bmp'), - p.join(storage.imagesPath, '${item.id}_thumb.png'), - }; - final declared = item.thumbPath; - if (declared != null && declared.isNotEmpty) candidates.add(declared); - if (item.content.isNotEmpty) { - for (final entry in item.content.split('\n')) { - if (entry.isNotEmpty) candidates.add(entry); - } - } - - var freed = 0; - for (final path in candidates) { - try { - final canonical = p.canonicalize(path); - if (!canonical.startsWith(baseWithSep)) continue; - final f = File(canonical); - if (!f.existsSync()) continue; - final bytes = f.lengthSync(); - f.deleteSync(); - freed += bytes; - } catch (e) { - AppLogger.warn('[CleanupService] quota: delete file failed: $e'); - } - } - return freed; - } - - /// Best-effort check that the volume/mount carrying [path] is currently - /// present. When the volume is offline (drive not mounted, NAS down, - /// removable disk unplugged), callers should skip purge logic so the user - /// does not lose history entries on a temporary disconnection. - static bool _probePathOnDisk(String path) => - File(path).existsSync() || Directory(path).existsSync(); - - /// Whether [path] is on disk, or null when the probe itself failed. - /// - /// An unreachable network share throws instead of reporting absence, and - /// [isVolumePresent] cannot tell the two apart either: its own catch assumes - /// the volume is present, which lands here. - bool? _pathExists(String path) { - try { - return _probePath(path); - } catch (e) { - AppLogger.warn('[CleanupService] path probe failed for "$path": $e'); - return null; - } - } - - static bool isVolumePresent(String path) { - try { - if (Platform.isWindows) { - if (path.startsWith(r'\\')) { - // UNC: \\server\share\... — require server+share to be reachable. - final parts = path.substring(2).split(RegExp(r'[\\/]')); - if (parts.length < 2 || parts[0].isEmpty || parts[1].isEmpty) { - return false; - } - final share = '\\\\${parts[0]}\\${parts[1]}'; - return Directory(share).existsSync(); - } - final m = RegExp(r'^([a-zA-Z]):').firstMatch(path); - if (m == null) return true; - return Directory('${m.group(1)}:\\').existsSync(); - } - if (Platform.isMacOS) { - if (path.startsWith('/Volumes/')) { - final rest = path.substring('/Volumes/'.length); - final slash = rest.indexOf('/'); - final mount = slash < 0 ? rest : rest.substring(0, slash); - if (mount.isEmpty) return false; - return Directory('/Volumes/$mount').existsSync(); - } - return true; - } - // Other platforms: best-effort; mount discovery is out of scope. Treat - // as present so purge proceeds when the file is genuinely missing. - return true; - } catch (_) { - return true; - } - } -} diff --git a/core/lib/services/clipboard_service.dart b/core/lib/services/clipboard_service.dart deleted file mode 100644 index 8469b84d..00000000 --- a/core/lib/services/clipboard_service.dart +++ /dev/null @@ -1,566 +0,0 @@ -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; - } - - /// Suppresses the clipboard rewrite performed by direct plain-text paste. - /// This variant does not require the listener to have persisted the current - /// clipboard item yet (for example, when the hotkey follows Copy immediately). - void notifyDirectPasteInitiated(String content) { - _pasteStopwatch = Stopwatch()..start(); - _lastPastedContent = content; - } - - bool _shouldIgnore(String? content) { - final sw = _pasteStopwatch; - if (sw == null) return false; - final elapsed = sw.elapsedMilliseconds; - if (content != null) { - // Text/file events carry their content, so suppress only the echo we - // wrote. A blanket time window can silently lose a genuinely new copy - // made immediately after a paste. - return content == _lastPastedContent && elapsed < pasteIgnoreWindowMs * 2; - } - // Image callbacks have no comparable text payload and still need the - // short time-based guard. - return elapsed < pasteIgnoreWindowMs; - } - - /// A plain copy leaves `rtf`/`html` untouched: styles are a layer over - /// `content` and "paste as plain text" already serves the unstyled view, so - /// dropping them would be an irreversible loss. A copy that does carry styles - /// replaces both keys at once, or the item would mix two sources. - String? _mergeFormatMetadata( - String? current, - List? rtfBytes, - List? htmlBytes, - ) { - final meta = {}; - if (current != null && current.isNotEmpty) { - try { - final decoded = jsonDecode(current); - if (decoded is Map) meta.addAll(decoded); - } catch (_) {} - } - final carriesFormat = - (rtfBytes != null && rtfBytes.isNotEmpty) || - (htmlBytes != null && htmlBytes.isNotEmpty); - if (!carriesFormat) return meta.isEmpty ? null : jsonEncode(meta); - meta.remove('rtf'); - meta.remove('html'); - if (rtfBytes != null) meta['rtf'] = base64Encode(rtfBytes); - if (htmlBytes != null) meta['html'] = base64Encode(htmlBytes); - return meta.isEmpty ? null : jsonEncode(meta); - } - - 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(), - metadata: _mergeFormatMetadata(existing.metadata, rtfBytes, htmlBytes), - ); - 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(), - metadata: _mergeFormatMetadata(legacy.metadata, rtfBytes, htmlBytes), - ); - await _repository.update(updated); - _itemReactivated.add(updated); - return updated; - } - } - - final item = ClipboardItem( - content: content, - type: resolvedType, - appSource: source, - metadata: _mergeFormatMetadata(null, rtfBytes, htmlBytes), - ); - await _repository.save(item); - _itemAdded.add(item); - return item; - } - - /// Reactivating an entry whose file is gone would swallow the incoming - /// capture: the payload is dropped and the user keeps a broken item. Only - /// rejects the match when there are bytes to lose — without them, keeping - /// the existing entry preserves history. Size cannot be compared: the - /// processing queue rewrites `content` to a PNG. - bool _matchesStoredImage(ClipboardItem existing, List? imageBytes) { - if (imageBytes == null || imageBytes.isEmpty) return true; - if (existing.content.isEmpty) return true; - try { - return File(existing.content).existsSync(); - // coverage:ignore-start - } catch (e) { - AppLogger.warn('processImage: could not stat ${existing.content}: $e'); - return true; - // coverage:ignore-end - } - } - - 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 && _matchesStoredImage(existing, imageBytes)) { - 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; - // Reject self-referential captures: an HDROP whose paths all live inside our - // own images store is our own write echoed back, never a real user file. - if (files.every(_isInsideImagesDir)) 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; - } - - /// Bumps [itemId] to the top and emits a reactivation event. Unlike - /// [recordPaste], the copy action keeps the window open, so the list must be - /// told to reorder; paste count is left untouched. - Future recordCopy(String itemId) async { - final item = await _repository.getById(itemId); - if (item == null) return null; - final updated = item.copyWith(modifiedAt: DateTime.now().toUtc()); - await _repository.update(updated); - if (!_disposed) _itemReactivated.add(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. - /// True when [path] resolves to a location inside the app's own images - /// directory. Used both to scope deletions and to reject self-referential - /// clipboard captures (e.g. an HDROP pointing back at our PNG store). - bool _isInsideImagesDir(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('_isInsideImagesDir: canonicalize failed for "$path": $e'); - return false; - } - final baseWithSep = canonicalBase.endsWith(p.separator) - ? canonicalBase - : '$canonicalBase${p.separator}'; - return canonicalTarget.startsWith(baseWithSep); - } - - bool _deleteAppFile(String path) { - final imagesPath = _imagesPath; - if (imagesPath == null || imagesPath.isEmpty) return false; - if (!_isInsideImagesDir(path)) { - AppLogger.error( - '_deleteAppFile: refused to delete out-of-scope path "$path"', - ); - return false; - } - try { - final file = File(p.canonicalize(path)); - 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 deleted file mode 100644 index bbc1d1ba..00000000 --- a/core/lib/services/crash_logger.dart +++ /dev/null @@ -1,130 +0,0 @@ -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/image_processing_queue.dart b/core/lib/services/image_processing_queue.dart deleted file mode 100644 index 65f537f7..00000000 --- a/core/lib/services/image_processing_queue.dart +++ /dev/null @@ -1,235 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:isolate'; -import 'dart:typed_data'; - -import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:path/path.dart' as p; - -import '../models/clipboard_item.dart'; -import '../repository/i_clipboard_repository.dart'; -import 'app_logger.dart'; -import 'image_processor.dart'; - -/// A job submitted to [ImageProcessingQueue]. -class _ImageJob { - _ImageJob({ - required this.item, - required this.imageBytes, - required this.imagesPath, - }); - - final ClipboardItem item; - final Uint8List imageBytes; - final String imagesPath; -} - -/// Serial queue for image processing jobs. -/// -/// Processes one job at a time to avoid saturating CPU and disk. -/// Each job runs [ImageProcessor.processSync] in a dedicated isolate -/// with a configurable [jobTimeout]. -/// -/// If a job exceeds [jobTimeout], the isolate is killed. The BMP fallback -/// written before launching the job is preserved so the item remains -/// pasteable. The event is logged and the queue moves on. -/// -/// Call [dispose] on app shutdown to cancel pending work and release resources. -class ImageProcessingQueue { - ImageProcessingQueue({ - required IClipboardRepository repository, - this.jobTimeout = const Duration(seconds: 10), - this.onItemUpdated, - this.getMaxImageBytes, - }) : _repository = repository; - - final IClipboardRepository _repository; - final Duration jobTimeout; - - /// Called on the main isolate after a job completes and the repository - /// entry has been updated with the final PNG path and dimensions. - final void Function(ClipboardItem item)? onItemUpdated; - - int Function()? getMaxImageBytes; - - final _queue = <_ImageJob>[]; - bool _processing = false; - bool _disposed = false; - - /// Enqueues an image processing job. Returns immediately; the job runs when - /// the queue reaches it. Silently drops jobs after [dispose] is called. - void enqueue({ - required ClipboardItem item, - required List imageBytes, - required String imagesPath, - }) { - if (_disposed) return; - final bytes = imageBytes is Uint8List - ? imageBytes - : Uint8List.fromList(imageBytes); - final maxBytes = getMaxImageBytes?.call() ?? 0; - if (maxBytes > 0 && bytes.length > maxBytes) { - AppLogger.info( - '[ImageQueue] skip ${item.id}: ${bytes.length}B exceeds cap ${maxBytes}B' - ' (BMP fallback kept)', - ); - return; - } - _queue.add( - _ImageJob(item: item, imageBytes: bytes, imagesPath: imagesPath), - ); - if (_queue.length > 10) { - AppLogger.warn('[ImageQueue] queue depth: ${_queue.length}'); - } - _scheduleNext(); - } - - void _scheduleNext() { - if (_processing || _queue.isEmpty || _disposed) return; - _processing = true; - final job = _queue.removeAt(0); - _runJob(job).whenComplete(() { - _processing = false; - _scheduleNext(); - }); - } - - Future _runJob(_ImageJob job) async { - final resultPort = ReceivePort(); - Isolate? isolate; - - try { - final resultCompleter = Completer(); - - resultPort.listen((msg) { - if (!resultCompleter.isCompleted) { - resultCompleter.complete(msg is ImageProcessResult ? msg : null); - } - }); - - isolate = await Isolate.spawn( - _isolateWorker, - _IsolateParams( - imageBytes: job.imageBytes, - id: job.item.id, - imagesDir: job.imagesPath, - resultPort: resultPort.sendPort, - ), - debugName: 'ImageWorker:${job.item.id}', - errorsAreFatal: false, - ); - - ImageProcessResult? result; - try { - result = await resultCompleter.future.timeout(jobTimeout); - } on TimeoutException { - AppLogger.warn( - '[ImageQueue] timeout (${jobTimeout.inSeconds}s) for ${job.item.id}' - ' — keeping BMP fallback', - ); - return; // BMP on disk stays; item remains pasteable. - } - - if (result == null) { - AppLogger.warn( - '[ImageQueue] null result for ${job.item.id}' - ' (unsupported format) — keeping BMP fallback', - ); - return; - } - - // Remove BMP fallback now that the final PNG exists. - final bmpPath = p.join(job.imagesPath, '${job.item.id}.bmp'); - await deleteOwned(bmpPath, job.imagesPath); - - if (_disposed) return; - - final meta = - '{"width":${result.width},"height":${result.height},' - '"size":${result.fileSize}}'; - final updated = job.item.copyWith( - content: result.imagePath, - metadata: meta, - ); - await _repository.update(updated); - if (!_disposed) onItemUpdated?.call(updated); - } catch (e, s) { - AppLogger.error('[ImageQueue] job failed for ${job.item.id}: $e\n$s'); - } finally { - resultPort.close(); - isolate?.kill(priority: Isolate.beforeNextEvent); - } - } - - /// Cancels pending jobs and waits up to 1500 ms for the active job to finish. - /// After this call the queue refuses new jobs. - Future dispose() async { - if (_disposed) return; - _disposed = true; - _queue.clear(); - if (_processing) { - await Future.delayed(const Duration(milliseconds: 1500)); - } - } - - /// Deletes a file only if it is canonically inside [imagesDir]. - /// - /// [delete] replaces the filesystem call so the retry path can be exercised - /// without depending on OS-specific ways of locking a file. - @visibleForTesting - static Future deleteOwned( - String path, - String imagesDir, { - void Function(File)? delete, - }) async { - try { - final base = p.canonicalize(imagesDir); - final target = p.canonicalize(path); - final sep = base.endsWith(p.separator) ? base : '$base${p.separator}'; - if (!target.startsWith(sep)) return; - final f = File(target); - final remove = delete ?? (File file) => file.deleteSync(); - // Windows denies the delete while a scanner or the clipboard watcher - // still holds the handle it just opened. One-shot deletion leaked the - // fallback BMP permanently, so give the handle time to close. - for (var attempt = 0; ; attempt++) { - try { - if (f.existsSync()) remove(f); - return; - } on FileSystemException { - if (attempt == 2) rethrow; - await Future.delayed(const Duration(milliseconds: 150)); - } - } - } catch (e) { - AppLogger.warn('[ImageQueue] deleteOwned failed for "$path": $e'); - } - } -} - -// --------------------------------------------------------------------------- -// Isolate worker — runs in a separate isolate, no access to singletons. -// --------------------------------------------------------------------------- - -class _IsolateParams { - _IsolateParams({ - required this.imageBytes, - required this.id, - required this.imagesDir, - required this.resultPort, - }); - - final Uint8List imageBytes; - final String id; - final String imagesDir; - final SendPort resultPort; -} - -void _isolateWorker(_IsolateParams params) { - final result = ImageProcessor.processSync( - imageBytes: params.imageBytes, - id: params.id, - imagesDir: params.imagesDir, - ); - params.resultPort.send(result); -} diff --git a/core/lib/services/image_processor.dart b/core/lib/services/image_processor.dart deleted file mode 100644 index 5f9b45a6..00000000 --- a/core/lib/services/image_processor.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:io'; -import 'dart:isolate'; -import 'dart:typed_data'; - -import 'package:image/image.dart' as img; -import 'package:path/path.dart' as p; - -class ImageProcessResult { - const ImageProcessResult({ - required this.imagePath, - required this.width, - required this.height, - required this.fileSize, - }); - - final String imagePath; - final int width; - final int height; - final int fileSize; -} - -class ImageProcessor { - static Future processAndSave({ - required Uint8List imageBytes, - required String id, - required String imagesDir, - }) async { - return Isolate.run( - () => processSync(imageBytes: imageBytes, id: id, imagesDir: imagesDir), - ); - } - - static ImageProcessResult? processSync({ - required Uint8List imageBytes, - required String id, - required String imagesDir, - }) { - // Decode failures (unsupported format, truncated data) → return null so the - // caller can distinguish "format not supported" from I/O errors below. - final img.Image? decoded; - try { - decoded = img.decodeImage(imageBytes); - } catch (_) { - return null; - } - if (decoded == null) return null; - - if (decoded.numChannels == 4 && decoded.bitsPerChannel == 8) { - var allTransparent = true; - for (final px in decoded) { - if (px.a != 0) { - allTransparent = false; - break; - } - } - if (allTransparent) { - for (final px in decoded) { - px.a = 255; - } - } - } - - // Encoding and file-write errors (disk full, permissions) are NOT silenced — - // they propagate out of the Isolate and are caught + logged by the caller. - final pngBytes = img.encodePng(decoded); - final imagePath = p.join(imagesDir, '$id.png'); - File(imagePath).writeAsBytesSync(pngBytes); - - return ImageProcessResult( - imagePath: imagePath, - width: decoded.width, - height: decoded.height, - fileSize: pngBytes.length, - ); - } -} diff --git a/core/lib/services/native_thumbnail_provider.dart b/core/lib/services/native_thumbnail_provider.dart deleted file mode 100644 index e850cf9b..00000000 --- a/core/lib/services/native_thumbnail_provider.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:typed_data'; - -/// Contract for OS-backed thumbnail providers. Implementations request a -/// thumbnail bitmap from the native shell (Windows `IShellItemImageFactory`, -/// macOS `QLThumbnailGenerator`) and return the encoded PNG bytes ready to -/// be written to disk. -/// -/// Implementations are expected to: -/// - Return `null` when the OS has no usable thumbnail (no error). -/// - Treat icon-only fallbacks as `null` (e.g. discard if the bitmap is -/// ≤ 64 px on either side when 256 px were requested). -/// - Time out fast (≤ 2 s) so the queue stays responsive. -/// - Never throw for "missing thumb" cases. Throw only for genuine -/// programming errors (invalid arguments, channel not registered). -/// -/// Returned bytes must be a valid PNG. The caller writes them verbatim -/// inside the app's `images/` directory and is responsible for the path -/// safety checks. -abstract class NativeThumbnailProvider { - /// Requests a thumbnail of [path] sized at [sizePx] on the longest side. - /// HiDPI scaling is the implementation's responsibility. - Future request(String path, {int sizePx = 256}); -} - -/// Default no-op implementation. Used on platforms with no native backend -/// wired yet, and in tests that want to exercise only the Dart fallback. -class NoopNativeThumbnailProvider implements NativeThumbnailProvider { - const NoopNativeThumbnailProvider(); - - @override - Future request(String path, {int sizePx = 256}) async => null; -} diff --git a/core/lib/services/support_service.dart b/core/lib/services/support_service.dart deleted file mode 100644 index 3580001b..00000000 --- a/core/lib/services/support_service.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'dart:convert'; -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 = utf8.encode(redacted); - 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 = utf8.encode(redacted); - 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 = utf8.encode(info); - 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) { - // explorer requires the flag and path as one contiguous token; - // splitting them makes it ignore the selection and open Documents. - await Process.run('explorer', ['/select,$filePath']); - } else if (Platform.isMacOS) { - await Process.run('open', ['-R', filePath]); - } - // coverage:ignore-end - } 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]); - } - // coverage:ignore-end - } 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/lib/services/text_classifier.dart b/core/lib/services/text_classifier.dart deleted file mode 100644 index ced83937..00000000 --- a/core/lib/services/text_classifier.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:convert'; - -import '../models/clipboard_content_type.dart'; - -abstract final class TextClassifier { - static final _email = RegExp( - r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$', - ); - - static final _phone = RegExp( - r'^\+\d[\d\s\(\)\-]{4,18}$' - r'|' - r'^\(\+?\d{1,4}\)[\d\s\-]{4,14}$', - ); - - static final _hexColor = RegExp( - r'^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$', - ); - - static final _rgbColor = RegExp( - r'^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(\s*,\s*[\d.]+)?\s*\)$', - caseSensitive: false, - ); - - static final _hslColor = RegExp( - r'^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(\s*,\s*[\d.]+)?\s*\)$', - caseSensitive: false, - ); - - static final _ip = RegExp( - r'^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$', - ); - - static final _uuid = RegExp( - r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', - caseSensitive: false, - ); - - static ClipboardContentType classify(String content) { - final t = content.trim(); - if (t.isEmpty) return ClipboardContentType.text; - - if (!t.contains('\n')) { - if (_email.hasMatch(t)) return ClipboardContentType.email; - if (_isPhone(t)) return ClipboardContentType.phone; - if (_hexColor.hasMatch(t) || - _rgbColor.hasMatch(t) || - _hslColor.hasMatch(t)) { - return ClipboardContentType.color; - } - if (_ip.hasMatch(t)) return ClipboardContentType.ip; - if (_uuid.hasMatch(t)) return ClipboardContentType.uuid; - } - - if (_isJson(t)) return ClipboardContentType.json; - return ClipboardContentType.text; - } - - static bool _isPhone(String value) { - if (!_phone.hasMatch(value)) return false; - final digits = value.replaceAll(RegExp(r'\D'), ''); - return digits.length >= 7 && digits.length <= 15; - } - - static bool _isJson(String value) { - final t = value.trim(); - if (!((t.startsWith('{') && t.endsWith('}')) || - (t.startsWith('[') && t.endsWith(']')))) { - return false; - } - try { - jsonDecode(t); - return true; - } catch (_) { - return false; - } - } -} diff --git a/core/lib/services/thumbnail_queue.dart b/core/lib/services/thumbnail_queue.dart deleted file mode 100644 index 59f5afab..00000000 --- a/core/lib/services/thumbnail_queue.dart +++ /dev/null @@ -1,213 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:path/path.dart' as p; - -import '../models/clipboard_item.dart'; -import '../repository/i_clipboard_repository.dart'; -import 'app_logger.dart'; -import 'thumbnail_service.dart'; - -/// Reasons a [ThumbnailQueue] job can be enqueued. Used only for logging. -enum ThumbnailJobReason { freshItem, staleRegeneration, manualRefresh } - -class _ThumbJob { - _ThumbJob(this.itemId, this.reason); - - final String itemId; - final ThumbnailJobReason reason; -} - -/// Serial queue that generates 256-px PNG thumbnails for image items via -/// [ThumbnailService] and persists the result back to the repository. -/// -/// Responsibilities: -/// - Single in-flight job at a time (no CPU/disk thrash). -/// - Race-safe persistence: the item is re-fetched right before update, -/// and if it has been deleted in the meantime the generated thumb file -/// is removed and the update is skipped. -/// - mtime-based staleness check: [enqueueIfStale] re-encodes when the -/// source file's `mtime` no longer matches the recorded -/// `sourceModifiedAt`. -/// - Best-effort emit of the updated item via [onItemUpdated] so the UI -/// can rebuild the affected card. -/// -/// All file writes happen inside [ThumbnailService.imagesPath]. Cleanup of -/// orphan thumbs is owned by `CleanupService` (see `getThumbPaths()`). -class ThumbnailQueue { - ThumbnailQueue({ - required IClipboardRepository repository, - required ThumbnailService service, - this.onItemUpdated, - }) : _repository = repository, - _service = service; - - final IClipboardRepository _repository; - final ThumbnailService _service; - - /// Called on the main isolate after a job successfully writes a thumb and - /// updates the repository row. Never called for skipped or failed jobs. - final void Function(ClipboardItem item)? onItemUpdated; - - final _queue = <_ThumbJob>[]; - final _enqueuedIds = {}; - bool _processing = false; - bool _disposed = false; - - /// Visible for tests: number of jobs currently waiting (excludes the - /// one being processed, if any). - int get pendingCount => _queue.length; - - /// Visible for tests: `true` when there are no queued jobs **and** no - /// job is currently being processed. Use this to wait for full quiescence - /// instead of [pendingCount], which goes to zero as soon as a job is - /// taken off the queue (before encoding finishes). - bool get isIdle => _queue.isEmpty && !_processing; - - /// Enqueues a thumbnail job for [item]. No-ops if the queue is disposed, - /// the item type is not eligible, or the same id is already queued. - /// - /// Returns synchronously; the actual generation runs asynchronously. - void enqueue(ClipboardItem item, {ThumbnailJobReason? reason}) { - if (_disposed) return; - if (!_isEligible(item)) return; - if (_enqueuedIds.contains(item.id)) return; - _enqueuedIds.add(item.id); - _queue.add(_ThumbJob(item.id, reason ?? ThumbnailJobReason.freshItem)); - if (_queue.length > 20) { - AppLogger.warn('[ThumbQueue] queue depth: ${_queue.length}'); - } - _scheduleNext(); - } - - /// Enqueues a regeneration only if the source file's current `mtime` - /// differs from `item.sourceModifiedAt`. Items without a recorded - /// `sourceModifiedAt` are also enqueued (we have no baseline to compare). - /// - /// Safe to call from the UI thread; the file `stat` runs synchronously - /// but is cheap and only invoked when the card is being resolved. - void enqueueIfStale(ClipboardItem item) { - if (_disposed) return; - if (!_isEligible(item)) return; - - final sourcePath = _singleSourcePath(item); - if (sourcePath == null) return; - - final file = File(sourcePath); - if (!file.existsSync()) return; - - final FileStat stat; - try { - stat = file.statSync(); - } catch (_) { - return; - } - - final recorded = item.sourceModifiedAt; - final currentUtc = stat.modified.toUtc(); - final isStale = - recorded == null || - currentUtc.millisecondsSinceEpoch != recorded.millisecondsSinceEpoch; - - if (!isStale) return; - - enqueue(item, reason: ThumbnailJobReason.staleRegeneration); - } - - bool _isEligible(ClipboardItem item) { - if (!_service.acceptsType(item.type)) return false; - if (item.content.isEmpty) return false; - return _singleSourcePath(item) != null; - } - - String? _singleSourcePath(ClipboardItem item) { - final paths = item.content.split('\n').where((s) => s.isNotEmpty).toList(); - if (paths.length != 1) return null; - return paths.single; - } - - void _scheduleNext() { - if (_processing || _queue.isEmpty || _disposed) return; - _processing = true; - final job = _queue.removeAt(0); - _runJob(job).whenComplete(() { - _enqueuedIds.remove(job.itemId); - _processing = false; - _scheduleNext(); - }); - } - - Future _runJob(_ThumbJob job) async { - if (_disposed) return; - - // Re-fetch right before generation: the item may have been deleted or - // mutated since enqueue. Cheaper to re-read than to encode and discard. - final fresh = await _repository.getById(job.itemId); - if (fresh == null) return; - if (!_isEligible(fresh)) return; - - final result = await _safeGenerate(fresh); - if (result == null) return; - - if (_disposed) { - _safeDelete(result.thumbPath); - return; - } - - // Race window: the user may have deleted the item while we were - // encoding. If gone, drop the file we just produced. - final stillThere = await _repository.getById(job.itemId); - if (stillThere == null) { - _safeDelete(result.thumbPath); - return; - } - - final updated = stillThere.copyWith( - thumbPath: result.thumbPath, - sourceModifiedAt: result.sourceModifiedAt, - ); - try { - await _repository.update(updated); - } catch (e, s) { - AppLogger.error('[ThumbQueue] update failed for ${job.itemId}: $e\n$s'); - _safeDelete(result.thumbPath); - return; - } - - if (!_disposed) onItemUpdated?.call(updated); - } - - Future _safeGenerate(ClipboardItem item) async { - try { - return await _service.generateForItem(item); - } catch (e, s) { - AppLogger.warn('[ThumbQueue] generate failed for ${item.id}: $e\n$s'); - return null; - } - } - - void _safeDelete(String path) { - try { - final base = p.canonicalize(_service.imagesPath); - final target = p.canonicalize(path); - final sep = base.endsWith(p.separator) ? base : '$base${p.separator}'; - if (!target.startsWith(sep)) return; - final file = File(target); - if (file.existsSync()) file.deleteSync(); - } catch (e) { - AppLogger.warn('[ThumbQueue] _safeDelete failed for "$path": $e'); - } - } - - /// Cancels pending jobs and waits up to 1500 ms for the active job to - /// finish. After this call the queue refuses new jobs. - Future dispose() async { - if (_disposed) return; - _disposed = true; - _queue.clear(); - _enqueuedIds.clear(); - if (_processing) { - await Future.delayed(const Duration(milliseconds: 1500)); - } - } -} diff --git a/core/lib/services/thumbnail_service.dart b/core/lib/services/thumbnail_service.dart deleted file mode 100644 index d69aa536..00000000 --- a/core/lib/services/thumbnail_service.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'dart:io'; -import 'dart:isolate'; -import 'dart:typed_data'; - -import 'package:image/image.dart' as img; -import 'package:path/path.dart' as p; - -import '../models/clipboard_content_type.dart'; -import '../models/clipboard_item.dart'; -import 'app_logger.dart'; -import 'native_thumbnail_provider.dart'; - -/// Result of a thumbnail generation attempt. -class ThumbnailResult { - const ThumbnailResult({ - required this.thumbPath, - required this.sourceModifiedAt, - }); - - /// Path inside `imagesPath`, of the form `_thumb.png`. - final String thumbPath; - - /// `mtime` (UTC) of the source file at the time the thumb was generated. - /// Used to detect staleness when the external file is modified. - final DateTime sourceModifiedAt; -} - -/// Generates 256-px PNG thumbnails for clipboard items that reference -/// external media files. -/// -/// Two paths: -/// 1. **Native** (preferred when `nativeProvider` is set): asks the OS -/// shell for a cached thumbnail (Win `IShellItemImageFactory`, -/// macOS `QLThumbnailGenerator`). Covers [ClipboardContentType.image], -/// [video] and [audio] (cover art). -/// 2. **Dart fallback** (always available for images): decodes the file -/// with `package:image` in a one-shot isolate. Only handles -/// [ClipboardContentType.image]. -/// -/// The output file is always written under `imagesPath/_thumb.png`. -/// Snippets we own (paths already inside `imagesPath`) are skipped — they -/// are small enough to render directly. -class ThumbnailService { - ThumbnailService({ - required this.imagesPath, - this.nativeProvider, - this.maxSourceBytes = 25 * 1024 * 1024, - this.maxDimension = 256, - this.isTypeEnabled, - }); - - /// Absolute, canonicalized path to the app's `images/` directory. Every - /// generated thumb is written here and only here. - final String imagesPath; - - /// Optional OS-backed provider tried before the Dart fallback. When set, - /// the service also accepts video and audio items. When null, only image - /// items are processed and the Dart fallback is used. - final NativeThumbnailProvider? nativeProvider; - - /// Skip generation if the source file is bigger than this many bytes. - /// Only applied to the Dart fallback; native providers handle their own - /// limits (most just read the OS cache). - final int maxSourceBytes; - - /// Longest side of the generated thumbnail, in pixels. - final int maxDimension; - - bool Function(ClipboardContentType type)? isTypeEnabled; - - /// Generates a thumbnail for [item] if applicable. Returns the result - /// metadata so the caller can persist `thumbPath` + `sourceModifiedAt` - /// in the repository, or `null` if no thumb was produced. - /// - /// This method is safe to call from the UI thread: heavy work runs in - /// a one-shot isolate via `Isolate.run`. - Future generateForItem(ClipboardItem item) async { - if (!_isAcceptedType(item.type)) return null; - if (item.content.isEmpty) return null; - - final paths = item.content.split('\n').where((s) => s.isNotEmpty).toList(); - if (paths.length != 1) return null; - - final sourcePath = paths.single; - - // Skip snippets we own: they already live inside imagesPath and are - // typically small enough to render directly. They are also the - // output of the image processing queue, so generating a thumb of a - // thumb is wasteful. - final canonicalSource = _safeCanonicalize(sourcePath); - final canonicalImages = _safeCanonicalize(imagesPath); - if (canonicalSource == null || canonicalImages == null) return null; - if (p.isWithin(canonicalImages, canonicalSource)) return null; - - final sourceFile = File(sourcePath); - if (!sourceFile.existsSync()) return null; - - final FileStat stat; - try { - stat = sourceFile.statSync(); - } catch (e) { - AppLogger.warn('ThumbnailService: stat failed for $sourcePath: $e'); - return null; - } - if (stat.size <= 0) return null; - - final outPath = p.join(imagesPath, '${item.id}_thumb.png'); - - // Defense in depth: outPath must canonicalize back inside imagesPath. - final canonicalOut = _safeCanonicalize(outPath); - if (canonicalOut == null || !p.isWithin(canonicalImages, canonicalOut)) { - AppLogger.error( - 'ThumbnailService: refusing thumb path outside imagesPath: $outPath', - ); - return null; - } - - // 1) Try native provider first (cheap cache hit when available). - final native = nativeProvider; - if (native != null) { - final bytes = await _safeNativeRequest(native, sourcePath); - if (bytes != null && bytes.isNotEmpty) { - try { - await File(outPath).writeAsBytes(bytes, flush: true); - return ThumbnailResult( - thumbPath: outPath, - sourceModifiedAt: stat.modified.toUtc(), - ); - } catch (e, s) { - AppLogger.warn( - 'ThumbnailService: failed to write native thumb $outPath: $e\n$s', - ); - // Fall through to Dart fallback (only useful for images). - } - } - } - - // 2) Dart fallback: only images, only within size limit. - if (item.type != ClipboardContentType.image) return null; - if (stat.size > maxSourceBytes) return null; - - final bool ok; - try { - final bytes = await sourceFile.readAsBytes(); - ok = await Isolate.run( - () => _encodeThumbSync( - bytes: bytes, - outPath: outPath, - maxDimension: maxDimension, - ), - ); - } catch (e, s) { - AppLogger.warn( - 'ThumbnailService: Dart fallback failed for $sourcePath: $e\n$s', - ); - return null; - } - if (!ok) return null; - - return ThumbnailResult( - thumbPath: outPath, - sourceModifiedAt: stat.modified.toUtc(), - ); - } - - bool _isAcceptedType(ClipboardContentType type) { - if (isTypeEnabled != null && !isTypeEnabled!(type)) return false; - if (type == ClipboardContentType.image) return true; - if (nativeProvider == null) return false; - return type == ClipboardContentType.video || - type == ClipboardContentType.audio; - } - - /// Whether the service will attempt to generate a thumbnail for items - /// of [type]. Visible so callers (e.g. [ThumbnailQueue]) can short- - /// circuit before enqueuing. - bool acceptsType(ClipboardContentType type) => _isAcceptedType(type); - - Future _safeNativeRequest( - NativeThumbnailProvider provider, - String path, - ) async { - try { - return await provider - .request(path, sizePx: maxDimension) - .timeout(const Duration(seconds: 2)); - } catch (e, s) { - AppLogger.warn('ThumbnailService: native provider failed: $e\n$s'); - return null; - } - } - - /// Decode + downscale + encode PNG, all synchronous. Designed to run - /// inside an isolate. Returns `true` on success, `false` if decoding - /// failed; rethrows on I/O failures so the caller can log them. - static bool _encodeThumbSync({ - required Uint8List bytes, - required String outPath, - required int maxDimension, - }) { - final img.Image? decoded; - try { - decoded = img.decodeImage(bytes); - } catch (_) { - return false; - } - if (decoded == null) return false; - - final scaled = _downscale(decoded, maxDimension); - final pngBytes = img.encodePng(scaled); - File(outPath).writeAsBytesSync(pngBytes); - return true; - } - - static img.Image _downscale(img.Image src, int maxDim) { - final w = src.width; - final h = src.height; - if (w <= maxDim && h <= maxDim) return src; - if (w >= h) { - return img.copyResize(src, width: maxDim); - } - return img.copyResize(src, height: maxDim); - } - - static String? _safeCanonicalize(String path) { - try { - return p.canonicalize(path); - } catch (_) { - return null; - } - } -} diff --git a/core/pubspec.yaml b/core/pubspec.yaml deleted file mode 100644 index 4c753e72..00000000 --- a/core/pubspec.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: core -description: "CopyPaste — Core library: models, repository, services, config." -version: 0.0.1 -publish_to: 'none' -resolution: workspace - -environment: - sdk: ^3.11.1 - flutter: ">=3.3.0" - -dependencies: - flutter: - sdk: flutter - drift: ^2.0.0 - sqlite3: ^3.0.0 - path_provider: ^2.0.0 - path: ^1.9.0 - uuid: ^4.0.0 - archive: ^4.0.9 - image: ^4.5.3 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - drift_dev: ^2.0.0 - build_runner: ^2.0.0 - test: ^1.29.0 diff --git a/core/test/app_config_test.dart b/core/test/app_config_test.dart deleted file mode 100644 index 8c95aa72..00000000 --- a/core/test/app_config_test.dart +++ /dev/null @@ -1,1151 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:core/core.dart'; - -void main() { - group('AppConfig', () { - test('default values are correct', () { - const config = AppConfig(); - expect(config.preferredLanguage, equals('auto')); - expect(config.runOnStartup, isTrue); - expect(config.retentionDays, equals(30)); - expect(config.pageSize, equals(30)); - expect(config.hotkeyUseCtrl, isTrue); - expect(config.hotkeyUseWin, isFalse); - expect(config.hotkeyUseAlt, isTrue); - expect(config.hotkeyUseShift, isFalse); - expect(config.hotkeyKeyName, equals('V')); - }); - - test('serialization round-trip preserves all fields', () { - const config = AppConfig( - preferredLanguage: 'es-CL', - retentionDays: 60, - pageSize: 50, - hotkeyUseCtrl: true, - ); - final json = config.toJson(); - final restored = AppConfig.fromJson(json); - expect(restored.preferredLanguage, equals('es-CL')); - expect(restored.retentionDays, equals(60)); - expect(restored.pageSize, equals(50)); - expect(restored.hotkeyUseCtrl, isTrue); - }); - - test('copyWith produces new instance with changes', () { - const config = AppConfig(); - final updated = config.copyWith(retentionDays: 90, pageSize: 20); - expect(updated.retentionDays, equals(90)); - expect(updated.pageSize, equals(20)); - expect(config.retentionDays, equals(30)); - }); - - test('fromJson uses defaults for missing fields', () { - final config = AppConfig.fromJson({}); - expect(config.preferredLanguage, equals('auto')); - expect(config.runOnStartup, isTrue); - }); - - test('load returns default when file does not exist', () async { - final config = await AppConfig.load('/nonexistent/path/config.json'); - expect(config.preferredLanguage, equals('auto')); - expect(config.plainPasteHotkeyEnabled, isFalse); - }); - - test('save and load round-trip', () async { - final dir = Directory.systemTemp.createTempSync('config_test_'); - final path = '${dir.path}/config.json'; - try { - const original = AppConfig( - preferredLanguage: 'es-CL', - pageSize: 50, - retentionDays: 90, - ); - await original.save(path); - final loaded = await AppConfig.load(path); - expect(loaded.preferredLanguage, equals('es-CL')); - expect(loaded.pageSize, equals(50)); - expect(loaded.retentionDays, equals(90)); - } finally { - dir.deleteSync(recursive: true); - } - }); - - test('lastBackupDateUtc serializes correctly', () { - final date = DateTime.utc(2026, 3, 1, 12); - final config = AppConfig(lastBackupDateUtc: date); - final json = config.toJson(); - final restored = AppConfig.fromJson(json); - expect(restored.lastBackupDateUtc, equals(date)); - }); - }); - - group('AppConfig.copyWith sentinel fields', () { - test('copyWith can clear lastBackupDateUtc to null', () { - final date = DateTime.utc(2026, 1, 1); - final config = AppConfig(lastBackupDateUtc: date); - final updated = config.copyWith(lastBackupDateUtc: null); - expect(updated.lastBackupDateUtc, isNull); - }); - - test('copyWith without lastBackupDateUtc preserves existing value', () { - final date = DateTime.utc(2026, 1, 1); - final config = AppConfig(lastBackupDateUtc: date); - final updated = config.copyWith(pageSize: 20); - expect(updated.lastBackupDateUtc, equals(date)); - }); - }); - - group('AppConfig hotkey fields', () { - test('hotkey defaults are correct', () { - const config = AppConfig(); - expect(config.hotkeyUseCtrl, isTrue); - expect(config.hotkeyUseWin, isFalse); - expect(config.hotkeyUseAlt, isTrue); - expect(config.hotkeyUseShift, isFalse); - expect(config.hotkeyVirtualKey, equals(0x56)); - expect(config.hotkeyKeyName, equals('V')); - expect(config.plainPasteHotkeyEnabled, isFalse); - expect(config.plainPasteHotkeyUseCtrl, isTrue); - expect(config.plainPasteHotkeyUseAlt, isTrue); - expect(config.plainPasteHotkeyUseWin, isFalse); - expect(config.plainPasteHotkeyUseShift, isTrue); - expect(config.plainPasteHotkeyVirtualKey, equals(0x56)); - expect(config.plainPasteHotkeyKeyName, equals('V')); - }); - - test('platform defaults follow native shortcut conventions', () { - final windows = AppConfig.defaultForPlatform('windows'); - expect(windows.hotkeyKeyName, 'C'); - expect(windows.hotkeyUseCtrl, isTrue); - expect(windows.hotkeyUseAlt, isTrue); - expect(windows.plainPasteHotkeyEnabled, isFalse); - expect(windows.plainPasteHotkeyUseCtrl, isTrue); - expect(windows.plainPasteHotkeyUseAlt, isTrue); - expect(windows.plainPasteHotkeyUseShift, isFalse); - expect(windows.duplicateIgnoreWindowMs, 350); - expect(windows.delayBeforeFocusMs, 80); - expect(windows.delayBeforePasteMs, 120); - expect(windows.maxFocusVerifyAttempts, 12); - - final macos = AppConfig.defaultForPlatform('macos'); - expect(macos.hotkeyUseCtrl, isTrue); - expect(macos.hotkeyUseAlt, isFalse); - expect(macos.hotkeyUseShift, isTrue); - expect(macos.plainPasteHotkeyUseWin, isTrue); - expect(macos.plainPasteHotkeyUseAlt, isTrue); - expect(macos.plainPasteHotkeyUseShift, isTrue); - expect(macos.plainPasteHotkeyUseCtrl, isTrue); - }); - - test('legacy JSON does not silently enable the new global hotkey', () { - final restored = AppConfig.fromJson({'hotkeyKeyName': 'P'}); - expect(restored.plainPasteHotkeyEnabled, isFalse); - }); - - test('legacy Windows opening shortcut remains Ctrl+Alt+C', () { - if (!Platform.isWindows) return; - final restored = AppConfig.fromJson({ - 'hotkeyUseCtrl': true, - 'hotkeyUseWin': false, - 'hotkeyUseAlt': true, - 'hotkeyUseShift': false, - 'hotkeyVirtualKey': 0x43, - 'hotkeyKeyName': 'C', - 'plainPasteHotkeyEnabled': true, - 'plainPasteHotkeyUseCtrl': true, - 'plainPasteHotkeyUseWin': false, - 'plainPasteHotkeyUseAlt': false, - 'plainPasteHotkeyUseShift': true, - 'plainPasteHotkeyVirtualKey': 0x56, - 'plainPasteHotkeyKeyName': 'V', - }); - - expect(restored.hotkeyKeyName, 'C'); - expect(restored.hotkeyUseCtrl, isTrue); - expect(restored.hotkeyUseAlt, isTrue); - expect(restored.plainPasteHotkeyEnabled, isFalse); - expect(restored.plainPasteHotkeyUseCtrl, isTrue); - expect(restored.plainPasteHotkeyUseAlt, isTrue); - expect(restored.plainPasteHotkeyUseShift, isFalse); - }); - - test('version 2 Windows opening default migrates back to Ctrl+Alt+C', () { - if (!Platform.isWindows) return; - final restored = AppConfig.fromJson({ - 'shortcutDefaultsVersion': 2, - 'hotkeyUseCtrl': true, - 'hotkeyUseWin': false, - 'hotkeyUseAlt': true, - 'hotkeyUseShift': false, - 'hotkeyVirtualKey': 0x56, - 'hotkeyKeyName': 'V', - }); - - expect(restored.hotkeyVirtualKey, 0x43); - expect(restored.hotkeyKeyName, 'C'); - }); - - test('versioned custom Ctrl+Shift+V global shortcut is preserved', () { - final restored = AppConfig.fromJson({ - 'shortcutDefaultsVersion': 2, - 'plainPasteHotkeyEnabled': true, - 'plainPasteHotkeyUseCtrl': true, - 'plainPasteHotkeyUseWin': false, - 'plainPasteHotkeyUseAlt': false, - 'plainPasteHotkeyUseShift': true, - 'plainPasteHotkeyVirtualKey': 0x56, - 'plainPasteHotkeyKeyName': 'V', - }); - - expect(restored.plainPasteHotkeyEnabled, isTrue); - expect(restored.plainPasteHotkeyUseCtrl, isTrue); - expect(restored.plainPasteHotkeyUseAlt, isFalse); - expect(restored.plainPasteHotkeyUseShift, isTrue); - }); - - test('version 3 Windows plain-paste default migrates to Ctrl+Alt+V', () { - if (!Platform.isWindows) return; - final restored = AppConfig.fromJson({ - 'shortcutDefaultsVersion': 3, - 'plainPasteHotkeyEnabled': true, - 'plainPasteHotkeyUseCtrl': true, - 'plainPasteHotkeyUseWin': false, - 'plainPasteHotkeyUseAlt': true, - 'plainPasteHotkeyUseShift': true, - 'plainPasteHotkeyVirtualKey': 0x56, - 'plainPasteHotkeyKeyName': 'V', - }); - - expect(restored.plainPasteHotkeyEnabled, isTrue); - expect(restored.plainPasteHotkeyUseCtrl, isTrue); - expect(restored.plainPasteHotkeyUseAlt, isTrue); - expect(restored.plainPasteHotkeyUseShift, isFalse); - }); - - test('version 4 Windows plain-paste default migrates to Ctrl+Alt+V', () { - if (!Platform.isWindows) return; - final restored = AppConfig.fromJson({ - 'shortcutDefaultsVersion': 4, - 'plainPasteHotkeyEnabled': true, - 'plainPasteHotkeyUseCtrl': true, - 'plainPasteHotkeyUseWin': false, - 'plainPasteHotkeyUseAlt': false, - 'plainPasteHotkeyUseShift': true, - 'plainPasteHotkeyVirtualKey': 0x56, - 'plainPasteHotkeyKeyName': 'V', - }); - - expect(restored.plainPasteHotkeyEnabled, isTrue); - expect(restored.plainPasteHotkeyUseCtrl, isTrue); - expect(restored.plainPasteHotkeyUseAlt, isTrue); - expect(restored.plainPasteHotkeyUseShift, isFalse); - }); - - test('load persists shortcut migrations once', () async { - if (!Platform.isWindows) return; - final dir = Directory.systemTemp.createTempSync('shortcut_migration_'); - final path = '${dir.path}/config.json'; - try { - File(path).writeAsStringSync( - jsonEncode({ - 'shortcutDefaultsVersion': 3, - 'plainPasteHotkeyEnabled': true, - 'plainPasteHotkeyUseCtrl': true, - 'plainPasteHotkeyUseWin': false, - 'plainPasteHotkeyUseAlt': true, - 'plainPasteHotkeyUseShift': true, - 'plainPasteHotkeyVirtualKey': 0x56, - 'plainPasteHotkeyKeyName': 'V', - }), - ); - - final restored = await AppConfig.load(path); - final persisted = - jsonDecode(File(path).readAsStringSync()) as Map; - - expect(restored.plainPasteHotkeyUseAlt, isTrue); - expect(restored.plainPasteHotkeyUseShift, isFalse); - expect( - persisted['shortcutDefaultsVersion'], - AppConfig.shortcutDefaultsVersion, - ); - expect(persisted['plainPasteHotkeyUseAlt'], isTrue); - expect(persisted['plainPasteHotkeyUseShift'], isFalse); - } finally { - dir.deleteSync(recursive: true); - } - }); - - test('untouched Instant timing migrates to Normal', () { - final restored = AppConfig.fromJson({ - 'pasteDefaultsVersion': 2, - 'duplicateIgnoreWindowMs': 300, - 'delayBeforeFocusMs': 0, - 'delayBeforePasteMs': 20, - 'maxFocusVerifyAttempts': 15, - }, platform: 'windows'); - - expect(restored.duplicateIgnoreWindowMs, 350); - expect(restored.delayBeforeFocusMs, 80); - expect(restored.delayBeforePasteMs, 120); - expect(restored.maxFocusVerifyAttempts, 12); - }); - - test('legacy Safe timing is no longer forced onto Instant', () { - final restored = AppConfig.fromJson({ - 'pasteDefaultsVersion': 1, - 'duplicateIgnoreWindowMs': 450, - 'delayBeforeFocusMs': 100, - 'delayBeforePasteMs': 180, - 'maxFocusVerifyAttempts': 15, - }, platform: 'windows'); - - expect(restored.duplicateIgnoreWindowMs, 450); - expect(restored.delayBeforeFocusMs, 100); - expect(restored.delayBeforePasteMs, 180); - expect(restored.maxFocusVerifyAttempts, 15); - }); - - test('legacy custom Windows timing is preserved', () { - final restored = AppConfig.fromJson({ - 'pasteDefaultsVersion': 1, - 'duplicateIgnoreWindowMs': 451, - 'delayBeforeFocusMs': 100, - 'delayBeforePasteMs': 180, - 'maxFocusVerifyAttempts': 15, - }, platform: 'windows'); - - expect(restored.duplicateIgnoreWindowMs, 451); - expect(restored.delayBeforeFocusMs, 100); - expect(restored.delayBeforePasteMs, 180); - expect(restored.maxFocusVerifyAttempts, 15); - }); - - test('load persists the Windows paste timing migration', () async { - if (!Platform.isWindows) return; - final dir = Directory.systemTemp.createTempSync('paste_migration_'); - final path = '${dir.path}/config.json'; - try { - File(path).writeAsStringSync( - jsonEncode({ - 'shortcutDefaultsVersion': AppConfig.shortcutDefaultsVersion, - 'pasteDefaultsVersion': 2, - 'duplicateIgnoreWindowMs': 300, - 'delayBeforeFocusMs': 0, - 'delayBeforePasteMs': 20, - 'maxFocusVerifyAttempts': 15, - }), - ); - - final restored = await AppConfig.load(path); - final persisted = - jsonDecode(File(path).readAsStringSync()) as Map; - - expect(restored.delayBeforeFocusMs, 80); - expect(restored.delayBeforePasteMs, 120); - expect( - persisted['pasteDefaultsVersion'], - AppConfig.pasteDefaultsVersion, - ); - expect(persisted['delayBeforeFocusMs'], 80); - expect(persisted['delayBeforePasteMs'], 120); - } finally { - dir.deleteSync(recursive: true); - } - }); - - test( - 'load keeps a migrated config when persistence is unavailable', - () async { - final dir = Directory.systemTemp.createTempSync('config_read_only_'); - final path = '${dir.path}/config.json'; - try { - File(path).writeAsStringSync( - jsonEncode({ - 'shortcutDefaultsVersion': 1, - 'pasteDefaultsVersion': 1, - }), - ); - final lockResult = Platform.isWindows - ? await Process.run('attrib', ['+R', path]) - : await Process.run('chmod', ['a-w', dir.path]); - expect(lockResult.exitCode, 0); - - final restored = await AppConfig.load(path); - - expect(restored.preferredLanguage, 'auto'); - } finally { - if (Platform.isWindows) { - await Process.run('attrib', ['-R', path]); - } else { - await Process.run('chmod', ['u+w', dir.path]); - } - dir.deleteSync(recursive: true); - } - }, - ); - - test('copyWith all hotkey fields', () { - const config = AppConfig(); - final updated = config.copyWith( - hotkeyUseCtrl: true, - hotkeyUseWin: false, - hotkeyUseAlt: false, - hotkeyUseShift: true, - hotkeyVirtualKey: 0x43, - hotkeyKeyName: 'C', - ); - expect(updated.hotkeyUseCtrl, isTrue); - expect(updated.hotkeyUseWin, isFalse); - expect(updated.hotkeyUseAlt, isFalse); - expect(updated.hotkeyUseShift, isTrue); - expect(updated.hotkeyVirtualKey, equals(0x43)); - expect(updated.hotkeyKeyName, equals('C')); - }); - - test('hotkey fields round-trip via JSON', () { - const config = AppConfig( - hotkeyUseCtrl: true, - hotkeyUseWin: false, - hotkeyUseAlt: false, - hotkeyUseShift: true, - hotkeyVirtualKey: 0x43, - hotkeyKeyName: 'C', - ); - final restored = AppConfig.fromJson(config.toJson()); - expect(restored.hotkeyUseCtrl, isTrue); - expect(restored.hotkeyUseWin, isFalse); - expect(restored.hotkeyKeyName, equals('C')); - }); - - test('plain paste hotkey fields copy and round-trip via JSON', () { - final updated = const AppConfig().copyWith( - plainPasteHotkeyEnabled: true, - plainPasteHotkeyUseCtrl: false, - plainPasteHotkeyUseWin: true, - plainPasteHotkeyUseAlt: false, - plainPasteHotkeyUseShift: true, - plainPasteHotkeyVirtualKey: 0x50, - plainPasteHotkeyKeyName: 'P', - ); - final restored = AppConfig.fromJson(updated.toJson()); - expect(restored.plainPasteHotkeyEnabled, isTrue); - expect(restored.plainPasteHotkeyUseCtrl, isFalse); - expect(restored.plainPasteHotkeyUseWin, isTrue); - expect(restored.plainPasteHotkeyUseAlt, isFalse); - expect(restored.plainPasteHotkeyUseShift, isTrue); - expect(restored.plainPasteHotkeyVirtualKey, equals(0x50)); - expect(restored.plainPasteHotkeyKeyName, equals('P')); - }); - }); - - group('AppConfig appearance and behavior fields', () { - test('themeMode defaults to dark', () { - const config = AppConfig(); - expect(config.themeMode, equals('dark')); - }); - - test('themeMode round-trips via JSON', () { - const config = AppConfig(themeMode: 'dark'); - expect(AppConfig.fromJson(config.toJson()).themeMode, equals('dark')); - }); - - test('colorLabels round-trip', () { - const config = AppConfig(colorLabels: {'1': 'Work', '2': 'Home'}); - final restored = AppConfig.fromJson(config.toJson()); - expect(restored.colorLabels['1'], equals('Work')); - expect(restored.colorLabels['2'], equals('Home')); - }); - - test('colorLabels defaults to empty map', () { - const config = AppConfig(); - expect(config.colorLabels, isEmpty); - }); - - test('timing fields have correct defaults', () { - const config = AppConfig(); - expect(config.duplicateIgnoreWindowMs, equals(450)); - expect(config.delayBeforeFocusMs, equals(100)); - expect(config.delayBeforePasteMs, equals(180)); - expect(config.maxFocusVerifyAttempts, equals(15)); - }); - - test('popup size defaults', () { - const config = AppConfig(); - expect(config.popupWidth, equals(380)); - expect(config.popupHeight, equals(500)); - }); - - test('card line defaults', () { - const config = AppConfig(); - expect(config.cardMinLines, equals(2)); - expect(config.cardMaxLines, equals(5)); - }); - - test('hasSeenOnboarding defaults to false', () { - const config = AppConfig(); - expect(config.hasSeenOnboarding, isFalse); - }); - - 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( - config.copyWith(hasSeenOnboarding: true).hasSeenOnboarding, - isTrue, - ); - }); - - test('toJson omits lastBackupDateUtc when null', () { - const config = AppConfig(); - expect(config.toJson().containsKey('lastBackupDateUtc'), isFalse); - }); - - test('toJson includes lastBackupDateUtc when set', () { - final config = AppConfig(lastBackupDateUtc: DateTime.utc(2026, 3, 5)); - expect(config.toJson()['lastBackupDateUtc'], isA()); - }); - - test('full serialization round-trip with all fields', () { - const config = AppConfig( - preferredLanguage: 'es', - runOnStartup: false, - hotkeyUseCtrl: true, - pageSize: 50, - retentionDays: 60, - colorLabels: {'1': 'Work', '2': 'Home'}, - duplicateIgnoreWindowMs: 600, - delayBeforeFocusMs: 120, - delayBeforePasteMs: 200, - maxFocusVerifyAttempts: 20, - popupWidth: 400, - popupHeight: 520, - cardMinLines: 3, - cardMaxLines: 8, - hideOnDeactivate: false, - resetScrollOnShow: false, - resetSearchOnShow: false, - hasSeenHint: true, - themeMode: 'light', - ); - final restored = AppConfig.fromJson(config.toJson()); - expect(restored.preferredLanguage, equals('es')); - expect(restored.runOnStartup, isFalse); - expect(restored.hotkeyUseCtrl, isTrue); - expect(restored.pageSize, equals(50)); - expect(restored.retentionDays, equals(60)); - expect(restored.colorLabels['1'], equals('Work')); - expect(restored.duplicateIgnoreWindowMs, equals(600)); - expect(restored.popupWidth, equals(400)); - expect(restored.cardMinLines, equals(3)); - expect(restored.hideOnDeactivate, isFalse); - expect(restored.hasSeenHint, isTrue); - expect(restored.themeMode, equals('light')); - }); - }); - - group('AppConfig edge cases', () { - test('load returns default on corrupt file', () async { - final dir = Directory.systemTemp.createTempSync('config_test_'); - final path = '${dir.path}/config.json'; - File(path).writeAsStringSync('{not valid json'); - final config = await AppConfig.load(path); - expect(config, isA()); - dir.deleteSync(recursive: true); - }); - - test('fromJson throws on wrong types', () { - expect( - () => AppConfig.fromJson({ - 'preferredLanguage': 123, - 'runOnStartup': 'yes', - 'hotkeyUseCtrl': 'true', - 'colorLabels': 'not a map', - }), - throwsA(isA()), - ); - }); - - test('copyWith with all fields as null returns same values', () { - const config = AppConfig(); - final updated = config.copyWith(); - expect(updated.preferredLanguage, config.preferredLanguage); - expect(updated.runOnStartup, config.runOnStartup); - expect(updated.hotkeyUseCtrl, config.hotkeyUseCtrl); - expect(updated.themeMode, config.themeMode); - }); - - test('fromJson with empty string lastBackupDateUtc returns null', () { - final config = AppConfig.fromJson({'lastBackupDateUtc': ''}); - expect(config.lastBackupDateUtc, isNull); - }); - - test( - 'fromJson with invalid date string for lastBackupDateUtc returns null', - () { - final config = AppConfig.fromJson({'lastBackupDateUtc': 'not-a-date'}); - expect(config.lastBackupDateUtc, isNull); - }, - ); - - test('toJson includes all fields when set', () { - final config = AppConfig( - preferredLanguage: 'fr', - runOnStartup: false, - hotkeyUseCtrl: false, - hotkeyUseWin: true, - hotkeyUseAlt: true, - hotkeyUseShift: false, - hotkeyVirtualKey: 0x41, - hotkeyKeyName: 'A', - pageSize: 99, - maxItemsBeforeCleanup: 999, - scrollLoadThreshold: 888, - retentionDays: 77, - colorLabels: {'x': 'y'}, - duplicateIgnoreWindowMs: 1, - delayBeforeFocusMs: 2, - delayBeforePasteMs: 3, - maxFocusVerifyAttempts: 4, - lastBackupDateUtc: DateTime.utc(2026, 1, 1), - popupWidth: 111, - popupHeight: 222, - cardMinLines: 3, - cardMaxLines: 4, - hideOnDeactivate: false, - resetScrollOnShow: false, - resetSearchOnShow: false, - hasSeenHint: true, - themeMode: 'test', - accessibilityWasGranted: true, - lastRunVersion: 'v', - hasSeenOnboarding: true, - ); - final json = config.toJson(); - expect(json['preferredLanguage'], 'fr'); - expect(json['runOnStartup'], false); - expect(json['hotkeyUseCtrl'], false); - expect(json['hotkeyUseWin'], true); - expect(json['hotkeyUseAlt'], true); - expect(json['hotkeyUseShift'], false); - expect(json['hotkeyVirtualKey'], 0x41); - expect(json['hotkeyKeyName'], 'A'); - expect(json['pageSize'], 99); - expect(json['maxItemsBeforeCleanup'], 999); - expect(json['scrollLoadThreshold'], 888); - expect(json['retentionDays'], 77); - expect(json['colorLabels'], {'x': 'y'}); - expect(json['duplicateIgnoreWindowMs'], 1); - expect(json['delayBeforeFocusMs'], 2); - expect(json['delayBeforePasteMs'], 3); - expect(json['maxFocusVerifyAttempts'], 4); - expect(json['lastBackupDateUtc'], isA()); - expect(json['popupWidth'], 111); - expect(json['popupHeight'], 222); - expect(json['cardMinLines'], 3); - expect(json['cardMaxLines'], 4); - expect(json['hideOnDeactivate'], false); - expect(json['resetScrollOnShow'], false); - expect(json['resetSearchOnShow'], false); - expect(json['hasSeenHint'], true); - expect(json['themeMode'], 'test'); - expect(json['accessibilityWasGranted'], true); - expect(json['lastRunVersion'], 'v'); - expect(json['hasSeenOnboarding'], true); - }); - }); - - group('AppConfig lastRunVersion field', () { - test('lastRunVersion defaults to empty string', () { - const config = AppConfig(); - expect(config.lastRunVersion, equals('')); - }); - - test('lastRunVersion round-trips via JSON', () { - const config = AppConfig(lastRunVersion: 'v2.2.2'); - expect( - AppConfig.fromJson(config.toJson()).lastRunVersion, - equals('v2.2.2'), - ); - }); - - test('lastRunVersion absent in JSON defaults to empty string', () { - expect(AppConfig.fromJson({}).lastRunVersion, equals('')); - }); - - test('copyWith lastRunVersion updates value', () { - const config = AppConfig(); - expect( - config.copyWith(lastRunVersion: 'v2.0.0').lastRunVersion, - equals('v2.0.0'), - ); - }); - - test('lastRunVersion preserved when copyWith changes other field', () { - const config = AppConfig(lastRunVersion: 'v2.1.6'); - final updated = config.copyWith(pageSize: 50); - expect(updated.lastRunVersion, equals('v2.1.6')); - }); - }); - - group('AppConfig accessibilityWasGranted field', () { - test('accessibilityWasGranted defaults to false', () { - const config = AppConfig(); - expect(config.accessibilityWasGranted, isFalse); - }); - - test('accessibilityWasGranted round-trips via JSON', () { - const config = AppConfig(accessibilityWasGranted: true); - expect( - AppConfig.fromJson(config.toJson()).accessibilityWasGranted, - isTrue, - ); - }); - - test('accessibilityWasGranted absent in JSON defaults to false', () { - expect(AppConfig.fromJson({}).accessibilityWasGranted, isFalse); - }); - - test('copyWith accessibilityWasGranted updates value', () { - const config = AppConfig(); - expect( - config.copyWith(accessibilityWasGranted: true).accessibilityWasGranted, - isTrue, - ); - }); - - test( - 'accessibilityWasGranted preserved when copyWith changes other field', - () { - const config = AppConfig(accessibilityWasGranted: true); - expect(config.copyWith(pageSize: 20).accessibilityWasGranted, isTrue); - }, - ); - }); - - group('AppConfig behavior defaults', () { - test('hideOnDeactivate defaults to true', () { - const config = AppConfig(); - expect(config.hideOnDeactivate, isTrue); - }); - - test('resetScrollOnShow defaults to true', () { - const config = AppConfig(); - expect(config.resetScrollOnShow, isTrue); - }); - - test('resetSearchOnShow defaults to true', () { - const config = AppConfig(); - expect(config.resetSearchOnShow, isTrue); - }); - - test('hasSeenHint defaults to false', () { - const config = AppConfig(); - expect(config.hasSeenHint, isFalse); - }); - - test('maxItemsBeforeCleanup defaults to 100', () { - const config = AppConfig(); - expect(config.maxItemsBeforeCleanup, equals(100)); - }); - - test('scrollLoadThreshold defaults to 400', () { - const config = AppConfig(); - expect(config.scrollLoadThreshold, equals(400)); - }); - - test('hideOnDeactivate round-trips via JSON', () { - const config = AppConfig(hideOnDeactivate: false); - expect(AppConfig.fromJson(config.toJson()).hideOnDeactivate, isFalse); - }); - - test('resetScrollOnShow round-trips via JSON', () { - const config = AppConfig(resetScrollOnShow: false); - expect(AppConfig.fromJson(config.toJson()).resetScrollOnShow, isFalse); - }); - - test('resetSearchOnShow round-trips via JSON', () { - const config = AppConfig(resetSearchOnShow: false); - expect(AppConfig.fromJson(config.toJson()).resetSearchOnShow, isFalse); - }); - - test('hasSeenHint round-trips via JSON', () { - const config = AppConfig(hasSeenHint: true); - expect(AppConfig.fromJson(config.toJson()).hasSeenHint, isTrue); - }); - - test('maxItemsBeforeCleanup round-trips via JSON', () { - const config = AppConfig(maxItemsBeforeCleanup: 200); - expect( - AppConfig.fromJson(config.toJson()).maxItemsBeforeCleanup, - equals(200), - ); - }); - - test('scrollLoadThreshold round-trips via JSON', () { - const config = AppConfig(scrollLoadThreshold: 800); - expect( - AppConfig.fromJson(config.toJson()).scrollLoadThreshold, - equals(800), - ); - }); - - test('copyWith behavior fields updates correctly', () { - const config = AppConfig(); - final updated = config.copyWith( - hideOnDeactivate: false, - resetScrollOnShow: false, - resetSearchOnShow: false, - hasSeenHint: true, - maxItemsBeforeCleanup: 50, - scrollLoadThreshold: 200, - ); - expect(updated.hideOnDeactivate, isFalse); - expect(updated.resetScrollOnShow, isFalse); - expect(updated.resetSearchOnShow, isFalse); - expect(updated.hasSeenHint, isTrue); - expect(updated.maxItemsBeforeCleanup, equals(50)); - expect(updated.scrollLoadThreshold, equals(200)); - }); - }); - - group('AppConfig version and platform', () { - test('appVersion is a non-empty String constant', () { - expect(AppConfig.appVersion, isA()); - expect(AppConfig.appVersion, isNotEmpty); - }); - - test('defaultForCurrentPlatform() returns an AppConfig instance', () { - final config = AppConfig.defaultForCurrentPlatform(); - expect(config, isA()); - }); - - test('defaultForPlatform returns platform-specific hotkeys', () { - final macos = AppConfig.defaultForPlatform('macos'); - final windows = AppConfig.defaultForPlatform('windows'); - - expect(macos.plainPasteHotkeyUseWin, isTrue); - expect(windows.hotkeyKeyName, equals('C')); - }); - - test('defaultForPlatform unknown string returns default AppConfig', () { - final config = AppConfig.defaultForPlatform('unknown-platform'); - expect(config, isA()); - expect(config.preferredLanguage, equals('auto')); - }); - - test( - 'two calls to defaultForCurrentPlatform return equivalent configs', - () { - final a = AppConfig.defaultForCurrentPlatform(); - final b = AppConfig.defaultForCurrentPlatform(); - expect(a.preferredLanguage, equals(b.preferredLanguage)); - expect(a.themeMode, equals(b.themeMode)); - expect(a.retentionDays, equals(b.retentionDays)); - }, - ); - }); - - group('AppConfig fromJson fallback to platform defaults', () { - test('fromJson uses platform default for missing preferredLanguage', () { - final defaults = AppConfig.defaultForCurrentPlatform(); - final config = AppConfig.fromJson({}); - expect(config.preferredLanguage, equals(defaults.preferredLanguage)); - }); - - test('fromJson uses platform default for missing themeMode', () { - final defaults = AppConfig.defaultForCurrentPlatform(); - final config = AppConfig.fromJson({}); - expect(config.themeMode, equals(defaults.themeMode)); - }); - - test('fromJson explicit value overrides platform default', () { - final config = AppConfig.fromJson({'themeMode': 'light'}); - expect(config.themeMode, equals('light')); - }); - - test('fromJson explicit false overrides default true for runOnStartup', () { - final config = AppConfig.fromJson({'runOnStartup': false}); - expect(config.runOnStartup, isFalse); - }); - }); - - group('AppConfig PR #10 fields (thumbnails / onboarding / image cap)', () { - test('default values', () { - const c = AppConfig(); - expect(c.generateImageThumbnails, isTrue); - expect(c.generateVideoThumbnails, isTrue); - expect(c.generateAudioThumbnails, isTrue); - expect(c.maxImageProcessingSizeMB, equals(25)); - }); - - test('JSON round-trip preserves new fields', () { - const c = AppConfig( - generateImageThumbnails: false, - generateVideoThumbnails: false, - generateAudioThumbnails: false, - maxImageProcessingSizeMB: 5, - ); - final restored = AppConfig.fromJson(c.toJson()); - expect(restored.generateImageThumbnails, isFalse); - expect(restored.generateVideoThumbnails, isFalse); - expect(restored.generateAudioThumbnails, isFalse); - expect(restored.maxImageProcessingSizeMB, equals(5)); - }); - - test('copyWith updates each new field independently', () { - const c = AppConfig(); - final u = c.copyWith( - generateImageThumbnails: false, - maxImageProcessingSizeMB: 10, - ); - expect(u.generateImageThumbnails, isFalse); - expect(u.generateVideoThumbnails, isTrue); // unchanged - expect(u.maxImageProcessingSizeMB, equals(10)); - }); - - 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( - 'hasSeenOnboarding stays false when neither legacy nor new is set', - () { - final c = AppConfig.fromJson({}); - expect(c.hasSeenOnboarding, isFalse); - }, - ); - }); - - group('AppConfig PR #9 field (keepBrokenItemsDays)', () { - test('default value is 30', () { - const c = AppConfig(); - expect(c.keepBrokenItemsDays, equals(30)); - }); - - test('JSON round-trip preserves keepBrokenItemsDays', () { - const c = AppConfig(keepBrokenItemsDays: 7); - final restored = AppConfig.fromJson(c.toJson()); - expect(restored.keepBrokenItemsDays, equals(7)); - }); - - test('absent key in JSON falls back to default (30)', () { - final c = AppConfig.fromJson({}); - expect(c.keepBrokenItemsDays, equals(30)); - }); - - test('copyWith updates keepBrokenItemsDays independently', () { - const c = AppConfig(); - final updated = c.copyWith(keepBrokenItemsDays: 14); - expect(updated.keepBrokenItemsDays, equals(14)); - // Other fields unaffected - expect(updated.retentionDays, equals(c.retentionDays)); - }); - }); - - group('AppConfig PR #10b field (resetFiltersOnShow)', () { - test('default value is true', () { - const c = AppConfig(); - expect(c.resetFiltersOnShow, isTrue); - }); - - test('JSON round-trip preserves resetFiltersOnShow', () { - const c = AppConfig(resetFiltersOnShow: false); - final restored = AppConfig.fromJson(c.toJson()); - expect(restored.resetFiltersOnShow, isFalse); - }); - - test('absent key in JSON falls back to default (true)', () { - final c = AppConfig.fromJson({}); - expect(c.resetFiltersOnShow, isTrue); - }); - - test('copyWith updates resetFiltersOnShow independently', () { - const c = AppConfig(); - final updated = c.copyWith(resetFiltersOnShow: false); - expect(updated.resetFiltersOnShow, isFalse); - expect(updated.resetScrollOnShow, equals(c.resetScrollOnShow)); - expect(updated.resetSearchOnShow, equals(c.resetSearchOnShow)); - }); - }); - - group('AppConfig PR #11 field (imagesQuotaMB)', () { - test('default value is 0 (unlimited)', () { - const c = AppConfig(); - expect(c.imagesQuotaMB, equals(0)); - }); - - test('JSON round-trip preserves imagesQuotaMB', () { - const c = AppConfig(imagesQuotaMB: 500); - final restored = AppConfig.fromJson(c.toJson()); - expect(restored.imagesQuotaMB, equals(500)); - }); - - test('absent key in JSON falls back to default (0)', () { - final c = AppConfig.fromJson({}); - expect(c.imagesQuotaMB, equals(0)); - }); - - test('copyWith updates imagesQuotaMB independently', () { - const c = AppConfig(); - final updated = c.copyWith(imagesQuotaMB: 1024); - expect(updated.imagesQuotaMB, equals(1024)); - // Other multimedia fields unaffected - expect( - updated.maxImageProcessingSizeMB, - equals(c.maxImageProcessingSizeMB), - ); - }); - }); - - group('AppConfig PR #12 window position fields', () { - test('default values', () { - const c = AppConfig(); - expect(c.rememberWindowPosition, isFalse); - expect(c.lastWindowX, isNull); - expect(c.lastWindowY, isNull); - }); - - test('toJson with defaults omits lastWindowX and lastWindowY', () { - const c = AppConfig(); - final json = c.toJson(); - expect(json.containsKey('lastWindowX'), isFalse); - expect(json.containsKey('lastWindowY'), isFalse); - expect(json.containsKey('rememberWindowPosition'), isTrue); - }); - - test('toJson with values present includes lastWindowX and lastWindowY', () { - const c = AppConfig(lastWindowX: 100.0, lastWindowY: 200.0); - final json = c.toJson(); - expect(json['lastWindowX'], equals(100.0)); - expect(json['lastWindowY'], equals(200.0)); - }); - - test('fromJson with values present reads them correctly', () { - final c = AppConfig.fromJson({ - 'lastWindowX': 123.5, - 'lastWindowY': 456.5, - 'rememberWindowPosition': true, - }); - expect(c.lastWindowX, equals(123.5)); - expect(c.lastWindowY, equals(456.5)); - expect(c.rememberWindowPosition, isTrue); - }); - - test( - 'fromJson with values absent leaves lastWindowX and lastWindowY null', - () { - final c = AppConfig.fromJson({}); - expect(c.lastWindowX, isNull); - expect(c.lastWindowY, isNull); - }, - ); - - test('fromJson with lastWindowX as int converts to double', () { - final c = AppConfig.fromJson({'lastWindowX': 1920}); - expect(c.lastWindowX, equals(1920.0)); - expect(c.lastWindowX, isA()); - }); - - test('rememberWindowPosition is always present in toJson', () { - const c1 = AppConfig(rememberWindowPosition: false); - const c2 = AppConfig(rememberWindowPosition: true); - expect(c1.toJson().containsKey('rememberWindowPosition'), isTrue); - expect(c1.toJson()['rememberWindowPosition'], isFalse); - expect(c2.toJson()['rememberWindowPosition'], isTrue); - }); - - test('copyWith without lastWindowX preserves existing value', () { - const c = AppConfig(lastWindowX: 100.0); - final updated = c.copyWith(rememberWindowPosition: true); - expect(updated.lastWindowX, equals(100.0)); - }); - - test('copyWith(lastWindowX: null) clears the value', () { - const c = AppConfig(lastWindowX: 100.0); - final updated = c.copyWith(lastWindowX: null); - expect(updated.lastWindowX, isNull); - }); - - test('copyWith(lastWindowX: 200.0) updates the value', () { - const c = AppConfig(lastWindowX: 100.0); - final updated = c.copyWith(lastWindowX: 200.0); - expect(updated.lastWindowX, equals(200.0)); - }); - - test('copyWith without lastWindowY preserves existing value', () { - const c = AppConfig(lastWindowY: 50.0); - final updated = c.copyWith(rememberWindowPosition: true); - expect(updated.lastWindowY, equals(50.0)); - }); - - test('copyWith(lastWindowY: null) clears the value', () { - const c = AppConfig(lastWindowY: 50.0); - final updated = c.copyWith(lastWindowY: null); - expect(updated.lastWindowY, isNull); - }); - - test('copyWith(lastWindowY: 300.0) updates the value', () { - const c = AppConfig(lastWindowY: 50.0); - final updated = c.copyWith(lastWindowY: 300.0); - expect(updated.lastWindowY, equals(300.0)); - }); - - test('copyWith(rememberWindowPosition: true) updates correctly', () { - const c = AppConfig(); - final updated = c.copyWith(rememberWindowPosition: true); - expect(updated.rememberWindowPosition, isTrue); - }); - - test( - 'copyWith without rememberWindowPosition preserves existing value', - () { - const c = AppConfig(rememberWindowPosition: true); - final updated = c.copyWith(lastWindowX: 10.0); - expect(updated.rememberWindowPosition, isTrue); - }, - ); - - test('save and load round-trip preserves window position fields', () async { - final dir = Directory.systemTemp.createTempSync( - 'config_window_pos_test_', - ); - final path = '${dir.path}/config.json'; - try { - const original = AppConfig( - rememberWindowPosition: true, - lastWindowX: 123.5, - lastWindowY: 456.5, - ); - await original.save(path); - final loaded = await AppConfig.load(path); - expect(loaded.rememberWindowPosition, isTrue); - expect(loaded.lastWindowX, equals(123.5)); - expect(loaded.lastWindowY, equals(456.5)); - } finally { - dir.deleteSync(recursive: true); - } - }); - }); -} diff --git a/core/test/app_logger_test.dart b/core/test/app_logger_test.dart deleted file mode 100644 index 619ee9a5..00000000 --- a/core/test/app_logger_test.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; - -void main() { - // AppLogger uses static state: each test file is its own Dart isolate, - // so state starts fresh here. - - group('AppLogger before initialization', () { - test('info/warn/error do nothing before init', () { - expect(() => AppLogger.info('pre-init'), returnsNormally); - expect(() => AppLogger.warn('pre-init'), returnsNormally); - expect(() => AppLogger.error('pre-init'), returnsNormally); - }); - - test('exception returns early when not initialized', () { - expect(() => AppLogger.exception(Exception('test')), returnsNormally); - }); - - test('logFilePath is null before init', () { - expect(AppLogger.logFilePath, isNull); - }); - - test('logDirectory is null before init', () { - expect(AppLogger.logDirectory, isNull); - }); - }); - - group('AppLogger initialization', () { - late Directory tempDir; - - setUpAll(() { - tempDir = Directory.systemTemp.createTempSync('logger_test_init_'); - // Create an old log file to test cleanup - final oldFile = File(p.join(tempDir.path, 'copypaste_2020-01-01.log')); - oldFile.writeAsStringSync('old log entry\n'); - // Set modification time to 10 days ago - final oldTime = DateTime.now().subtract(const Duration(days: 10)); - oldFile.setLastModifiedSync(oldTime); - - // Create a non-log file (should not be deleted) - File(p.join(tempDir.path, 'other.txt')).writeAsStringSync('other'); - - AppLogger.initialize(tempDir.path); - }); - - tearDownAll(() { - AppLogger.isEnabled = false; - tempDir.deleteSync(recursive: true); - }); - - test('logFilePath is set after init', () { - expect(AppLogger.logFilePath, isNotNull); - expect(AppLogger.logFilePath, contains('copypaste_')); - expect(AppLogger.logFilePath, endsWith('.log')); - }); - - test('logDirectory matches provided path', () { - expect(AppLogger.logDirectory, equals(tempDir.path)); - }); - - test('isEnabled is true after successful init', () { - expect(AppLogger.isEnabled, isTrue); - }); - - test('old log files are cleaned up during init', () { - final oldFile = File(p.join(tempDir.path, 'copypaste_2020-01-01.log')); - expect(oldFile.existsSync(), isFalse); - }); - - test('non-log files are not cleaned up', () { - final other = File(p.join(tempDir.path, 'other.txt')); - expect(other.existsSync(), isTrue); - }); - - test('second call to initialize is a no-op', () { - final pathBefore = AppLogger.logFilePath; - AppLogger.initialize('/some/other/path'); - expect(AppLogger.logFilePath, equals(pathBefore)); - }); - - test('info writes INFO entry to log', () { - AppLogger.info('test info msg'); - final content = File(AppLogger.logFilePath!).readAsStringSync(); - expect(content, contains('test info msg')); - expect(content, contains('INFO')); - }); - - test('warn writes WARN entry to log', () { - AppLogger.warn('test warn msg'); - final content = File(AppLogger.logFilePath!).readAsStringSync(); - expect(content, contains('test warn msg')); - expect(content, contains('WARN')); - }); - - test('error writes ERROR entry to log', () { - AppLogger.error('test error msg'); - final content = File(AppLogger.logFilePath!).readAsStringSync(); - expect(content, contains('test error msg')); - expect(content, contains('ERROR')); - }); - - test('exception with context writes context and error', () { - AppLogger.exception(Exception('test exception'), null, 'TestContext'); - final content = File(AppLogger.logFilePath!).readAsStringSync(); - expect(content, contains('TestContext')); - expect(content, contains('test exception')); - }); - - test('exception with stackTrace includes stack trace', () { - try { - throw Exception('stacktrace test'); - } catch (e, s) { - AppLogger.exception(e, s, 'StackContext'); - } - final content = File(AppLogger.logFilePath!).readAsStringSync(); - expect(content, contains('stacktrace test')); - }); - - test('exception without context writes error only', () { - AppLogger.exception(Exception('no context')); - final content = File(AppLogger.logFilePath!).readAsStringSync(); - expect(content, contains('no context')); - }); - - test('exception with null error does nothing', () { - final before = File(AppLogger.logFilePath!).readAsStringSync(); - AppLogger.exception(null); - final after = File(AppLogger.logFilePath!).readAsStringSync(); - expect(after, equals(before)); - }); - - test('log entry format includes timestamp brackets', () { - AppLogger.info('format check'); - final lines = File(AppLogger.logFilePath!).readAsLinesSync(); - final line = lines.lastWhere((l) => l.contains('format check')); - expect(line, matches(r'^\[\d{2}:\d{2}:\d{2}\.\d{3}\] \[INFO\]')); - }); - - test('isEnabled=false suppresses all logging', () { - AppLogger.isEnabled = false; - final before = File(AppLogger.logFilePath!).readAsStringSync(); - AppLogger.info('should not appear'); - AppLogger.warn('should not appear'); - AppLogger.error('should not appear'); - AppLogger.exception(Exception('should not appear')); - final after = File(AppLogger.logFilePath!).readAsStringSync(); - expect(after, equals(before)); - AppLogger.isEnabled = true; // restore - }); - - test('log rotation triggered when file exceeds max size', () { - final logFile = File(AppLogger.logFilePath!); - // Write 11MB to trigger rotation (max is 10MB) - logFile.writeAsBytesSync(Uint8List(11 * 1024 * 1024)); - final originalPath = AppLogger.logFilePath; - - AppLogger.info('post rotation'); - - // The original large content should be gone (renamed or deleted) - final currentSize = File(AppLogger.logFilePath!).existsSync() - ? File(AppLogger.logFilePath!).lengthSync() - : 0; - expect( - currentSize, - lessThan(11 * 1024 * 1024), - reason: 'Log should have been rotated, original file gone or small', - ); - expect(originalPath, isNotNull); - }); - }); -} diff --git a/core/test/backup_service_integration_test.dart b/core/test/backup_service_integration_test.dart deleted file mode 100644 index 8fdb63bd..00000000 --- a/core/test/backup_service_integration_test.dart +++ /dev/null @@ -1,258 +0,0 @@ -/// Integration tests for BackupService with a live SqliteRepository. -/// Verifies that a backup created from real repository data can be fully -/// restored and the contents are intact across all platforms. -library; - -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; - -void main() { - late Directory sourceDir; - late Directory destDir; - late StorageConfig sourceStorage; - late StorageConfig destStorage; - late SqliteRepository repo; - - setUp(() async { - sourceDir = Directory.systemTemp.createTempSync('backup_int_src_'); - destDir = Directory.systemTemp.createTempSync('backup_int_dst_'); - sourceStorage = await StorageConfig.create(baseDir: sourceDir.path); - destStorage = await StorageConfig.create(baseDir: destDir.path); - await sourceStorage.ensureDirectories(); - await destStorage.ensureDirectories(); - repo = SqliteRepository.fromPath(sourceStorage.databasePath); - }); - - tearDown(() async { - await repo.close(); - if (sourceDir.existsSync()) sourceDir.deleteSync(recursive: true); - if (destDir.existsSync()) destDir.deleteSync(recursive: true); - }); - - group('BackupService integration – full round-trip', () { - test('backup captures item count from service', () async { - final service = ClipboardService(repo); - await service.processText('item one', ClipboardContentType.text); - await service.processText('item two', ClipboardContentType.text); - await service.dispose(); - - final backupPath = p.join(sourceDir.path, 'snapshot.zip'); - final manifest = await BackupService.createBackup( - backupPath, - sourceStorage, - '2.0.0', - itemCount: await repo.count(), - hasPinnedItems: false, - walCheckpoint: () => repo.walCheckpoint(), - ); - - expect(manifest.itemCount, equals(2)); - }); - - test('restored database contains original clipboard items', () async { - final service = ClipboardService(repo); - await service.processText('first entry', ClipboardContentType.text); - await service.processText('second entry', ClipboardContentType.text); - await service.dispose(); - - final backupPath = p.join(sourceDir.path, 'full_restore.zip'); - await BackupService.createBackup( - backupPath, - sourceStorage, - '2.0.0', - walCheckpoint: () => repo.walCheckpoint(), - ); - - await repo.close(); - - final restored = await BackupService.restoreBackup( - backupPath, - destStorage, - ); - expect(restored, isNotNull); - - final destRepo = SqliteRepository.fromPath(destStorage.databasePath); - try { - final items = await destRepo.getAll(); - final contents = items.map((i) => i.content).toSet(); - expect(contents, contains('first entry')); - expect(contents, contains('second entry')); - } finally { - await destRepo.close(); - } - }); - - test('restored database preserves pinned status', () async { - final service = ClipboardService(repo); - final pinned = await service.processText( - 'pinned item', - ClipboardContentType.text, - ); - await service.updatePin(pinned!.id, true); - - await service.processText('normal item', ClipboardContentType.text); - await service.dispose(); - - final backupPath = p.join(sourceDir.path, 'pinned_restore.zip'); - await BackupService.createBackup( - backupPath, - sourceStorage, - '2.1.0', - walCheckpoint: () => repo.walCheckpoint(), - ); - await repo.close(); - - await BackupService.restoreBackup(backupPath, destStorage); - - final destRepo = SqliteRepository.fromPath(destStorage.databasePath); - try { - final items = await destRepo.getAll(); - final pinnedRestored = items.firstWhere( - (i) => i.content == 'pinned item', - ); - final normalRestored = items.firstWhere( - (i) => i.content == 'normal item', - ); - expect(pinnedRestored.isPinned, isTrue); - expect(normalRestored.isPinned, isFalse); - } finally { - await destRepo.close(); - } - }); - - test('backup captures image files', () async { - final imgFile = File(p.join(sourceStorage.imagesPath, 'test.png')) - ..writeAsBytesSync([137, 80, 78, 71, 13, 10, 26, 10]); // PNG header - - final backupPath = p.join(sourceDir.path, 'image_backup.zip'); - final manifest = await BackupService.createBackup( - backupPath, - sourceStorage, - '2.0.0', - ); - - expect(manifest.imageCount, equals(1)); - expect(imgFile.existsSync(), isTrue); - }); - - test('restored images directory has correct files', () async { - File( - p.join(sourceStorage.imagesPath, 'img1.png'), - ).writeAsBytesSync([1, 2, 3]); - File( - p.join(sourceStorage.imagesPath, 'img2.png'), - ).writeAsBytesSync([4, 5, 6]); - - final backupPath = p.join(sourceDir.path, 'img_restore.zip'); - await BackupService.createBackup(backupPath, sourceStorage, '2.0.0'); - - await BackupService.restoreBackup(backupPath, destStorage); - - expect( - File(p.join(destStorage.imagesPath, 'img1.png')).existsSync(), - isTrue, - ); - expect( - File(p.join(destStorage.imagesPath, 'img2.png')).existsSync(), - isTrue, - ); - }); - - test('backup includes _thumb.png companion files', () async { - // Regression: BackupService relies on directory listing to bundle - // images/, so thumbs produced by ThumbnailService must round-trip. - final main = File(p.join(sourceStorage.imagesPath, 'item-7.png')) - ..writeAsBytesSync([10, 20, 30]); - final thumb = File(p.join(sourceStorage.imagesPath, 'item-7_thumb.png')) - ..writeAsBytesSync([40, 50, 60, 70]); - - final backupPath = p.join(sourceDir.path, 'thumb_restore.zip'); - final manifest = await BackupService.createBackup( - backupPath, - sourceStorage, - '2.0.0', - ); - expect(manifest.imageCount, greaterThanOrEqualTo(2)); - - await BackupService.restoreBackup(backupPath, destStorage); - - final restoredMain = File(p.join(destStorage.imagesPath, 'item-7.png')); - final restoredThumb = File( - p.join(destStorage.imagesPath, 'item-7_thumb.png'), - ); - expect(restoredMain.existsSync(), isTrue); - expect(restoredThumb.existsSync(), isTrue); - expect(restoredMain.readAsBytesSync(), main.readAsBytesSync()); - expect(restoredThumb.readAsBytesSync(), thumb.readAsBytesSync()); - }); - - test('backup includes config files', () async { - File( - p.join(sourceStorage.configPath, 'app_config.json'), - ).writeAsStringSync('{"theme":"dark"}'); - - final backupPath = p.join(sourceDir.path, 'config_backup.zip'); - await BackupService.createBackup(backupPath, sourceStorage, '2.0.0'); - - await BackupService.restoreBackup(backupPath, destStorage); - - final restoredConfig = File( - p.join(destStorage.configPath, 'app_config.json'), - ); - expect(restoredConfig.existsSync(), isTrue); - expect(restoredConfig.readAsStringSync(), equals('{"theme":"dark"}')); - }); - - test( - 'validateBackup returns correct manifest for integration backup', - () async { - final service = ClipboardService(repo); - await service.processText('validate me', ClipboardContentType.text); - await service.dispose(); - - final backupPath = p.join(sourceDir.path, 'validate.zip'); - await BackupService.createBackup( - backupPath, - sourceStorage, - '2.5.0', - itemCount: 1, - hasPinnedItems: false, - walCheckpoint: () => repo.walCheckpoint(), - ); - - final manifest = await BackupService.validateBackup(backupPath); - expect(manifest, isNotNull); - expect(manifest!.appVersion, equals('2.5.0')); - expect(manifest.version, equals(BackupManifest.currentVersion)); - expect(manifest.itemCount, equals(1)); - }, - ); - - test( - 'hasPinnedItems is true in manifest when pinned items exist', - () async { - final service = ClipboardService(repo); - final item = await service.processText( - 'pinned', - ClipboardContentType.text, - ); - await service.updatePin(item!.id, true); - await service.dispose(); - - final backupPath = p.join(sourceDir.path, 'pinned_manifest.zip'); - final manifest = await BackupService.createBackup( - backupPath, - sourceStorage, - '2.0.0', - hasPinnedItems: true, - ); - - expect(manifest.hasPinnedItems, isTrue); - }, - ); - }); -} diff --git a/core/test/backup_service_test.dart b/core/test/backup_service_test.dart deleted file mode 100644 index d9cfeb36..00000000 --- a/core/test/backup_service_test.dart +++ /dev/null @@ -1,522 +0,0 @@ -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('backup_test_'); - storage = await StorageConfig.create(baseDir: tempDir.path); - await storage.ensureDirectories(); - }); - - tearDown(() => tempDir.deleteSync(recursive: true)); - - group('BackupManifest', () { - test('toJson and fromJson round-trip', () { - final now = DateTime.utc(2026, 3, 1); - final manifest = BackupManifest( - version: 1, - appVersion: '2.0.0', - createdAtUtc: now, - itemCount: 10, - imageCount: 2, - hasPinnedItems: true, - machineName: 'DESKTOP-TEST', - ); - final restored = BackupManifest.fromJson(manifest.toJson()); - expect(restored.version, equals(1)); - expect(restored.appVersion, equals('2.0.0')); - expect(restored.itemCount, equals(10)); - expect(restored.imageCount, equals(2)); - expect(restored.hasPinnedItems, isTrue); - expect(restored.machineName, equals('DESKTOP-TEST')); - expect(restored.createdAtUtc, equals(now)); - }); - - test('fromJson uses defaults for missing fields', () { - final manifest = BackupManifest.fromJson({}); - expect(manifest.version, equals(1)); - expect(manifest.appVersion, equals('')); - expect(manifest.itemCount, equals(0)); - expect(manifest.imageCount, equals(0)); - expect(manifest.hasPinnedItems, isFalse); - }); - }); - - group('BackupService.createBackup', () { - test('creates zip file at outputPath', () async { - File(storage.databasePath).writeAsBytesSync([83, 81, 76, 105]); - - final outputPath = p.join(tempDir.path, 'backup.zip'); - await BackupService.createBackup(outputPath, storage, '2.0.0'); - - expect(File(outputPath).existsSync(), isTrue); - }); - - test('manifest has correct appVersion', () async { - final outputPath = p.join(tempDir.path, 'backup2.zip'); - final manifest = await BackupService.createBackup( - outputPath, - storage, - '2.1.0', - ); - expect(manifest.appVersion, equals('2.1.0')); - }); - - test('counts image files correctly', () async { - File(p.join(storage.imagesPath, 'a.png')).writeAsBytesSync([1, 2]); - File(p.join(storage.imagesPath, 'b.png')).writeAsBytesSync([3, 4]); - - final outputPath = p.join(tempDir.path, 'backup3.zip'); - final manifest = await BackupService.createBackup( - outputPath, - storage, - '2.0.0', - ); - expect(manifest.imageCount, equals(2)); - }); - - test('works without database file', () async { - final outputPath = p.join(tempDir.path, 'backup_empty.zip'); - final manifest = await BackupService.createBackup( - outputPath, - storage, - '2.0.0', - ); - expect(manifest.imageCount, equals(0)); - }); - - test( - 'does not leave temp files in systemTemp after successful backup', - () async { - final outputPath = p.join(tempDir.path, 'backup_no_leak.zip'); - - // Snapshot temp files before - final before = Directory.systemTemp - .listSync() - .whereType() - .where((f) => p.basename(f.path).startsWith('copypaste_backup_')) - .map((f) => f.path) - .toSet(); - - await BackupService.createBackup(outputPath, storage, '2.0.0'); - - // No new copypaste_backup_* files should remain - final after = Directory.systemTemp - .listSync() - .whereType() - .where((f) => p.basename(f.path).startsWith('copypaste_backup_')) - .map((f) => f.path) - .toSet(); - - final leaked = after.difference(before); - expect(leaked, isEmpty, reason: 'Temp backup file was not cleaned up'); - }, - ); - }); - - group('BackupService.restoreBackup', () { - test('returns null for nonexistent backup file', () async { - final result = await BackupService.restoreBackup( - p.join(tempDir.path, 'missing.zip'), - storage, - ); - expect(result, isNull); - }); - - test('round-trip create and restore', () async { - File(storage.databasePath).writeAsBytesSync([83, 81, 76]); - - final outputPath = p.join(tempDir.path, 'roundtrip.zip'); - await BackupService.createBackup(outputPath, storage, '2.0.0'); - - final restoreDir = Directory.systemTemp.createTempSync('restore_'); - try { - final restoreStorage = await StorageConfig.create( - baseDir: restoreDir.path, - ); - final manifest = await BackupService.restoreBackup( - outputPath, - restoreStorage, - ); - - expect(manifest, isNotNull); - expect(manifest!.appVersion, equals('2.0.0')); - expect(File(restoreStorage.databasePath).existsSync(), isTrue); - } finally { - restoreDir.deleteSync(recursive: true); - } - }); - - test('restore creates and cleans up pre-restore snapshot', () async { - File(storage.databasePath).writeAsBytesSync([83, 81, 76]); - - final outputPath = p.join(tempDir.path, 'snapshot_test.zip'); - await BackupService.createBackup(outputPath, storage, '2.0.0'); - - final restoreDir = Directory.systemTemp.createTempSync('snapshot_'); - try { - final restoreStorage = await StorageConfig.create( - baseDir: restoreDir.path, - ); - await restoreStorage.ensureDirectories(); - File(restoreStorage.databasePath).writeAsBytesSync([1, 2, 3]); - - await BackupService.restoreBackup(outputPath, restoreStorage); - - final snapshotDirs = Directory(restoreDir.path) - .listSync() - .whereType() - .where((d) => p.basename(d.path).startsWith('.pre-restore-')); - expect(snapshotDirs, isEmpty); - } finally { - restoreDir.deleteSync(recursive: true); - } - }); - - test( - 'restoreBackup calls onBeforeRestore callback when provided', - () async { - File(storage.databasePath).writeAsBytesSync([83, 81, 76]); - - final outputPath = p.join(tempDir.path, 'before_restore.zip'); - await BackupService.createBackup(outputPath, storage, '2.0.0'); - - final restoreDir = Directory.systemTemp.createTempSync('before_r_'); - try { - final restoreStorage = await StorageConfig.create( - baseDir: restoreDir.path, - ); - var beforeRestoreCalled = false; - - final manifest = await BackupService.restoreBackup( - outputPath, - restoreStorage, - onBeforeRestore: () async { - beforeRestoreCalled = true; - }, - ); - - expect(beforeRestoreCalled, isTrue); - expect(manifest, isNotNull); - } finally { - restoreDir.deleteSync(recursive: true); - } - }, - ); - - test('restoreBackup deletes wal and shm files before restore', () async { - File(storage.databasePath).writeAsBytesSync([83, 81, 76]); - - final outputPath = p.join(tempDir.path, 'wal_cleanup.zip'); - await BackupService.createBackup(outputPath, storage, '2.0.0'); - - final restoreDir = Directory.systemTemp.createTempSync('wal_cleanup_'); - try { - final restoreStorage = await StorageConfig.create( - baseDir: restoreDir.path, - ); - await restoreStorage.ensureDirectories(); - - final walFile = File('${restoreStorage.databasePath}-wal') - ..writeAsBytesSync([1, 2, 3]); - final shmFile = File('${restoreStorage.databasePath}-shm') - ..writeAsBytesSync([4, 5, 6]); - - final manifest = await BackupService.restoreBackup( - outputPath, - restoreStorage, - ); - - expect(manifest, isNotNull); - expect(walFile.existsSync(), isFalse); - expect(shmFile.existsSync(), isFalse); - } finally { - restoreDir.deleteSync(recursive: true); - } - }); - }); - - group('BackupService.validateBackup', () { - test('returns manifest for valid backup', () async { - File(storage.databasePath).writeAsBytesSync([83, 81, 76, 105]); - - final outputPath = p.join(tempDir.path, 'validate.zip'); - await BackupService.createBackup(outputPath, storage, '2.0.0'); - - final manifest = await BackupService.validateBackup(outputPath); - expect(manifest, isNotNull); - expect(manifest!.appVersion, equals('2.0.0')); - }); - - test('returns null for nonexistent file', () async { - final manifest = await BackupService.validateBackup( - p.join(tempDir.path, 'missing.zip'), - ); - expect(manifest, isNull); - }); - - test('returns null for invalid zip', () async { - final badFile = File(p.join(tempDir.path, 'bad.zip')); - badFile.writeAsBytesSync([0, 1, 2, 3]); - - final manifest = await BackupService.validateBackup(badFile.path); - expect(manifest, isNull); - }); - - test('returns null when manifest version is newer than supported', () async { - final archive = Archive(); - final manifestJson = - '{"version":99,"appVersion":"99.0","createdAtUtc":"${DateTime.now().toUtc().toIso8601String()}","itemCount":0,"imageCount":0,"hasPinnedItems":false,"machineName":"test"}'; - final manifestBytes = manifestJson.codeUnits; - archive.addFile( - ArchiveFile('manifest.json', manifestBytes.length, manifestBytes), - ); - - final zipPath = p.join(tempDir.path, 'future_version_validate.zip'); - await File(zipPath).writeAsBytes(ZipEncoder().encode(archive)); - - final manifest = await BackupService.validateBackup(zipPath); - expect(manifest, isNull); - }); - }); - - group('BackupService additional coverage', () { - test('createBackup calls walCheckpoint when provided', () async { - var checkpointed = false; - final outputPath = p.join(tempDir.path, 'wal_backup.zip'); - await BackupService.createBackup( - outputPath, - storage, - '2.0.0', - walCheckpoint: () async { - checkpointed = true; - }, - ); - expect(checkpointed, isTrue); - expect(File(outputPath).existsSync(), isTrue); - }); - - test( - 'createBackup includes itemCount and hasPinnedItems in manifest', - () async { - final outputPath = p.join(tempDir.path, 'metadata_backup.zip'); - final manifest = await BackupService.createBackup( - outputPath, - storage, - '2.0.0', - itemCount: 42, - hasPinnedItems: true, - ); - expect(manifest.itemCount, equals(42)); - expect(manifest.hasPinnedItems, isTrue); - }, - ); - - test('createBackup includes config files', () async { - await storage.ensureDirectories(); - File(p.join(storage.configPath, 'config.json')).writeAsStringSync('{}'); - - final outputPath = p.join(tempDir.path, 'config_backup.zip'); - await BackupService.createBackup(outputPath, storage, '2.0.0'); - - final manifest = await BackupService.validateBackup(outputPath); - expect(manifest, isNotNull); - }); - - test('restoreBackup returns null for version greater than current', () async { - // Create a backup with a valid archive but version > current - File(storage.databasePath).writeAsBytesSync([83, 81, 76]); - final outputPath = p.join(tempDir.path, 'v99_backup.zip'); - // Create a fake zip with version 99 manifest - final archive = Archive(); - final manifestJson = - '{"version":99,"appVersion":"99.0","createdAtUtc":"${DateTime.now().toUtc().toIso8601String()}","itemCount":0,"imageCount":0,"hasPinnedItems":false,"machineName":"test"}'; - final manifestBytes = manifestJson.codeUnits; - archive.addFile( - ArchiveFile('manifest.json', manifestBytes.length, manifestBytes), - ); - await File(outputPath).writeAsBytes(ZipEncoder().encode(archive)); - - final restoreDir = Directory.systemTemp.createTempSync('restore_v99_'); - try { - final restoreStorage = await StorageConfig.create( - baseDir: restoreDir.path, - ); - final result = await BackupService.restoreBackup( - outputPath, - restoreStorage, - ); - expect(result, isNull); - } finally { - restoreDir.deleteSync(recursive: true); - } - }); - - test('restoreBackup skips files with path traversal', () async { - final archive = Archive(); - final manifestJson = - '{"version":1,"appVersion":"2.0","createdAtUtc":"${DateTime.now().toUtc().toIso8601String()}","itemCount":0,"imageCount":0,"hasPinnedItems":false,"machineName":"test"}'; - final manifestBytes = manifestJson.codeUnits; - archive.addFile( - ArchiveFile('manifest.json', manifestBytes.length, manifestBytes), - ); - // Add a file with path traversal - archive.addFile(ArchiveFile('../evil.txt', 5, [104, 101, 108, 108, 111])); - - final zipPath = p.join(tempDir.path, 'traversal.zip'); - await File(zipPath).writeAsBytes(ZipEncoder().encode(archive)); - - final restoreDir = Directory.systemTemp.createTempSync('traversal_r_'); - try { - final restoreStorage = await StorageConfig.create( - baseDir: restoreDir.path, - ); - final result = await BackupService.restoreBackup( - zipPath, - restoreStorage, - ); - expect(result, isNotNull); // succeeds but skips traversal file - final evil = File(p.join(restoreDir.path, '..', 'evil.txt')); - expect(evil.existsSync(), isFalse); - } finally { - restoreDir.deleteSync(recursive: true); - } - }); - - test('restoreBackup with images directory restores images', () async { - await storage.ensureDirectories(); - File(storage.databasePath).writeAsBytesSync([83, 81, 76]); - File(p.join(storage.imagesPath, 'img.png')).writeAsBytesSync([1, 2, 3]); - - final outputPath = p.join(tempDir.path, 'with_images.zip'); - await BackupService.createBackup(outputPath, storage, '2.0.0'); - - final restoreDir = Directory.systemTemp.createTempSync('img_restore_'); - try { - final restoreStorage = await StorageConfig.create( - baseDir: restoreDir.path, - ); - final manifest = await BackupService.restoreBackup( - outputPath, - restoreStorage, - ); - expect(manifest, isNotNull); - expect(manifest!.imageCount, equals(1)); - expect( - File(p.join(restoreStorage.imagesPath, 'img.png')).existsSync(), - isTrue, - ); - } finally { - restoreDir.deleteSync(recursive: true); - } - }); - - test('restoreBackup returns null for invalid zip', () async { - // Triggers AppLogger.error('restoreBackup failed: $e') in the catch block. - // snapshotDir is null here because ZipDecoder throws before _createPreRestoreSnapshot. - final badFile = File(p.join(tempDir.path, 'bad_restore.zip')); - badFile.writeAsBytesSync([0, 1, 2, 3]); - - final result = await BackupService.restoreBackup(badFile.path, storage); - expect(result, isNull); - }); - - test('restoreBackup triggers rollback when file extraction fails', () async { - // Build a valid zip with a clipboard.db entry. - final archive = Archive(); - final manifestJson = - '{"version":1,"appVersion":"2.0","createdAtUtc":"${DateTime.now().toUtc().toIso8601String()}","itemCount":0,"imageCount":0,"hasPinnedItems":false,"machineName":"test"}'; - final manifestBytes = manifestJson.codeUnits; - archive.addFile( - ArchiveFile('manifest.json', manifestBytes.length, manifestBytes), - ); - const dbBytes = [83, 81, 76, 105]; // fake SQLite header bytes - archive.addFile(ArchiveFile('clipboard.db', dbBytes.length, dbBytes)); - - final zipPath = p.join(tempDir.path, 'extraction_fail.zip'); - await File(zipPath).writeAsBytes(ZipEncoder().encode(archive)); - - final restoreDir = Directory.systemTemp.createTempSync('rollback_t_'); - try { - final restoreStorage = await StorageConfig.create( - baseDir: restoreDir.path, - ); - // Create a DIRECTORY at the path where clipboard.db would be written. - // File.create(recursive: true) on a directory path throws EISDIR, - // which causes the catch block to fire with snapshotDir != null, - // triggering _rollbackFromSnapshot. - Directory(restoreStorage.databasePath).createSync(recursive: true); - - final result = await BackupService.restoreBackup( - zipPath, - restoreStorage, - ); - // The error is caught; rollback runs; null is returned. - expect(result, isNull); - } finally { - restoreDir.deleteSync(recursive: true); - } - }); - - test( - 'rollback restores images and config when restore fails after snapshot', - () async { - final baseDir = Directory.systemTemp.createTempSync('rollback_full_'); - try { - final s = await StorageConfig.create(baseDir: baseDir.path); - await s.ensureDirectories(); - - // Pre-populate storage with an image and a config file so that - // _createPreRestoreSnapshot copies them (lines 252, 261) and - // _rollbackFromSnapshot restores them (lines 280-281, 287-288). - File(s.databasePath).writeAsBytesSync([83, 81, 76, 105]); // fake db - File( - p.join(s.imagesPath, 'keep.png'), - ).writeAsBytesSync([137, 80, 78, 71]); - File( - p.join(s.configPath, 'prefs.json'), - ).writeAsBytesSync('{"v":1}'.codeUnits); - - // Build a zip whose 'images' entry (a plain file) conflicts with - // the existing images/ directory in storage, causing EISDIR when - // File(outPath).create() is called during extraction. - final archive = Archive(); - final manifestJson = - '{"version":1,"appVersion":"2.0","createdAtUtc":"${DateTime.now().toUtc().toIso8601String()}","itemCount":0,"imageCount":0,"hasPinnedItems":false,"machineName":"ci"}'; - final manifestBytes = manifestJson.codeUnits; - archive.addFile( - ArchiveFile('manifest.json', manifestBytes.length, manifestBytes), - ); - const dbBytes = [83, 81, 76, 105]; - archive.addFile(ArchiveFile('clipboard.db', dbBytes.length, dbBytes)); - // This entry's name matches the images/ directory name inside - // storage.baseDir, so extraction will throw FileSystemException. - final imgsDirName = p.basename(s.imagesPath); - archive.addFile(ArchiveFile(imgsDirName, 1, [0])); - - final zipPath = p.join(tempDir.path, 'rollback_full.zip'); - await File(zipPath).writeAsBytes(ZipEncoder().encode(archive)); - - final result = await BackupService.restoreBackup(zipPath, s); - - // The EISDIR triggers the catch block; rollback runs; - // null is returned. - expect(result, isNull); - // After rollback the original image and config must be present. - expect(File(p.join(s.imagesPath, 'keep.png')).existsSync(), isTrue); - expect(File(p.join(s.configPath, 'prefs.json')).existsSync(), isTrue); - } finally { - if (baseDir.existsSync()) baseDir.deleteSync(recursive: true); - } - }, - ); - }); -} diff --git a/core/test/card_color_test.dart b/core/test/card_color_test.dart deleted file mode 100644 index fb44c2d3..00000000 --- a/core/test/card_color_test.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:core/core.dart'; - -void main() { - group('CardColor', () { - test('none has value 0 and transparent argb', () { - expect(CardColor.none.value, equals(0)); - expect(CardColor.none.argb, equals(0x00000000)); - }); - - test('all non-none colors have non-zero argb', () { - for (final color in CardColor.values) { - if (color == CardColor.none) continue; - expect( - color.argb, - isNot(equals(0)), - reason: '${color.name} should have non-zero argb', - ); - } - }); - - test('all values are unique', () { - final values = CardColor.values.map((c) => c.value).toList(); - expect( - values.toSet().length, - equals(values.length), - reason: 'all CardColor values must be unique', - ); - }); - - test('fromValue returns correct color for each defined value', () { - expect(CardColor.fromValue(0), equals(CardColor.none)); - expect(CardColor.fromValue(1), equals(CardColor.red)); - expect(CardColor.fromValue(2), equals(CardColor.green)); - expect(CardColor.fromValue(3), equals(CardColor.purple)); - expect(CardColor.fromValue(4), equals(CardColor.yellow)); - expect(CardColor.fromValue(5), equals(CardColor.blue)); - expect(CardColor.fromValue(6), equals(CardColor.orange)); - }); - - test('fromValue returns none for unknown positive value', () { - expect(CardColor.fromValue(99), equals(CardColor.none)); - expect(CardColor.fromValue(100), equals(CardColor.none)); - }); - - test('fromValue returns none for negative value', () { - expect(CardColor.fromValue(-1), equals(CardColor.none)); - expect(CardColor.fromValue(-100), equals(CardColor.none)); - }); - - test('roundtrip: value → fromValue for all colors', () { - for (final color in CardColor.values) { - expect( - CardColor.fromValue(color.value), - equals(color), - reason: 'roundtrip failed for ${color.name}', - ); - } - }); - - test('none is not equal to red', () { - expect(CardColor.none == CardColor.red, isFalse); - }); - test('toString returns enum name', () { - expect(CardColor.red.toString(), contains('CardColor.red')); - }); - test('none value is 0', () { - expect(CardColor.none.value, equals(0)); - }); - - test('7 total colors exist', () { - expect(CardColor.values.length, equals(7)); - }); - - test('each color has correct argb value', () { - expect(CardColor.red.argb, equals(0xFFE74C3C)); - expect(CardColor.green.argb, equals(0xFF2ECC71)); - expect(CardColor.purple.argb, equals(0xFF9B59B6)); - expect(CardColor.yellow.argb, equals(0xFFF1C40F)); - expect(CardColor.blue.argb, equals(0xFF3498DB)); - expect(CardColor.orange.argb, equals(0xFFE67E22)); - }); - }); -} diff --git a/core/test/cleanup_service_misc_test.dart b/core/test/cleanup_service_misc_test.dart deleted file mode 100644 index 1d42e7da..00000000 --- a/core/test/cleanup_service_misc_test.dart +++ /dev/null @@ -1,214 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; - -void main() { - group('CleanupService.updateKeepBrokenCallback', () { - test('replaces the keepBrokenDays getter', () async { - final tempDir = Directory.systemTemp.createTempSync( - 'cleanup_misc_keep_broken_', - ); - final repo = SqliteRepository.inMemory(); - final storage = await StorageConfig.create(baseDir: tempDir.path); - await storage.ensureDirectories(); - - try { - var keepDays = 999; // large value — won't purge anything - - final service = CleanupService( - repo, - () => 0, - storage: storage, - getKeepBrokenDays: () => keepDays, - ); - - // Create an item with a very old brokenSince date. - final extDir = Directory(p.join(tempDir.path, 'ext')) - ..createSync(recursive: true); - final ext = File(p.join(extDir.path, 'gone.png')) - ..writeAsBytesSync([1]); - await repo.save( - ClipboardItem( - id: 'broken-item', - content: ext.path, - type: ClipboardContentType.image, - brokenSince: DateTime.now().toUtc().subtract( - const Duration(days: 60), - ), - ), - ); - ext.deleteSync(); // file is gone - - // Swap to 1 day so the item would be purged. - keepDays = 1; - service.updateKeepBrokenCallback(() => keepDays); - - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 150)); - service.dispose(); - - // The item should be gone because brokenSince > keepDays. - expect(await repo.getById('broken-item'), isNull); - - await repo.close(); - } finally { - if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); - } - }); - }); - - group('CleanupService.updateImagesQuotaCallback', () { - test('replaces the quota getter and enforces the new limit', () async { - final tempDir = Directory.systemTemp.createTempSync( - 'cleanup_misc_quota_', - ); - final repo = SqliteRepository.inMemory(); - final storage = await StorageConfig.create(baseDir: tempDir.path); - await storage.ensureDirectories(); - - try { - var quotaMB = 0; // disabled initially - - final service = CleanupService( - repo, - () => 0, - storage: storage, - getImagesQuotaMB: () => quotaMB, - ); - - // Write two ~600 KB files and register them. - final f1 = File(p.join(storage.imagesPath, 'q1.png')) - ..writeAsBytesSync(List.filled(600 * 1024, 0xAA)); - final f2 = File(p.join(storage.imagesPath, 'q2.png')) - ..writeAsBytesSync(List.filled(600 * 1024, 0xBB)); - - await repo.save( - ClipboardItem( - id: 'q1', - content: f1.path, - type: ClipboardContentType.image, - createdAt: DateTime.utc(2024, 1, 1), - modifiedAt: DateTime.utc(2024, 1, 1), - ), - ); - await repo.save( - ClipboardItem( - id: 'q2', - content: f2.path, - type: ClipboardContentType.image, - createdAt: DateTime.utc(2024, 12, 1), - modifiedAt: DateTime.utc(2024, 12, 1), - ), - ); - - // First run with quota=0 — nothing should be purged. - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 150)); - service.dispose(); - - expect(f1.existsSync(), isTrue); - expect(f2.existsSync(), isTrue); - - // Now activate a 1 MB quota and run again. - quotaMB = 1; - service.updateImagesQuotaCallback(() => quotaMB); - - final marker = File(p.join(tempDir.path, 'last_cleanup.txt')); - if (marker.existsSync()) marker.deleteSync(); - - final service2 = CleanupService( - repo, - () => 0, - storage: storage, - getImagesQuotaMB: () => quotaMB, - ); - service2.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 200)); - service2.dispose(); - - // Oldest item (q1) must have been purged to go under 1 MB. - expect(f1.existsSync(), isFalse); - expect(f2.existsSync(), isTrue); - - await repo.close(); - } finally { - if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); - } - }); - }); - - group('CleanupService stale temp dirs', () { - test('preserves a freshly created copypaste_ temp dir', () async { - final tempDir = Directory.systemTemp.createTempSync('cleanup_temp_'); - final repo = SqliteRepository.inMemory(); - final storage = await StorageConfig.create(baseDir: tempDir.path); - await storage.ensureDirectories(); - - final fresh = Directory.systemTemp.createTempSync('copypaste_'); - try { - final service = CleanupService(repo, () => 0, storage: storage); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 150)); - service.dispose(); - - expect( - fresh.existsSync(), - isTrue, - reason: 'temp dirs younger than the max age must not be deleted', - ); - - await repo.close(); - } finally { - if (fresh.existsSync()) fresh.deleteSync(recursive: true); - if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); - } - }); - }); - - group('CleanupService.isVolumePresent – macOS', () { - test( - 'returns false for a /Volumes/ path with no such mount', - () { - // Pick a name that is extremely unlikely to be an actual mounted volume. - const fakePath = - '/Volumes/CopyPasteNonExistentVolumeXYZ9999/some/file.png'; - expect(CleanupService.isVolumePresent(fakePath), isFalse); - }, - skip: !Platform.isMacOS ? 'macOS-only' : null, - ); - - test( - 'returns false for bare /Volumes/ path (empty mount name)', - () { - // '/Volumes/' → rest='', mount='' → isEmpty → false - expect(CleanupService.isVolumePresent('/Volumes/'), isFalse); - }, - skip: !Platform.isMacOS ? 'macOS-only' : null, - ); - - test( - 'returns true for a regular macOS path not under /Volumes/', - () { - // Any path that doesn't start with '/Volumes/' returns true on macOS. - expect(CleanupService.isVolumePresent('/Users/test/file.png'), isTrue); - }, - skip: !Platform.isMacOS ? 'macOS-only' : null, - ); - - test( - 'returns true for existing Macintosh HD volume', - () { - // '/Volumes/Macintosh HD' is typically present on macOS machines. - // If not, the test is still valid: existsSync() returns false → we'd - // return false, but the code path is exercised. - const path = '/Volumes/Macintosh HD/some/file.png'; - // Just assert it doesn't throw; the boolean value depends on the host. - expect(() => CleanupService.isVolumePresent(path), returnsNormally); - }, - skip: !Platform.isMacOS ? 'macOS-only' : null, - ); - }); -} diff --git a/core/test/cleanup_service_orphan_test.dart b/core/test/cleanup_service_orphan_test.dart deleted file mode 100644 index 53d9ddf2..00000000 --- a/core/test/cleanup_service_orphan_test.dart +++ /dev/null @@ -1,358 +0,0 @@ -import 'dart:io'; - -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; - late SqliteRepository repo; - - setUp(() async { - tempDir = Directory.systemTemp.createTempSync('cleanup_orphan_test_'); - storage = await StorageConfig.create(baseDir: tempDir.path); - await storage.ensureDirectories(); - repo = SqliteRepository.inMemory(); - }); - - tearDown(() async { - await repo.close(); - if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); - }); - - group('CleanupService orphan image cleanup', () { - test('removes image files not referenced in repository', () async { - // Create orphan image in images directory - final orphan = File(p.join(storage.imagesPath, 'orphan.png')) - ..writeAsBytesSync([1, 2, 3]); - - // Add a referenced image to repository - final referenced = File(p.join(storage.imagesPath, 'referenced.png')) - ..writeAsBytesSync([4, 5, 6]); - await repo.save( - ClipboardItem( - content: referenced.path, - type: ClipboardContentType.image, - contentHash: 'hash-ref', - ), - ); - - final service = CleanupService(repo, () => 30, storage: storage); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 100)); - service.dispose(); - - expect(referenced.existsSync(), isTrue); - expect(orphan.existsSync(), isFalse); - }); - - test('keeps all images when all are referenced', () async { - final img1 = File(p.join(storage.imagesPath, 'img1.png')) - ..writeAsBytesSync([1, 2]); - final img2 = File(p.join(storage.imagesPath, 'img2.png')) - ..writeAsBytesSync([3, 4]); - - await repo.save( - ClipboardItem( - content: img1.path, - type: ClipboardContentType.image, - contentHash: 'hash1', - ), - ); - await repo.save( - ClipboardItem( - content: img2.path, - type: ClipboardContentType.image, - contentHash: 'hash2', - ), - ); - - final service = CleanupService(repo, () => 30, storage: storage); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 100)); - service.dispose(); - - expect(img1.existsSync(), isTrue); - expect(img2.existsSync(), isTrue); - }); - - test( - 'removes all orphan images when repository has no image items', - () async { - final orphan1 = File(p.join(storage.imagesPath, 'o1.png')) - ..writeAsBytesSync([1]); - final orphan2 = File(p.join(storage.imagesPath, 'o2.png')) - ..writeAsBytesSync([2]); - - // Only a text item — no images referenced - await repo.save( - ClipboardItem(content: 'plain text', type: ClipboardContentType.text), - ); - - final service = CleanupService(repo, () => 30, storage: storage); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 100)); - service.dispose(); - - expect(orphan1.existsSync(), isFalse); - expect(orphan2.existsSync(), isFalse); - }, - ); - - test('runs orphan cleanup even when retentionDays is 0', () async { - // Bug fix: orphan image cleanup must run independently of retention setting. - // When retention=0, time-based deletion is skipped but orphan cleanup still runs. - final orphan = File(p.join(storage.imagesPath, 'orphan_zero_ret.png')) - ..writeAsBytesSync([1, 2, 3]); - - final service = CleanupService(repo, () => 0, storage: storage); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 100)); - service.dispose(); - - // Orphan cleanup runs regardless of retention → orphan must be deleted - expect(orphan.existsSync(), isFalse); - }); - - test('unreachable external path never starts the purge clock', () async { - // An offline network share throws instead of reporting absence. Treating - // that as "file missing" would mark a healthy item broken, and letting - // the throw escape used to abort the orphan sweep entirely. - await repo.save( - ClipboardItem( - content: p.join(tempDir.path, 'offline_share', 'shot.png'), - type: ClipboardContentType.image, - contentHash: 'hash-external', - ), - ); - final orphan = File(p.join(storage.imagesPath, 'orphan_probe.png')) - ..writeAsBytesSync([1, 2, 3]); - - final service = CleanupService( - repo, - () => 30, - storage: storage, - probePath: (_) => throw const FileSystemException('Exists failed'), - ); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 100)); - service.dispose(); - - final items = await repo.getAll(); - expect(items.single.brokenSince, isNull); - expect(orphan.existsSync(), isFalse); - }); - - test('does not crash when images directory is missing', () async { - // Remove images directory to simulate missing dir - Directory(storage.imagesPath).deleteSync(recursive: true); - - final service = CleanupService(repo, () => 30, storage: storage); - await expectLater(service.runCleanupIfNeeded(), completes); - service.dispose(); - }); - - test('updateRetentionCallback changes retention days dynamically', () async { - var retentionDays = 0; - final service = CleanupService( - repo, - () => retentionDays, - storage: storage, - ); - - // With 0 days, cleanup is skipped - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 50)); - - // Now change retention to 30 and force next run by clearing last cleanup file - retentionDays = 30; - service.updateRetentionCallback(() => retentionDays); - - final cleanupFile = File(p.join(tempDir.path, 'last_cleanup.txt')); - if (cleanupFile.existsSync()) cleanupFile.deleteSync(); - - await service.runCleanupIfNeeded(); - service.dispose(); - - // No error means the dynamic callback update works - }); - - test('preserves thumbnail files referenced by thumbPath', () async { - // Regression: orphan sweep must NOT delete `_thumb.png` files - // produced by ThumbnailService for items with external sources. - final externalDir = Directory(p.join(tempDir.path, 'ext')) - ..createSync(recursive: true); - final external = File(p.join(externalDir.path, 'photo.png')) - ..writeAsBytesSync([9, 9, 9]); - - final thumb = File(p.join(storage.imagesPath, 'item-x_thumb.png')) - ..writeAsBytesSync([1, 2, 3, 4]); - - await repo.save( - ClipboardItem( - id: 'item-x', - content: external.path, - type: ClipboardContentType.image, - thumbPath: thumb.path, - ), - ); - - final service = CleanupService(repo, () => 30, storage: storage); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 100)); - service.dispose(); - - expect(thumb.existsSync(), isTrue, reason: 'thumb must survive sweep'); - expect(external.existsSync(), isTrue, reason: 'external file untouched'); - }); - }); - - group('CleanupService broken-external tracking', () { - Future runOnce(CleanupService service) async { - final cleanupFile = File(p.join(tempDir.path, 'last_cleanup.txt')); - if (cleanupFile.existsSync()) cleanupFile.deleteSync(); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 100)); - service.dispose(); - } - - test( - 'sets brokenSince when external file disappears (volume present)', - () async { - final extDir = Directory(p.join(tempDir.path, 'ext')) - ..createSync(recursive: true); - final ext = File(p.join(extDir.path, 'a.png'))..writeAsBytesSync([1]); - await repo.save( - ClipboardItem( - id: 'i1', - content: ext.path, - type: ClipboardContentType.image, - ), - ); - - ext.deleteSync(); - - final service = CleanupService( - repo, - () => 30, - storage: storage, - getKeepBrokenDays: () => 30, - ); - await runOnce(service); - service.dispose(); - - final reloaded = await repo.getById('i1'); - expect(reloaded?.brokenSince, isNotNull); - }, - ); - - test('clears brokenSince when external file reappears', () async { - final extDir = Directory(p.join(tempDir.path, 'ext')) - ..createSync(recursive: true); - final ext = File(p.join(extDir.path, 'b.png'))..writeAsBytesSync([2]); - await repo.save( - ClipboardItem( - id: 'i2', - content: ext.path, - type: ClipboardContentType.image, - brokenSince: DateTime.now().toUtc().subtract(const Duration(days: 5)), - ), - ); - - // file still present - final service = CleanupService( - repo, - () => 30, - storage: storage, - getKeepBrokenDays: () => 30, - ); - await runOnce(service); - service.dispose(); - - final reloaded = await repo.getById('i2'); - expect(reloaded?.brokenSince, isNull); - }); - - test('purges item + own thumb when brokenSince exceeds keepBrokenDays; ' - 'never touches the external path', () async { - final extDir = Directory(p.join(tempDir.path, 'ext')) - ..createSync(recursive: true); - final ext = File(p.join(extDir.path, 'gone.png'))..writeAsBytesSync([3]); - final thumb = File(p.join(storage.imagesPath, 'i3_thumb.png')) - ..writeAsBytesSync([4]); - - await repo.save( - ClipboardItem( - id: 'i3', - content: ext.path, - type: ClipboardContentType.image, - thumbPath: thumb.path, - brokenSince: DateTime.now().toUtc().subtract( - const Duration(days: 60), - ), - ), - ); - ext.deleteSync(); - - final service = CleanupService( - repo, - () => 0, - storage: storage, - getKeepBrokenDays: () => 30, - ); - await runOnce(service); - service.dispose(); - - expect(await repo.getById('i3'), isNull, reason: 'item purged'); - expect(thumb.existsSync(), isFalse, reason: 'own thumb deleted'); - // The external file was already deleted by the test itself; the - // assertion below documents the contract that the service never - // recreates or otherwise alters external paths. - expect(File(ext.path).existsSync(), isFalse); - }); - - test('does not touch pinned items even when external is broken', () async { - final extDir = Directory(p.join(tempDir.path, 'ext')) - ..createSync(recursive: true); - final ext = File(p.join(extDir.path, 'p.png'))..writeAsBytesSync([5]); - await repo.save( - ClipboardItem( - id: 'pinned', - content: ext.path, - type: ClipboardContentType.image, - isPinned: true, - ), - ); - ext.deleteSync(); - - final service = CleanupService( - repo, - () => 30, - storage: storage, - getKeepBrokenDays: () => 30, - ); - await runOnce(service); - service.dispose(); - - final reloaded = await repo.getById('pinned'); - expect(reloaded, isNotNull); - expect(reloaded?.brokenSince, isNull); - }); - - test('isVolumePresent returns false for absent Windows drive', () { - if (!Platform.isWindows) return; - // Pick a drive letter that is highly unlikely to be mounted. - final result = CleanupService.isVolumePresent(r'Q:\does\not\exist.png'); - // If by chance Q: exists on the dev box, accept either result for the - // purpose of this smoke check; the contract is documented above. - expect(result, isA()); - }); - - test('isVolumePresent returns true for current drive', () { - final cwd = Directory.current.path; - expect(CleanupService.isVolumePresent(cwd), isTrue); - }); - }); -} diff --git a/core/test/cleanup_service_quota_test.dart b/core/test/cleanup_service_quota_test.dart deleted file mode 100644 index f00e838a..00000000 --- a/core/test/cleanup_service_quota_test.dart +++ /dev/null @@ -1,258 +0,0 @@ -import 'dart:io'; - -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; - late SqliteRepository repo; - - setUp(() async { - tempDir = Directory.systemTemp.createTempSync('cleanup_quota_test_'); - storage = await StorageConfig.create(baseDir: tempDir.path); - await storage.ensureDirectories(); - repo = SqliteRepository.inMemory(); - }); - - tearDown(() async { - await repo.close(); - if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); - }); - - Future writeOwned(String id, int sizeBytes) async { - final f = File(p.join(storage.imagesPath, '$id.png')); - f.writeAsBytesSync(List.filled(sizeBytes, 0xAA)); - return f; - } - - Future saveItem({ - required String id, - required String filePath, - required DateTime createdAt, - bool isPinned = false, - String? thumbPath, - }) async { - final item = ClipboardItem( - id: id, - content: filePath, - type: ClipboardContentType.image, - contentHash: 'hash-$id', - createdAt: createdAt, - modifiedAt: createdAt, - isPinned: isPinned, - thumbPath: thumbPath, - ); - await repo.save(item); - return item; - } - - Future runCleanup(CleanupService service) async { - service.start(tempDir.path); - // Give the async chain (`runCleanupIfNeeded`) time to drain. - await Future.delayed(const Duration(milliseconds: 200)); - service.dispose(); - } - - group('CleanupService images quota (LRU purge)', () { - test('does nothing when quotaMB <= 0', () async { - final f1 = await writeOwned('a', 2 * 1024 * 1024); - await saveItem( - id: 'a', - filePath: f1.path, - createdAt: DateTime.utc(2024, 1, 1), - ); - - final service = CleanupService( - repo, - () => 0, - storage: storage, - getImagesQuotaMB: () => 0, - ); - await runCleanup(service); - - expect(f1.existsSync(), isTrue); - expect(await repo.count(), 1); - }); - - test('purges oldest unpinned items until under the cap', () async { - // 3 items, ~600 KB each, cap = 1 MB → must drop the two oldest. - final f1 = await writeOwned('old', 600 * 1024); - final f2 = await writeOwned('mid', 600 * 1024); - final f3 = await writeOwned('new', 600 * 1024); - await saveItem( - id: 'old', - filePath: f1.path, - createdAt: DateTime.utc(2024, 1, 1), - ); - await saveItem( - id: 'mid', - filePath: f2.path, - createdAt: DateTime.utc(2024, 6, 1), - ); - await saveItem( - id: 'new', - filePath: f3.path, - createdAt: DateTime.utc(2024, 12, 1), - ); - - final service = CleanupService( - repo, - () => 0, - storage: storage, - getImagesQuotaMB: () => 1, - ); - await runCleanup(service); - - expect(f1.existsSync(), isFalse, reason: 'oldest must be purged'); - expect(f2.existsSync(), isFalse, reason: 'mid must be purged'); - expect(f3.existsSync(), isTrue, reason: 'newest must survive'); - expect(await repo.getById('old'), isNull); - expect(await repo.getById('mid'), isNull); - expect(await repo.getById('new'), isNotNull); - }); - - test('skips pinned items even when oldest', () async { - final f1 = await writeOwned('pinned', 600 * 1024); - final f2 = await writeOwned('mid', 600 * 1024); - final f3 = await writeOwned('new', 600 * 1024); - await saveItem( - id: 'pinned', - filePath: f1.path, - createdAt: DateTime.utc(2024, 1, 1), - isPinned: true, - ); - await saveItem( - id: 'mid', - filePath: f2.path, - createdAt: DateTime.utc(2024, 6, 1), - ); - await saveItem( - id: 'new', - filePath: f3.path, - createdAt: DateTime.utc(2024, 12, 1), - ); - - final service = CleanupService( - repo, - () => 0, - storage: storage, - getImagesQuotaMB: () => 1, - ); - await runCleanup(service); - - expect(f1.existsSync(), isTrue, reason: 'pinned must survive'); - expect(f2.existsSync(), isFalse, reason: 'unpinned mid purged'); - expect(await repo.getById('pinned'), isNotNull); - }); - - test('also deletes the per-item thumbnail when present', () async { - final f1 = await writeOwned('a', 800 * 1024); - final thumb = File(p.join(storage.imagesPath, 'a_thumb.png')) - ..writeAsBytesSync(List.filled(300 * 1024, 0xBB)); - await saveItem( - id: 'a', - filePath: f1.path, - createdAt: DateTime.utc(2024, 1, 1), - thumbPath: thumb.path, - ); - // newer item to keep - final f2 = await writeOwned('b', 200 * 1024); - await saveItem( - id: 'b', - filePath: f2.path, - createdAt: DateTime.utc(2024, 12, 1), - ); - - final service = CleanupService( - repo, - () => 0, - storage: storage, - getImagesQuotaMB: () => 1, - ); - await runCleanup(service); - - expect(f1.existsSync(), isFalse); - expect(thumb.existsSync(), isFalse, reason: 'thumb must be deleted too'); - expect(f2.existsSync(), isTrue); - }); - - test('never touches external paths referenced by items', () async { - // External "user file" outside images/. - final external = File(p.join(tempDir.path, 'user_photo.png')) - ..writeAsBytesSync(List.filled(900 * 1024, 0xCC)); - await saveItem( - id: 'ext', - filePath: external.path, - createdAt: DateTime.utc(2024, 1, 1), - ); - // Plus an oversized owned snippet. - final owned = await writeOwned('owned', 1500 * 1024); - await saveItem( - id: 'owned', - filePath: owned.path, - createdAt: DateTime.utc(2024, 6, 1), - ); - - final service = CleanupService( - repo, - () => 0, - storage: storage, - getImagesQuotaMB: () => 1, - ); - await runCleanup(service); - - expect( - external.existsSync(), - isTrue, - reason: 'external user file must never be deleted', - ); - // owned snippet purged because total > 1MB and it is the oldest with - // owned bytes inside images/. - expect(owned.existsSync(), isFalse); - }); - - test('updateImagesQuotaCallback swaps the limit live', () async { - final f1 = await writeOwned('a', 600 * 1024); - final f2 = await writeOwned('b', 600 * 1024); - await saveItem( - id: 'a', - filePath: f1.path, - createdAt: DateTime.utc(2024, 1, 1), - ); - await saveItem( - id: 'b', - filePath: f2.path, - createdAt: DateTime.utc(2024, 12, 1), - ); - - var quota = 0; // initially disabled - final service = CleanupService( - repo, - () => 0, - storage: storage, - getImagesQuotaMB: () => quota, - ); - await runCleanup(service); - expect(f1.existsSync(), isTrue); - expect(f2.existsSync(), isTrue); - - // Activate the cap and run again on a fresh service so the - // last-cleanup gate doesn't skip the run. - quota = 1; - final marker = File(p.join(tempDir.path, 'last_cleanup.txt')); - if (marker.existsSync()) marker.deleteSync(); - final service2 = CleanupService( - repo, - () => 0, - storage: storage, - getImagesQuotaMB: () => quota, - ); - await runCleanup(service2); - expect(f1.existsSync(), isFalse); - expect(f2.existsSync(), isTrue); - }); - }); -} diff --git a/core/test/cleanup_service_test.dart b/core/test/cleanup_service_test.dart deleted file mode 100644 index 200434cd..00000000 --- a/core/test/cleanup_service_test.dart +++ /dev/null @@ -1,390 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:core/core.dart'; -import 'package:core/repository/i_clipboard_repository.dart'; - -class _FailingRepo implements IClipboardRepository { - bool failGetImagePaths = false; - - @override - Future clearOldItems(int days, {bool excludePinned = true}) => - Future.error(Exception('forced clearOldItems error')); - - @override - Future> getImagePaths() { - if (failGetImagePaths) { - return Future.error(Exception('forced getImagePaths error')); - } - return Future.value([]); - } - - @override - Future> getThumbPaths() => Future.value([]); - - @override - Future save(ClipboardItem item) => Future.value(); - @override - Future update(ClipboardItem item) => Future.value(); - @override - Future getById(String id) => Future.value(null); - @override - Future getLatest() => Future.value(null); - @override - Future findByContentAndType( - String content, - ClipboardContentType type, - ) => Future.value(null); - @override - Future findByContentHash(String hash) => Future.value(null); - @override - Future> getAll() => Future.value([]); - @override - Future delete(String id) => Future.value(); - @override - Future deleteAllUnpinned() => Future.value(0); - @override - Future count() => Future.value(0); - @override - Future> search( - String q, { - int limit = 50, - int skip = 0, - }) => Future.value([]); - @override - Future> searchAdvanced({ - String? query, - List? types, - List? colors, - bool? isPinned, - required int limit, - required int skip, - }) => Future.value([]); - @override - Future walCheckpoint() => Future.value(); - @override - Future close() => Future.value(); -} - -void main() { - late SqliteRepository repo; - late Directory tempDir; - - setUp(() { - repo = SqliteRepository.inMemory(); - tempDir = Directory.systemTemp.createTempSync('cleanup_test_'); - }); - - tearDown(() async { - await repo.close(); - if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); - }); - - group('CleanupService', () { - test('runCleanupIfNeeded via start() clears old items', () async { - await repo.save( - ClipboardItem( - content: 'old', - type: ClipboardContentType.text, - createdAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - modifiedAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - ), - ); - - final service = CleanupService(repo, () => 30); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 50)); - service.dispose(); - - final count = await repo.count(); - expect(count, equals(0)); - }); - - test('skips cleanup when same day as last run', () async { - final file = File('${tempDir.path}/last_cleanup.txt'); - file.writeAsStringSync(DateTime.now().toUtc().toIso8601String()); - - await repo.save( - ClipboardItem( - content: 'old', - type: ClipboardContentType.text, - createdAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - modifiedAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - ), - ); - - final service = CleanupService(repo, () => 30); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 50)); - service.dispose(); - - // Item should still be there because cleanup was skipped - final count = await repo.count(); - expect(count, equals(1)); - }); - - test('skips cleanup when retentionDays is 0', () async { - await repo.save( - ClipboardItem( - content: 'old', - type: ClipboardContentType.text, - createdAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - modifiedAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - ), - ); - - final service = CleanupService(repo, () => 0); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 50)); - service.dispose(); - - final count = await repo.count(); - expect(count, equals(1)); - }); - - test('skips cleanup when retentionDays is negative', () async { - await repo.save( - ClipboardItem( - content: 'item', - type: ClipboardContentType.text, - createdAt: DateTime.now().toUtc().subtract(const Duration(days: 100)), - modifiedAt: DateTime.now().toUtc().subtract( - const Duration(days: 100), - ), - ), - ); - - final service = CleanupService(repo, () => -1); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 50)); - service.dispose(); - - final count = await repo.count(); - expect(count, equals(1)); - }); - - test('preserves pinned items during cleanup', () async { - final pinned = ClipboardItem( - content: 'pinned old', - type: ClipboardContentType.text, - isPinned: true, - createdAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - modifiedAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - ); - await repo.save(pinned); - - final service = CleanupService(repo, () => 30); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 50)); - service.dispose(); - - final found = await repo.getById(pinned.id); - expect(found, isNotNull); - }); - - test('writes last cleanup date to file after running', () async { - final service = CleanupService(repo, () => 30); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 50)); - service.dispose(); - - final file = File('${tempDir.path}/last_cleanup.txt'); - expect(file.existsSync(), isTrue); - - final content = file.readAsStringSync().trim(); - final parsed = DateTime.tryParse(content); - expect(parsed, isNotNull); - // Should be today's date - final now = DateTime.now().toUtc(); - expect(parsed!.year, equals(now.year)); - expect(parsed.month, equals(now.month)); - expect(parsed.day, equals(now.day)); - }); - - test('runs cleanup with previous-day date file', () async { - final yesterday = DateTime.now().toUtc().subtract( - const Duration(days: 1), - ); - final file = File('${tempDir.path}/last_cleanup.txt'); - file.writeAsStringSync(yesterday.toIso8601String()); - - await repo.save( - ClipboardItem( - content: 'old', - type: ClipboardContentType.text, - createdAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - modifiedAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - ), - ); - - final service = CleanupService(repo, () => 30); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 50)); - service.dispose(); - - final count = await repo.count(); - expect(count, equals(0)); - }); - - test('dispose prevents further cleanup', () async { - final service = CleanupService(repo, () => 30); - service.dispose(); - // After dispose, runCleanupIfNeeded is a no-op - - await repo.save( - ClipboardItem( - content: 'old', - type: ClipboardContentType.text, - createdAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - modifiedAt: DateTime.now().toUtc().subtract(const Duration(days: 40)), - ), - ); - - await service.runCleanupIfNeeded(); - - final count = await repo.count(); - expect(count, equals(1)); // item was NOT deleted - }); - - test('does not crash on missing base dir', () async { - final service = CleanupService(repo, () => 30); - await expectLater(service.runCleanupIfNeeded(), completes); - service.dispose(); - }); - - test('logs error when clearOldItems throws', () async { - final failingRepo = _FailingRepo(); - final service = CleanupService(failingRepo, () => 30); - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 50)); - service.dispose(); - }); - - test( - 'logs error when getImagePaths throws during orphan cleanup', - () async { - final storage = await StorageConfig.create(baseDir: tempDir.path); - await storage.ensureDirectories(); - - final repoWithPassingClear = SqliteRepository.inMemory(); - final hybridRepo = _HybridRepo(repoWithPassingClear); - final hybridService = CleanupService( - hybridRepo, - () => 30, - storage: storage, - ); - - final yesterday = DateTime.now().toUtc().subtract( - const Duration(days: 1), - ); - File( - '${tempDir.path}/last_cleanup.txt', - ).writeAsStringSync(yesterday.toIso8601String()); - - hybridService.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 50)); - hybridService.dispose(); - await repoWithPassingClear.close(); - }, - ); - - test('orphan sweep still runs when broken-ref tracking throws', () async { - final storage = await StorageConfig.create(baseDir: tempDir.path); - await storage.ensureDirectories(); - final orphan = File('${storage.imagesPath}/stranded.png') - ..writeAsBytesSync([1, 2, 3]); - - final inner = SqliteRepository.inMemory(); - final repo = _HybridRepo( - inner, - failGetImagePaths: false, - failGetAll: true, - ); - final service = CleanupService(repo, () => 30, storage: storage); - - service.start(tempDir.path); - await Future.delayed(const Duration(milliseconds: 100)); - service.dispose(); - await inner.close(); - - // Tracking blew up, but the sweep it used to abort completed anyway. - expect(orphan.existsSync(), isFalse); - }); - }); -} - -class _HybridRepo implements IClipboardRepository { - _HybridRepo( - this._inner, { - this.failGetImagePaths = true, - this.failGetAll = false, - }); - final IClipboardRepository _inner; - final bool failGetImagePaths; - final bool failGetAll; - - @override - Future> getImagePaths() => failGetImagePaths - ? Future.error(Exception('forced getImagePaths error')) - : _inner.getImagePaths(); - - @override - Future> getThumbPaths() => _inner.getThumbPaths(); - - @override - Future clearOldItems(int days, {bool excludePinned = true}) => - _inner.clearOldItems(days, excludePinned: excludePinned); - @override - Future save(ClipboardItem item) => _inner.save(item); - @override - Future update(ClipboardItem item) => _inner.update(item); - @override - Future getById(String id) => _inner.getById(id); - @override - Future getLatest() => _inner.getLatest(); - @override - Future findByContentAndType( - String content, - ClipboardContentType type, - ) => _inner.findByContentAndType(content, type); - @override - Future findByContentHash(String hash) => - _inner.findByContentHash(hash); - @override - Future> getAll() => failGetAll - ? Future.error(Exception('forced getAll error')) - : _inner.getAll(); - @override - Future delete(String id) => _inner.delete(id); - @override - Future deleteAllUnpinned() => _inner.deleteAllUnpinned(); - @override - Future count() => _inner.count(); - @override - Future> search( - String q, { - int limit = 50, - int skip = 0, - }) => _inner.search(q, limit: limit, skip: skip); - @override - Future> searchAdvanced({ - String? query, - List? types, - List? colors, - bool? isPinned, - required int limit, - required int skip, - }) => _inner.searchAdvanced( - query: query, - types: types, - colors: colors, - isPinned: isPinned, - limit: limit, - skip: skip, - ); - @override - Future walCheckpoint() => _inner.walCheckpoint(); - @override - Future close() => _inner.close(); -} diff --git a/core/test/clipboard_content_type_test.dart b/core/test/clipboard_content_type_test.dart deleted file mode 100644 index 0a2d3829..00000000 --- a/core/test/clipboard_content_type_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:core/core.dart'; - -void main() { - group('ClipboardContentType.value', () { - test('returns correct int for each variant', () { - expect(ClipboardContentType.unknown.value, equals(-1)); - expect(ClipboardContentType.text.value, equals(0)); - expect(ClipboardContentType.image.value, equals(1)); - expect(ClipboardContentType.file.value, equals(2)); - expect(ClipboardContentType.folder.value, equals(3)); - expect(ClipboardContentType.link.value, equals(4)); - expect(ClipboardContentType.audio.value, equals(5)); - expect(ClipboardContentType.video.value, equals(6)); - expect(ClipboardContentType.email.value, equals(7)); - expect(ClipboardContentType.phone.value, equals(8)); - expect(ClipboardContentType.color.value, equals(9)); - expect(ClipboardContentType.ip.value, equals(10)); - expect(ClipboardContentType.uuid.value, equals(11)); - expect(ClipboardContentType.json.value, equals(12)); - }); - }); - - group('ClipboardContentType.fromValue', () { - test('converts known int values', () { - expect(ClipboardContentType.fromValue(0), ClipboardContentType.text); - expect(ClipboardContentType.fromValue(1), ClipboardContentType.image); - expect(ClipboardContentType.fromValue(2), ClipboardContentType.file); - expect(ClipboardContentType.fromValue(3), ClipboardContentType.folder); - expect(ClipboardContentType.fromValue(4), ClipboardContentType.link); - expect(ClipboardContentType.fromValue(5), ClipboardContentType.audio); - expect(ClipboardContentType.fromValue(6), ClipboardContentType.video); - expect(ClipboardContentType.fromValue(7), ClipboardContentType.email); - expect(ClipboardContentType.fromValue(8), ClipboardContentType.phone); - expect(ClipboardContentType.fromValue(9), ClipboardContentType.color); - expect(ClipboardContentType.fromValue(10), ClipboardContentType.ip); - expect(ClipboardContentType.fromValue(11), ClipboardContentType.uuid); - expect(ClipboardContentType.fromValue(12), ClipboardContentType.json); - }); - - test('returns unknown for out-of-range values', () { - expect(ClipboardContentType.fromValue(-1), ClipboardContentType.unknown); - expect(ClipboardContentType.fromValue(99), ClipboardContentType.unknown); - expect(ClipboardContentType.fromValue(-99), ClipboardContentType.unknown); - }); - - test('value and fromValue are inverse for all non-unknown variants', () { - for (final type in ClipboardContentType.values) { - if (type == ClipboardContentType.unknown) continue; - expect(ClipboardContentType.fromValue(type.value), equals(type)); - } - }); - }); - - group('ClipboardContentType extra coverage', () { - test('unknown is not equal to text', () { - expect( - ClipboardContentType.unknown == ClipboardContentType.text, - isFalse, - ); - }); - test('toString returns enum name', () { - expect( - ClipboardContentType.text.toString(), - contains('ClipboardContentType.text'), - ); - }); - test('unknown value is -1', () { - expect(ClipboardContentType.unknown.value, equals(-1)); - }); - }); -} diff --git a/core/test/clipboard_item_test.dart b/core/test/clipboard_item_test.dart deleted file mode 100644 index 0171ca20..00000000 --- a/core/test/clipboard_item_test.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:core/core.dart'; - -void main() { - group('ClipboardItem', () { - test('generates unique id when none provided', () { - final a = ClipboardItem(content: 'a', type: ClipboardContentType.text); - final b = ClipboardItem(content: 'b', type: ClipboardContentType.text); - expect(a.id, isNotEmpty); - expect(a.id, isNot(equals(b.id))); - }); - - test('preserves provided id', () { - final item = ClipboardItem( - id: 'fixed-id', - content: 'x', - type: ClipboardContentType.text, - ); - expect(item.id, equals('fixed-id')); - }); - - test('default field values', () { - final item = ClipboardItem(content: 'x', type: ClipboardContentType.text); - expect(item.isPinned, isFalse); - expect(item.pasteCount, equals(0)); - expect(item.cardColor, equals(CardColor.none)); - expect(item.appSource, isNull); - expect(item.label, isNull); - expect(item.metadata, isNull); - expect(item.contentHash, isNull); - }); - - test('isFileBasedType true for file, folder, audio, video', () { - expect( - ClipboardItem( - content: '', - type: ClipboardContentType.file, - ).isFileBasedType, - isTrue, - ); - expect( - ClipboardItem( - content: '', - type: ClipboardContentType.folder, - ).isFileBasedType, - isTrue, - ); - expect( - ClipboardItem( - content: '', - type: ClipboardContentType.audio, - ).isFileBasedType, - isTrue, - ); - expect( - ClipboardItem( - content: '', - type: ClipboardContentType.video, - ).isFileBasedType, - isTrue, - ); - }); - - test('isFileBasedType false for text, image, link, unknown', () { - for (final type in [ - ClipboardContentType.text, - ClipboardContentType.image, - ClipboardContentType.link, - ClipboardContentType.unknown, - ]) { - expect( - ClipboardItem(content: '', type: type).isFileBasedType, - isFalse, - reason: '${type.name} should not be file-based', - ); - } - }); - - test('copyWith only changes specified fields', () { - final item = ClipboardItem( - content: 'original', - type: ClipboardContentType.text, - pasteCount: 5, - cardColor: CardColor.blue, - ); - final copy = item.copyWith(content: 'updated', isPinned: true); - expect(copy.id, equals(item.id)); - expect(copy.content, equals('updated')); - expect(copy.isPinned, isTrue); - expect(copy.pasteCount, equals(5)); - expect(copy.cardColor, equals(CardColor.blue)); - expect(copy.type, equals(ClipboardContentType.text)); - }); - - test('copyWith with all card colors', () { - final item = ClipboardItem(content: 'x', type: ClipboardContentType.text); - for (final color in CardColor.values) { - final copy = item.copyWith(cardColor: color); - expect(copy.cardColor, equals(color)); - } - }); - - test('equality based on id', () { - final a = ClipboardItem( - id: 'same-id', - content: 'a', - type: ClipboardContentType.text, - ); - final b = ClipboardItem( - id: 'same-id', - content: 'b', - type: ClipboardContentType.link, - ); - final c = ClipboardItem( - id: 'different-id', - content: 'a', - type: ClipboardContentType.text, - ); - expect(a, equals(b)); - expect(a.hashCode, equals(b.hashCode)); - expect(a, isNot(equals(c))); - }); - - test('isFileAvailable returns true for non-file types', () { - final item = ClipboardItem( - content: 'text', - type: ClipboardContentType.text, - ); - expect(item.isFileAvailable(), isTrue); - }); - - test('isFileAvailable returns false for empty content on file types', () { - final item = ClipboardItem(content: '', type: ClipboardContentType.file); - expect(item.isFileAvailable(), isFalse); - }); - - test('isFileAvailable returns false when file does not exist', () { - final item = ClipboardItem( - content: '/nonexistent/path/file.txt', - type: ClipboardContentType.file, - ); - expect(item.isFileAvailable(), isFalse); - }); - - test( - 'isFileAvailable returns false when content is only whitespace lines', - () { - final item = ClipboardItem( - content: '\n\n', - type: ClipboardContentType.file, - ); - expect(item.isFileAvailable(), isFalse); - }, - ); - - test('isFileAvailable returns true when file exists', () { - final dir = Directory.systemTemp.createTempSync('item_test_'); - try { - final file = File('${dir.path}/test.txt')..writeAsStringSync('test'); - final item = ClipboardItem( - content: file.path, - type: ClipboardContentType.file, - ); - expect(item.isFileAvailable(), isTrue); - } finally { - dir.deleteSync(recursive: true); - } - }); - - test( - 'isFileAvailable returns true for folder type when directory exists', - () { - final dir = Directory.systemTemp.createTempSync('folder_item_test_'); - try { - final item = ClipboardItem( - content: dir.path, - type: ClipboardContentType.folder, - ); - expect(item.isFileAvailable(), isTrue); - } finally { - dir.deleteSync(recursive: true); - } - }, - ); - }); - - group('ClipboardItem.hasRichText', () { - ClipboardItem itemWith(String? metadata) => ClipboardItem( - content: 'x', - type: ClipboardContentType.text, - ).copyWith(metadata: metadata); - - test('is true when metadata carries a non-empty rtf key', () { - expect(itemWith('{"rtf":"e1xydGYx"}').hasRichText, isTrue); - }); - - test('is false when rtf is present but empty', () { - expect(itemWith('{"rtf":""}').hasRichText, isFalse); - }); - - test('is false when only html is present', () { - // Copying from a browser drags text/html along even for plain text, so - // html alone must not promote an item to rich. - expect(itemWith('{"html":"PGh0bWw+"}').hasRichText, isFalse); - }); - - test('is false when there is no metadata', () { - expect(itemWith(null).hasRichText, isFalse); - expect(itemWith('').hasRichText, isFalse); - }); - - test('is false on malformed or non-map metadata', () { - expect(itemWith('not json').hasRichText, isFalse); - expect(itemWith('[1,2,3]').hasRichText, isFalse); - }); - - test('is false when rtf holds a non-string value', () { - expect(itemWith('{"rtf":42}').hasRichText, isFalse); - }); - }); - - group('ClipboardItem.hasFormatting', () { - ClipboardItem itemWith(String? metadata) => ClipboardItem( - content: 'x', - type: ClipboardContentType.text, - ).copyWith(metadata: metadata); - - test('is true for rtf', () { - expect(itemWith('{"rtf":"e1xydGYx"}').hasFormatting, isTrue); - }); - - test('is true for html alone', () { - // Unlike hasRichText: the writer restores html to the clipboard, so a - // normal paste would carry formatting and stripping it is meaningful. - final item = itemWith('{"html":"PGh0bWw+"}'); - expect(item.hasFormatting, isTrue); - expect(item.hasRichText, isFalse); - }); - - test('is false when no format payload is attached', () { - expect(itemWith(null).hasFormatting, isFalse); - expect(itemWith('{"duration":42}').hasFormatting, isFalse); - expect(itemWith('{"rtf":"","html":""}').hasFormatting, isFalse); - }); - }); -} diff --git a/core/test/clipboard_service_extended_test.dart b/core/test/clipboard_service_extended_test.dart deleted file mode 100644 index ff6b3609..00000000 --- a/core/test/clipboard_service_extended_test.dart +++ /dev/null @@ -1,311 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; - -void main() { - late SqliteRepository repo; - late ClipboardService service; - - setUp(() { - repo = SqliteRepository.inMemory(); - service = ClipboardService(repo); - }); - - tearDown(() async { - await service.dispose(); - await repo.close(); - }); - - group('ClipboardService.processFiles', () { - test('creates file item from multiple file paths', () async { - final files = ['C:\\file1.txt', 'C:\\file2.txt', 'C:\\file3.txt']; - final result = await service.processFiles( - files, - ClipboardContentType.file, - ); - - expect(result, isNotNull); - expect(result!.type, equals(ClipboardContentType.file)); - expect(result.content, contains('file1.txt')); - expect(result.content, contains('file2.txt')); - expect(result.metadata, isNotNull); - }); - - test('creates folder item from path', () async { - final result = await service.processFiles([ - 'C:\\MyFolder', - ], ClipboardContentType.folder); - - expect(result, isNotNull); - expect(result!.type, equals(ClipboardContentType.folder)); - }); - - test('returns null for empty file list', () async { - final result = await service.processFiles([], ClipboardContentType.file); - - expect(result, isNull); - }); - - test('reactivates existing file item', () async { - final files = ['C:\\existing.txt']; - final first = await service.processFiles( - files, - ClipboardContentType.file, - ); - - ClipboardItem? reactivated; - service.onItemReactivated.listen((item) => reactivated = item); - - final second = await service.processFiles( - files, - ClipboardContentType.file, - ); - await Future.delayed(Duration.zero); - - expect(second, isNotNull); - expect(reactivated?.id, equals(first!.id)); - }); - - test('includes file metadata in item', () async { - final files = ['C:\\test.pdf']; - final result = await service.processFiles( - files, - ClipboardContentType.file, - ); - - expect(result!.metadata, isNotNull); - expect(result.metadata, contains('file_count')); - expect(result.metadata, contains('file_name')); - expect(result.metadata, contains('first_ext')); - }); - }); - - group('ClipboardService.processImage', () { - test('creates image item by contentHash', () async { - final result = await service.processImage( - 'hash-abc-123', - imagePath: '/tmp/image.png', - ); - - expect(result, isNotNull); - expect(result!.contentHash, equals('hash-abc-123')); - expect(result.type, equals(ClipboardContentType.image)); - }); - - test('reactivates existing image by contentHash', () async { - const hash = 'dup-hash'; - final first = await service.processImage(hash, imagePath: '/img1.png'); - - ClipboardItem? reactivated; - service.onItemReactivated.listen((item) => reactivated = item); - - final second = await service.processImage(hash, imagePath: '/img2.png'); - await Future.delayed(Duration.zero); - - expect(second, isNotNull); - expect(reactivated?.id, equals(first!.id)); - }); - - test('does not swallow new bytes when the stored file is gone', () async { - const hash = 'stale-file-hash'; - final first = await service.processImage(hash, imagePath: '/gone.png'); - - final second = await service.processImage( - hash, - imagePath: '/gone.png', - imageBytes: [1, 2, 3, 4], - ); - - expect(second, isNotNull); - expect(second!.id, isNot(equals(first!.id))); - }); - - test( - 'reactivates instead of duplicating when the file is still there', - () async { - final dir = Directory.systemTemp.createTempSync('cp_img_'); - addTearDown(() => dir.deleteSync(recursive: true)); - final onDisk = File(p.join(dir.path, 'kept.png')) - ..writeAsBytesSync([1, 2, 3]); - - const hash = 'live-file-hash'; - final first = await service.processImage(hash, imagePath: onDisk.path); - final second = await service.processImage( - hash, - imagePath: onDisk.path, - imageBytes: [9, 9], - ); - - expect(second!.id, equals(first!.id)); - }, - ); - - test('still creates the item when the BMP cannot be written', () async { - final missingDir = p.join( - Directory.systemTemp.path, - 'cp_absent_${DateTime.now().microsecondsSinceEpoch}', - 'nested', - ); - final isolated = ClipboardService(repo, imagesPath: missingDir); - addTearDown(isolated.dispose); - - final result = await isolated.processImage( - 'unwritable-hash', - imageBytes: [1, 2, 3], - ); - - expect(result, isNotNull); - expect(result!.content, isEmpty); - }); - - test('reactivates a pathless entry even when bytes are present', () async { - const hash = 'pathless-hash'; - final first = await service.processImage(hash); - - final second = await service.processImage( - hash, - imageBytes: [7, 7, 7], - ); - - expect(second!.id, equals(first!.id)); - }); - - test('stores image path in content field', () async { - const imagePath = '/home/user/screenshot.png'; - final result = await service.processImage( - 'path-hash', - imagePath: imagePath, - ); - - expect(result!.content, equals(imagePath)); - }); - }); - - group('ClipboardService.notifyPasteInitiated', () { - test( - 'does not lose different content copied within paste window', - () async { - service.pasteIgnoreWindowMs = 100; - - final item = await service.processText( - 'first content', - ClipboardContentType.text, - ); - expect(item, isNotNull); - - await service.notifyPasteInitiated(item!.id); - - final copied = await service.processText( - 'second content', - ClipboardContentType.text, - ); - - expect(copied, isNotNull); - expect(copied!.content, 'second content'); - }, - ); - - test('window expires allowing new items', () async { - service.pasteIgnoreWindowMs = 50; - - final first = await service.processText( - 'content1', - ClipboardContentType.text, - ); - expect(first, isNotNull); - - await service.notifyPasteInitiated(first!.id); - await Future.delayed(const Duration(milliseconds: 60)); - - final second = await service.processText( - 'content2', - ClipboardContentType.text, - ); - - expect(second, isNotNull); - expect(second!.content, equals('content2')); - }); - - test('same content ignored within double window', () async { - service.pasteIgnoreWindowMs = 50; - - const text = 'duplicate content'; - final first = await service.processText(text, ClipboardContentType.text); - expect(first, isNotNull); - - await service.notifyPasteInitiated(first!.id); - await Future.delayed(const Duration(milliseconds: 30)); - - final duplicate = await service.processText( - text, - ClipboardContentType.text, - ); - - expect(duplicate, isNull); - }); - }); - - group('ClipboardService.dispose', () { - test('disposes resources without errors', () async { - final testService = ClipboardService(repo); - - // Just verify dispose completes without errors - await testService.dispose(); - expect(true, isTrue); - }); - }); - - group('ClipboardService integration scenarios', () { - test('text and file items coexist', () async { - final text = await service.processText( - 'text content', - ClipboardContentType.text, - ); - final files = await service.processFiles([ - 'C:\\document.docx', - ], ClipboardContentType.file); - - expect(text, isNotNull); - expect(files, isNotNull); - expect(text!.type, equals(ClipboardContentType.text)); - expect(files!.type, equals(ClipboardContentType.file)); - expect(text.id, isNot(files.id)); - }); - - test('can process all content types', () async { - final text = await service.processText('text', ClipboardContentType.text); - final link = await service.processText( - 'https://example.com', - ClipboardContentType.link, - ); - final image = await service.processImage('img-hash'); - final files = await service.processFiles([ - 'file.txt', - ], ClipboardContentType.file); - - expect(text, isNotNull); - expect(link, isNotNull); - expect(image, isNotNull); - expect(files, isNotNull); - }); - - test('metadata is preserved for complex content', () async { - final result = await service.processText( - 'rich content', - ClipboardContentType.text, - source: 'Word', - rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], - htmlBytes: [0x3C, 0x68, 0x74, 0x6D, 0x6C], - ); - - expect(result, isNotNull); - expect(result!.appSource, equals('Word')); - expect(result.metadata, isNotNull); - expect(result.metadata, contains('rtf')); - expect(result.metadata, contains('html')); - }); - }); -} diff --git a/core/test/clipboard_service_platform_test.dart b/core/test/clipboard_service_platform_test.dart deleted file mode 100644 index 8e056f30..00000000 --- a/core/test/clipboard_service_platform_test.dart +++ /dev/null @@ -1,380 +0,0 @@ -/// Integration tests that verify ClipboardService behaviour is identical -/// across Windows and macOS — no platform-specific branching exists -/// in the Dart service layer, so these tests run unconditionally on all -/// platforms (CI runs for each OS via the flutter test matrix). -library; - -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; - -void main() { - late SqliteRepository repo; - late Directory imagesDir; - late Directory filesDir; - late ClipboardService service; - - setUp(() { - repo = SqliteRepository.inMemory(); - imagesDir = Directory.systemTemp.createTempSync('svc_platform_'); - filesDir = Directory.systemTemp.createTempSync('svc_platform_files_'); - service = ClipboardService(repo, imagesPath: imagesDir.path); - }); - - tearDown(() async { - await service.dispose(); - await repo.close(); - if (imagesDir.existsSync()) imagesDir.deleteSync(recursive: true); - if (filesDir.existsSync()) filesDir.deleteSync(recursive: true); - }); - - group('ClipboardService – cross-platform path handling', () { - test('processFiles handles Unix-style paths', () async { - final result = await service.processFiles([ - '/home/user/documents/report.pdf', - ], ClipboardContentType.file); - expect(result, isNotNull); - expect(result!.content, equals('/home/user/documents/report.pdf')); - final meta = jsonDecode(result.metadata!) as Map; - expect(meta['file_name'], equals('report.pdf')); - expect(meta['first_ext'], equals('.pdf')); - }); - - test('processFiles handles Windows-style paths', () async { - final result = await service.processFiles([ - r'C:\Users\user\Documents\report.docx', - ], ClipboardContentType.file); - expect(result, isNotNull); - // p.basename handles both separators - expect(result!.content, contains('report.docx')); - }); - - test('processFiles handles multiple files across platforms', () async { - final paths = [ - if (Platform.isWindows) ...[ - r'C:\docs\file1.txt', - r'C:\docs\file2.txt', - ] else ...[ - '/home/user/file1.txt', - '/home/user/file2.txt', - ], - ]; - - final result = await service.processFiles( - paths, - ClipboardContentType.file, - ); - expect(result, isNotNull); - final meta = jsonDecode(result!.metadata!) as Map; - expect(meta['file_count'], equals(2)); - }); - - test('processFiles with folder type sets is_directory=true', () async { - final folder = Platform.isWindows ? r'C:\MyFolder' : '/home/user/folder'; - final result = await service.processFiles([ - folder, - ], ClipboardContentType.folder); - expect(result, isNotNull); - final meta = jsonDecode(result!.metadata!) as Map; - expect(meta['is_directory'], isTrue); - }); - - test('processFiles with file type sets is_directory=false', () async { - final file = Platform.isWindows - ? r'C:\MyFolder\file.txt' - : '/home/user/file.txt'; - final result = await service.processFiles([ - file, - ], ClipboardContentType.file); - final meta = jsonDecode(result!.metadata!) as Map; - expect(meta['is_directory'], isFalse); - }); - }); - - group('ClipboardService – content deduplication cross-platform', () { - test('duplicate text is reactivated regardless of source', () async { - await service.processText( - 'duplicate', - ClipboardContentType.text, - source: 'app1', - ); - ClipboardItem? reactivated; - service.onItemReactivated.listen((item) => reactivated = item); - - await service.processText( - 'duplicate', - ClipboardContentType.text, - source: 'app2', - ); - await Future.delayed(Duration.zero); - - expect(reactivated, isNotNull); - expect(reactivated!.content, equals('duplicate')); - }); - - test('same image hash triggers reactivation', () async { - await service.processImage('cross-platform-hash-1'); - ClipboardItem? reactivated; - service.onItemReactivated.listen((item) => reactivated = item); - - await service.processImage('cross-platform-hash-1'); - await Future.delayed(Duration.zero); - - expect(reactivated, isNotNull); - }); - }); - - group('ClipboardService – image processing with real temp dir', () { - test( - 'processImage with imageBytes writes .bmp then updates via background isolate', - () async { - // This test verifies the full write-temp-BMP → background PNG pipeline. - // On all platforms the temp file is in imagesDir (injected), so the - // path separator is native and no platform divergence is expected. - final pngBytes = _makeSmallPng(); - String? reactivatedPath; - service.onItemReactivated.listen( - (item) => reactivatedPath = item.content, - ); - - final result = await service.processImage( - 'integration-hash', - imageBytes: pngBytes, - ); - expect(result, isNotNull); - // Immediately after processImage the content points to the temp BMP - expect(result!.content, endsWith('.bmp')); - expect(File(result.content).existsSync(), isTrue); - - // Wait for background PNG processing - await Future.delayed(const Duration(seconds: 8)); - - if (reactivatedPath != null) { - expect(reactivatedPath, endsWith('.png')); - } - // Whether or not the background completes within the wait, no crash is OK - }, - ); - }); - - group('ClipboardService – metadata encoding cross-platform', () { - test('RTF and HTML metadata is base64-encoded correctly', () async { - final rtfBytes = [0x7B, 0x5C, 0x72, 0x74, 0x66, 0x31]; // {\rtf1 - final htmlBytes = [ - 0x3C, - 0x62, - 0x3E, - 0x68, - 0x69, - 0x3C, - 0x2F, - 0x62, - 0x3E, - ]; // hi - - final result = await service.processText( - 'rich content', - ClipboardContentType.text, - rtfBytes: rtfBytes, - htmlBytes: htmlBytes, - ); - expect(result, isNotNull); - final meta = jsonDecode(result!.metadata!) as Map; - expect(meta.containsKey('rtf'), isTrue); - expect(meta.containsKey('html'), isTrue); - - // Verify round-trip decodability - final decodedRtf = base64Decode(meta['rtf'] as String); - final decodedHtml = base64Decode(meta['html'] as String); - expect(decodedRtf, equals(rtfBytes)); - expect(decodedHtml, equals(htmlBytes)); - }); - - test('metadata is null when no rtf/html provided', () async { - final result = await service.processText( - 'no metadata', - ClipboardContentType.text, - ); - expect(result!.metadata, isNull); - }); - }); - - group('ClipboardService – paste ignore window cross-platform', () { - test( - 'notifyPasteInitiated blocks clipboard echo on all platforms', - () async { - service.pasteIgnoreWindowMs = 500; - final item = await service.processText( - 'echo', - ClipboardContentType.text, - ); - await service.notifyPasteInitiated(item!.id); - - // Attempt to re-process the same content immediately - final ignored = await service.processText( - 'echo', - ClipboardContentType.text, - ); - expect(ignored, isNull); - }, - ); - - test('clipboard is no longer ignored after window expires', () async { - service.pasteIgnoreWindowMs = 20; - final item = await service.processText( - 'temporary', - ClipboardContentType.text, - ); - await service.notifyPasteInitiated(item!.id); - - // Wait past the full 2× window - await Future.delayed(const Duration(milliseconds: 50)); - - final result = await service.processText( - 'temporary', - ClipboardContentType.text, - ); - expect(result, isNotNull); - }); - }); - - group('ClipboardService – full CRUD lifecycle cross-platform', () { - test('full lifecycle: save → pin → label → delete', () async { - // Create - final item = await service.processText( - 'lifecycle', - ClipboardContentType.text, - ); - expect(item, isNotNull); - expect(await service.getItemCount(), equals(1)); - - // Pin - await service.updatePin(item!.id, true); - var found = await repo.getById(item.id); - expect(found!.isPinned, isTrue); - - // Label + color - await service.updateLabelAndColor(item.id, 'tagged', CardColor.green); - found = await repo.getById(item.id); - expect(found!.label, equals('tagged')); - expect(found.cardColor, equals(CardColor.green)); - - // Paste count - await service.recordPaste(item.id); - found = await repo.getById(item.id); - expect(found!.pasteCount, equals(1)); - - // Delete - await service.removeItem(item.id); - expect(await repo.getById(item.id), isNull); - expect(await service.getItemCount(), equals(0)); - }); - - test( - 'clearUnpinnedHistory preserves pinned items on all platforms', - () async { - final pinned = await service.processText( - 'keep', - ClipboardContentType.text, - ); - await service.updatePin(pinned!.id, true); - - await service.processText('delete-me-1', ClipboardContentType.text); - await service.processText('delete-me-2', ClipboardContentType.text); - - final count = await service.clearUnpinnedHistory(); - expect(count, equals(2)); - expect(await service.getItemCount(), equals(1)); - - final remaining = await service.getHistoryAdvanced(limit: 10, skip: 0); - expect(remaining.first.isPinned, isTrue); - }, - ); - }); - - group('ClipboardService – unsupported image format (SVG/PDF/etc.)', () { - test( - 'temp BMP is deleted when image bytes cannot be decoded (e.g. SVG)', - () async { - // Simulate SVG bytes arriving as image clipboard content - final svgBytes = - ''.codeUnits; - const fakeHash = 'svg-hash-001'; - - await service.processImage(fakeHash, imageBytes: svgBytes); - - // Give background Isolate time to attempt decode and clean up - await Future.delayed(const Duration(milliseconds: 500)); - - // Temp .bmp must not exist — it should be cleaned up on decode failure - final tempBmp = File(p.join(imagesDir.path, '$fakeHash.bmp')); - expect(tempBmp.existsSync(), isFalse); - }, - ); - - test( - 'repository item is still saved even when image bytes cannot be decoded', - () async { - final svgBytes = ''.codeUnits; - const fakeHash = 'svg-hash-002'; - - final item = await service.processImage(fakeHash, imageBytes: svgBytes); - - expect(item, isNotNull); - expect(item!.contentHash, equals(fakeHash)); - - await Future.delayed(const Duration(milliseconds: 500)); - - // Item still present in repository - final found = await repo.getById(item.id); - expect(found, isNotNull); - }, - ); - }); - - group('ClipboardService – file size metadata on all platforms', () { - test('includes file_size for a single real file', () async { - final testFile = File(p.join(filesDir.path, 'sample.txt')) - ..writeAsStringSync('cross-platform content'); - - final result = await service.processFiles([ - testFile.path, - ], ClipboardContentType.file); - expect(result, isNotNull); - final meta = jsonDecode(result!.metadata!) as Map; - expect(meta.containsKey('file_size'), isTrue); - expect((meta['file_size'] as int) > 0, isTrue); - }); - - test('does not include file_size for non-existent single file', () async { - final missingPath = p.join(filesDir.path, 'missing_file.txt'); - final result = await service.processFiles([ - missingPath, - ], ClipboardContentType.file); - expect(result, isNotNull); - final meta = jsonDecode(result!.metadata!) as Map; - // file_size may be absent (stat fails) — just verify no crash - expect(meta['file_count'], equals(1)); - }); - }); -} - -/// Builds a minimal 1×1 PNG in pure Dart (no external files needed). -List _makeSmallPng() { - // A valid 1×1 red PNG (67 bytes, hard-coded known-good bytes). - return [ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature - 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk length + type - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // width=1, height=1 - 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // bit depth=8, colour=RGB - 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IHDR CRC + IDAT chunk - 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, // IDAT data (1 red pixel) - 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, // IDAT CRC - 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, // IEND chunk - 0x44, 0xAE, 0x42, 0x60, 0x82, // IEND CRC - ]; -} diff --git a/core/test/clipboard_service_reclassify_test.dart b/core/test/clipboard_service_reclassify_test.dart deleted file mode 100644 index 6b059603..00000000 --- a/core/test/clipboard_service_reclassify_test.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:core/core.dart'; - -void main() { - late SqliteRepository repo; - late ClipboardService service; - - setUp(() { - repo = SqliteRepository.inMemory(); - service = ClipboardService(repo); - }); - - tearDown(() async { - await service.dispose(); - await repo.close(); - }); - - group('ClipboardService.reclassifyLegacyTextItems', () { - test('reclassifies legacy text item to email', () async { - // Insert a ClipboardItem already stored as plain text with email content - final item = ClipboardItem( - content: 'user@example.com', - type: ClipboardContentType.text, - ); - await repo.save(item); - - await service.reclassifyLegacyTextItems(); - - final updated = await repo.getById(item.id); - expect(updated, isNotNull); - expect(updated!.type, equals(ClipboardContentType.email)); - }); - - test('reclassifies legacy text item to uuid', () async { - final item = ClipboardItem( - content: '550e8400-e29b-41d4-a716-446655440000', - type: ClipboardContentType.text, - ); - await repo.save(item); - - await service.reclassifyLegacyTextItems(); - - final updated = await repo.getById(item.id); - expect(updated!.type, equals(ClipboardContentType.uuid)); - }); - - test('reclassifies legacy text item to ip', () async { - final item = ClipboardItem( - content: '192.168.1.1', - type: ClipboardContentType.text, - ); - await repo.save(item); - - await service.reclassifyLegacyTextItems(); - - final updated = await repo.getById(item.id); - expect(updated!.type, equals(ClipboardContentType.ip)); - }); - - test('reclassifies legacy text item to color', () async { - final item = ClipboardItem( - content: '#FF5733', - type: ClipboardContentType.text, - ); - await repo.save(item); - - await service.reclassifyLegacyTextItems(); - - final updated = await repo.getById(item.id); - expect(updated!.type, equals(ClipboardContentType.color)); - }); - - test('reclassifies legacy text item to json', () async { - final item = ClipboardItem( - content: '{"key": "value"}', - type: ClipboardContentType.text, - ); - await repo.save(item); - - await service.reclassifyLegacyTextItems(); - - final updated = await repo.getById(item.id); - expect(updated!.type, equals(ClipboardContentType.json)); - }); - - test('does not reclassify plain text items', () async { - final item = ClipboardItem( - content: 'just some plain text here', - type: ClipboardContentType.text, - ); - await repo.save(item); - - await service.reclassifyLegacyTextItems(); - - final unchanged = await repo.getById(item.id); - expect(unchanged!.type, equals(ClipboardContentType.text)); - }); - - test('does not touch non-text typed items', () async { - final item = ClipboardItem( - content: 'already-email@domain.com', - type: ClipboardContentType.email, - ); - await repo.save(item); - - await service.reclassifyLegacyTextItems(); - - final unchanged = await repo.getById(item.id); - expect(unchanged!.type, equals(ClipboardContentType.email)); - }); - - test('processes batch of items spanning multiple pages', () async { - // Insert more than one batch (batchSize = 50) of text items - for (var i = 0; i < 55; i++) { - await repo.save( - ClipboardItem( - content: 'plain text item number $i', - type: ClipboardContentType.text, - ), - ); - } - // Add a few that should be reclassified - final emailItem = ClipboardItem( - content: 'batch@test.com', - type: ClipboardContentType.text, - ); - final ipItem = ClipboardItem( - content: '10.0.0.1', - type: ClipboardContentType.text, - ); - await repo.save(emailItem); - await repo.save(ipItem); - - await service.reclassifyLegacyTextItems(); - - final updatedEmail = await repo.getById(emailItem.id); - final updatedIp = await repo.getById(ipItem.id); - expect(updatedEmail!.type, equals(ClipboardContentType.email)); - expect(updatedIp!.type, equals(ClipboardContentType.ip)); - }); - - test('completes gracefully when repository is empty', () async { - await expectLater(service.reclassifyLegacyTextItems(), completes); - }); - - test('stops reclassifying when disposed mid-batch', () async { - for (var i = 0; i < 10; i++) { - await repo.save( - ClipboardItem( - content: 'item$i@example.com', - type: ClipboardContentType.text, - ), - ); - } - // Dispose immediately — should not throw - await service.dispose(); - await expectLater(service.reclassifyLegacyTextItems(), completes); - }); - - test('reclassifies phone number stored as text', () async { - final item = ClipboardItem( - content: '+56 9 1234 5678', - type: ClipboardContentType.text, - ); - await repo.save(item); - - await service.reclassifyLegacyTextItems(); - - final updated = await repo.getById(item.id); - expect(updated!.type, equals(ClipboardContentType.phone)); - }); - }); -} diff --git a/core/test/clipboard_service_test.dart b/core/test/clipboard_service_test.dart deleted file mode 100644 index 7e1a2de5..00000000 --- a/core/test/clipboard_service_test.dart +++ /dev/null @@ -1,932 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:image/image.dart' as img; -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; - -void main() { - late SqliteRepository repo; - late ClipboardService service; - - setUp(() { - repo = SqliteRepository.inMemory(); - service = ClipboardService(repo); - }); - - tearDown(() async { - await service.dispose(); - await repo.close(); - }); - - group('ClipboardService.processText', () { - test('saves new item and emits onItemAdded', () async { - ClipboardItem? emitted; - service.onItemAdded.listen((item) => emitted = item); - - final result = await service.processText( - 'hello', - ClipboardContentType.text, - ); - await Future.delayed(Duration.zero); - - expect(result, isNotNull); - expect(result!.content, equals('hello')); - expect(emitted?.content, equals('hello')); - }); - - test('reactivates existing item and emits onItemReactivated', () async { - ClipboardItem? reactivated; - service.onItemReactivated.listen((item) => reactivated = item); - - final first = await service.processText('dup', ClipboardContentType.text); - expect(first, isNotNull); - - final second = await service.processText( - 'dup', - ClipboardContentType.text, - ); - await Future.delayed(Duration.zero); - - expect(second, isNotNull); - expect(reactivated?.content, equals('dup')); - }); - - test('returns null when inside paste ignore window', () async { - service.pasteIgnoreWindowMs = 60000; - service.notifyDirectPasteInitiated('ignored'); - - final result = await service.processText( - 'ignored', - ClipboardContentType.text, - ); - expect(result, isNull); - }); - - test('direct paste suppresses the plain-text clipboard rewrite', () async { - service.pasteIgnoreWindowMs = 60000; - service.notifyDirectPasteInitiated('fresh clipboard text'); - - final result = await service.processText( - 'fresh clipboard text', - ClipboardContentType.text, - ); - - expect(result, isNull); - expect(await repo.getAll(), isEmpty); - }); - - test('saves item with source and rtf/html metadata', () async { - final result = await service.processText( - 'data', - ClipboardContentType.link, - source: 'Chrome', - rtfBytes: [72, 69, 76, 76, 79], - htmlBytes: [60, 104, 116, 109, 108, 62], - ); - expect(result!.appSource, equals('Chrome')); - expect(result.metadata, isNotNull); - expect(result.metadata, contains('rtf')); - expect(result.metadata, contains('html')); - }); - - test('saves item without metadata when no rtf/html provided', () async { - final result = await service.processText( - 'plain', - ClipboardContentType.text, - ); - expect(result!.metadata, isNull); - }); - - test('re-copying with styles promotes a plain item to rich', () async { - final plain = await service.processText( - 'same text', - ClipboardContentType.text, - ); - expect(plain!.hasRichText, isFalse); - - final rich = await service.processText( - 'same text', - ClipboardContentType.text, - rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], - ); - - expect(rich!.id, equals(plain.id)); - expect(rich.hasRichText, isTrue); - }); - - test('re-copying as plain keeps the stored format', () async { - final rich = await service.processText( - 'same text', - ClipboardContentType.text, - rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], - htmlBytes: [0x3C, 0x62, 0x3E], - ); - - final plain = await service.processText( - 'same text', - ClipboardContentType.text, - ); - - expect(plain!.id, equals(rich!.id)); - expect(plain.hasRichText, isTrue); - expect(plain.hasFormatting, isTrue); - }); - - test('an empty format payload does not clear the stored one', () async { - await service.processText( - 'same text', - ClipboardContentType.text, - rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], - ); - - final plain = await service.processText( - 'same text', - ClipboardContentType.text, - rtfBytes: const [], - htmlBytes: const [], - ); - - expect(plain!.hasRichText, isTrue); - }); - - test('a styled copy replaces both format keys at once', () async { - await service.processText( - 'same text', - ClipboardContentType.text, - rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], - ); - - final htmlOnly = await service.processText( - 'same text', - ClipboardContentType.text, - htmlBytes: [0x3C, 0x62, 0x3E], - ); - - final meta = jsonDecode(htmlOnly!.metadata!) as Map; - expect(meta.containsKey('rtf'), isFalse); - expect(meta['html'], isNotEmpty); - }); - - test('a plain re-copy preserves keys owned by other flows', () async { - final first = await service.processText( - 'media caption', - ClipboardContentType.text, - ); - await service.updateMetadata(first!.id, '{"duration":42}'); - - final second = await service.processText( - 'media caption', - ClipboardContentType.text, - ); - - expect(second!.metadata, contains('duration')); - }); - - test('metadata refresh preserves keys owned by other flows', () async { - final first = await service.processText( - 'media caption', - ClipboardContentType.text, - ); - await service.updateMetadata(first!.id, '{"duration":42}'); - - final second = await service.processText( - 'media caption', - ClipboardContentType.text, - rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], - ); - - expect(second!.metadata, contains('duration')); - expect(second.hasRichText, isTrue); - }); - }); - - group('ClipboardService.processImage', () { - test('ignores an image callback immediately after direct paste', () async { - service.pasteIgnoreWindowMs = 60000; - service.notifyDirectPasteInitiated('plain clipboard text'); - - final result = await service.processImage( - 'ignored-image-hash', - imagePath: '/tmp/image.png', - ); - - expect(result, isNull); - expect(await repo.getAll(), isEmpty); - }); - - test('saves new image item by contentHash', () async { - final result = await service.processImage( - 'hash-abc', - imagePath: '/tmp/image.png', - ); - expect(result, isNotNull); - expect(result!.contentHash, equals('hash-abc')); - expect(result.type, equals(ClipboardContentType.image)); - expect(result.content, equals('/tmp/image.png')); - }); - - test('reactivates duplicate image by hash', () async { - ClipboardItem? reactivated; - service.onItemReactivated.listen((item) => reactivated = item); - - await service.processImage('hash-dup'); - await service.processImage('hash-dup'); - await Future.delayed(Duration.zero); - - expect(reactivated, isNotNull); - }); - - test('enqueues thumbnail generation for external image path', () async { - final imagesDir = Directory.systemTemp.createTempSync('svc_proc_thumb_'); - final externalDir = Directory.systemTemp.createTempSync('svc_proc_ext_'); - try { - final svc = ClipboardService(repo, imagesPath: imagesDir.path); - // Real PNG so the ThumbnailService can decode it. - final pixels = img.Image(width: 64, height: 64); - final externalFile = File(p.join(externalDir.path, 'photo.png')) - ..writeAsBytesSync(img.encodePng(pixels)); - - ClipboardItem? reactivated; - final sub = svc.onItemReactivated.listen((it) => reactivated = it); - - final created = await svc.processImage( - 'thumb-enq-hash', - imagePath: externalFile.path, - ); - expect(created, isNotNull); - - // Wait for the thumbnail queue to finish (single short job). - for (var i = 0; i < 40; i++) { - await Future.delayed(const Duration(milliseconds: 50)); - final stored = await repo.getById(created!.id); - if (stored?.thumbPath != null) break; - } - - final stored = await repo.getById(created!.id); - expect(stored?.thumbPath, isNotNull); - expect(File(stored!.thumbPath!).existsSync(), isTrue); - expect(reactivated?.id, equals(stored.id)); - - await sub.cancel(); - await svc.dispose(); - } finally { - imagesDir.deleteSync(recursive: true); - externalDir.deleteSync(recursive: true); - } - }); - }); - - group('ClipboardService.recordPaste', () { - test('increments pasteCount and returns updated item', () async { - final item = await service.processText( - 'paste me', - ClipboardContentType.text, - ); - expect(item, isNotNull); - - final updated = await service.recordPaste(item!.id); - - expect(updated, isNotNull); - expect(updated!.pasteCount, equals(1)); - final stored = await repo.getById(item.id); - expect(stored!.pasteCount, equals(1)); - }); - - test('returns null for unknown id', () async { - final result = await service.recordPaste('nonexistent-id'); - expect(result, isNull); - }); - }); - - group('ClipboardService.recordCopy', () { - test('bumps modifiedAt and emits onItemReactivated', () async { - final old = DateTime.utc(2020, 1, 1); - final item = ClipboardItem( - id: 'copy-me', - content: 'copy me', - type: ClipboardContentType.text, - modifiedAt: old, - ); - await repo.save(item); - - ClipboardItem? reactivated; - service.onItemReactivated.listen((it) => reactivated = it); - - final updated = await service.recordCopy(item.id); - await Future.delayed(Duration.zero); - - expect(updated, isNotNull); - expect(updated!.modifiedAt.isAfter(old), isTrue); - expect(updated.pasteCount, equals(item.pasteCount)); - expect(reactivated?.id, equals(item.id)); - }); - - test('returns null for unknown id', () async { - final result = await service.recordCopy('nonexistent-id'); - expect(result, isNull); - }); - }); - - group('ClipboardService.processFiles', () { - test('saves file list with metadata', () async { - ClipboardItem? emitted; - service.onItemAdded.listen((item) => emitted = item); - - final result = await service.processFiles( - ['C:\\docs\\file1.txt', 'C:\\docs\\file2.txt'], - ClipboardContentType.file, - source: 'explorer', - ); - await Future.delayed(Duration.zero); - - expect(result, isNotNull); - expect(result!.content, contains('file1.txt')); - expect(result.content, contains('file2.txt')); - expect(result.metadata, isNotNull); - expect(result.metadata, contains('file_count')); - expect(result.metadata, contains('"file_count":2')); - expect(result.appSource, equals('explorer')); - expect(emitted?.id, equals(result.id)); - }); - - test('returns null for empty file list', () async { - final result = await service.processFiles([], ClipboardContentType.file); - expect(result, isNull); - }); - - test('reactivates duplicate file list', () async { - ClipboardItem? reactivated; - service.onItemReactivated.listen((item) => reactivated = item); - - await service.processFiles(['C:\\same.txt'], ClipboardContentType.file); - await service.processFiles(['C:\\same.txt'], ClipboardContentType.file); - await Future.delayed(Duration.zero); - - expect(reactivated, isNotNull); - }); - - test('sets is_directory true for folder type', () async { - final result = await service.processFiles([ - 'C:\\MyFolder', - ], ClipboardContentType.folder); - expect(result, isNotNull); - expect(result!.metadata, contains('"is_directory":true')); - }); - }); - - group('ClipboardService.removeItem', () { - test('deletes item from repository', () async { - final item = await service.processText('bye', ClipboardContentType.text); - await service.removeItem(item!.id); - final found = await repo.getById(item.id); - expect(found, isNull); - }); - }); - - group('ClipboardService.updatePin', () { - test('pins and unpins item', () async { - final item = await service.processText( - 'pin me', - ClipboardContentType.text, - ); - - await service.updatePin(item!.id, true); - var found = await repo.getById(item.id); - expect(found!.isPinned, isTrue); - - await service.updatePin(item.id, false); - found = await repo.getById(item.id); - expect(found!.isPinned, isFalse); - }); - - test('silently ignores unknown id', () async { - await expectLater(service.updatePin('nonexistent', true), completes); - }); - }); - - group('ClipboardService.updateLabelAndColor', () { - test('updates label and color', () async { - final item = await service.processText( - 'label', - ClipboardContentType.text, - ); - - await service.updateLabelAndColor(item!.id, 'My Label', CardColor.blue); - final found = await repo.getById(item.id); - expect(found!.label, equals('My Label')); - expect(found.cardColor, equals(CardColor.blue)); - }); - - test('silently ignores unknown id', () async { - await expectLater( - service.updateLabelAndColor('nonexistent', null, CardColor.none), - completes, - ); - }); - }); - - group('ClipboardService.getHistoryAdvanced', () { - test('returns all items when no filters are applied', () async { - await service.processText('item1', ClipboardContentType.text); - await service.processText('item2', ClipboardContentType.text); - final results = await service.getHistoryAdvanced(limit: 50, skip: 0); - expect(results.length, equals(2)); - }); - - test('filters by type', () async { - await service.processText('text item', ClipboardContentType.text); - await service.processText('link item', ClipboardContentType.link); - final results = await service.getHistoryAdvanced( - types: [ClipboardContentType.text], - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.type, equals(ClipboardContentType.text)); - }); - - test('filters by color', () async { - final item = await service.processText( - 'colored', - ClipboardContentType.text, - ); - await service.updateLabelAndColor(item!.id, null, CardColor.red); - await service.processText('no color', ClipboardContentType.text); - final results = await service.getHistoryAdvanced( - colors: [CardColor.red], - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.cardColor, equals(CardColor.red)); - }); - - test('filters by isPinned', () async { - final item = await service.processText( - 'pinned', - ClipboardContentType.text, - ); - await service.updatePin(item!.id, true); - await service.processText('normal', ClipboardContentType.text); - final results = await service.getHistoryAdvanced( - isPinned: true, - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.isPinned, isTrue); - }); - - test('filters by query', () async { - await service.processText('hello world', ClipboardContentType.text); - await service.processText('something else', ClipboardContentType.text); - final results = await service.getHistoryAdvanced( - query: 'hello', - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.content, equals('hello world')); - }); - - test('respects limit and skip', () async { - for (var i = 0; i < 5; i++) { - await service.processText( - 'item$i unique_$i', - ClipboardContentType.text, - ); - } - final page1 = await service.getHistoryAdvanced(limit: 3, skip: 0); - final page2 = await service.getHistoryAdvanced(limit: 3, skip: 3); - expect(page1.length, equals(3)); - expect(page2.length, equals(2)); - }); - }); - - group('ClipboardService.clearUnpinnedHistory', () { - test('removes all non-pinned items', () async { - final item = await service.processText( - 'to be pinned', - ClipboardContentType.text, - ); - await service.updatePin(item!.id, true); - await service.processText('to be deleted', ClipboardContentType.text); - - final deleted = await service.clearUnpinnedHistory(); - expect(deleted, equals(1)); - - final remaining = await service.getHistoryAdvanced(limit: 50, skip: 0); - expect(remaining.length, equals(1)); - expect(remaining.first.isPinned, isTrue); - }); - - test('returns 0 when nothing to delete', () async { - final count = await service.clearUnpinnedHistory(); - expect(count, equals(0)); - }); - }); - - group('ClipboardService.getItemCount', () { - test('returns correct count', () async { - expect(await service.getItemCount(), equals(0)); - await service.processText('one', ClipboardContentType.text); - expect(await service.getItemCount(), equals(1)); - await service.processText('two', ClipboardContentType.text); - expect(await service.getItemCount(), equals(2)); - }); - }); - - group('ClipboardService.walCheckpoint', () { - test('completes without error', () async { - await service.processText('checkpoint test', ClipboardContentType.text); - await expectLater(service.walCheckpoint(), completes); - }); - }); - - group('ClipboardService.updateMetadata', () { - test('updates metadata and emits onItemReactivated', () async { - ClipboardItem? reactivated; - service.onItemReactivated.listen((item) => reactivated = item); - - final item = await service.processText('meta', ClipboardContentType.text); - await service.updateMetadata(item!.id, '{"key":"value"}'); - await Future.delayed(Duration.zero); - - final stored = await repo.getById(item.id); - expect(stored!.metadata, equals('{"key":"value"}')); - expect(reactivated, isNotNull); - expect(reactivated!.metadata, equals('{"key":"value"}')); - }); - - test('silently ignores unknown id', () async { - await expectLater(service.updateMetadata('nonexistent', '{}'), completes); - }); - }); - - group('ClipboardService.dispose', () { - test('closes streams after dispose', () async { - var addedDone = false; - var reactivatedDone = false; - service.onItemAdded.listen(null, onDone: () => addedDone = true); - service.onItemReactivated.listen( - null, - onDone: () => reactivatedDone = true, - ); - await service.dispose(); - await Future.delayed(Duration.zero); - expect(addedDone, isTrue); - expect(reactivatedDone, isTrue); - }); - }); - - group('ClipboardService._shouldIgnore second window', () { - test('ignores duplicate content within 2x paste window', () async { - service.pasteIgnoreWindowMs = 50; - final item = await service.processText( - 'dup-window', - ClipboardContentType.text, - ); - // Trigger notifyPasteInitiated so _lastPastedContent = 'dup-window' - await service.notifyPasteInitiated(item!.id); - - // Wait longer than 1x window but less than 2x window - await Future.delayed(const Duration(milliseconds: 65)); - - // Should be ignored because content matches and elapsed < 2x window - final result = await service.processText( - 'dup-window', - ClipboardContentType.text, - ); - expect(result, isNull); - }); - }); - - group('ClipboardService.processImage with imageBytes', () { - test('saves temp BMP when imageBytes and imagesPath provided', () async { - final imagesDir = Directory.systemTemp.createTempSync('svc_img_bmp_'); - try { - final svc = ClipboardService(repo, imagesPath: imagesDir.path); - final result = await svc.processImage( - 'hash-temp-bmp', - imageBytes: [1, 2, 3, 4, 5], - ); - expect(result, isNotNull); - // Content should point to temp BMP file - expect(result!.content, contains(imagesDir.path)); - await svc.dispose(); - } finally { - imagesDir.deleteSync(recursive: true); - } - }); - - test('background processing with valid PNG updates item', () async { - final imagesDir = Directory.systemTemp.createTempSync('svc_img_png_'); - try { - // Build a small valid PNG in memory - final image = img.Image(width: 2, height: 2); - image.setPixelRgb(0, 0, 255, 0, 0); - final pngBytes = img.encodePng(image); - - final svc = ClipboardService(repo, imagesPath: imagesDir.path); - final reactivatedCompleter = Completer(); - svc.onItemReactivated.listen((item) { - if (!reactivatedCompleter.isCompleted) { - reactivatedCompleter.complete(item); - } - }); - - final result = await svc.processImage( - 'hash-valid-png', - imageBytes: pngBytes, - ); - expect(result, isNotNull); - - // Wait for background isolate to finish processing - final updated = await reactivatedCompleter.future.timeout( - const Duration(seconds: 10), - ); - expect(updated.content, endsWith('.png')); - expect(updated.metadata, isNotNull); - expect(updated.metadata, contains('width')); - - await svc.dispose(); - } finally { - imagesDir.deleteSync(recursive: true); - } - }); - }); - - group('ClipboardService.removeItem for image', () { - test('cleans up image file when removing image item', () async { - final imagesDir = Directory.systemTemp.createTempSync('svc_rm_img_'); - try { - final svc = ClipboardService(repo, imagesPath: imagesDir.path); - final imageFile = File(p.join(imagesDir.path, 'img.png')) - ..writeAsBytesSync([137, 80, 78, 71]); - final item = ClipboardItem( - content: imageFile.path, - type: ClipboardContentType.image, - contentHash: 'rm-hash', - ); - await repo.save(item); - - await svc.removeItem(item.id); - - expect(await repo.getById(item.id), isNull); - expect(imageFile.existsSync(), isFalse); - - await svc.dispose(); - } finally { - imagesDir.deleteSync(recursive: true); - } - }); - - test('refuses to delete image file outside imagesPath', () async { - final imagesDir = Directory.systemTemp.createTempSync('svc_rm_safe_'); - final externalDir = Directory.systemTemp.createTempSync('svc_rm_ext_'); - try { - final svc = ClipboardService(repo, imagesPath: imagesDir.path); - // Simulates a dragged image whose content path is the user's own file - // (outside the app's images directory). Must never be deleted. - final externalFile = File(p.join(externalDir.path, 'user_photo.png')) - ..writeAsBytesSync([137, 80, 78, 71]); - final item = ClipboardItem( - content: externalFile.path, - type: ClipboardContentType.image, - contentHash: 'ext-hash', - ); - await repo.save(item); - - await svc.removeItem(item.id); - - expect(await repo.getById(item.id), isNull); - expect( - externalFile.existsSync(), - isTrue, - reason: 'external user files must never be deleted', - ); - - await svc.dispose(); - } finally { - imagesDir.deleteSync(recursive: true); - externalDir.deleteSync(recursive: true); - } - }); - - test('also deletes thumbPath file inside imagesPath', () async { - final imagesDir = Directory.systemTemp.createTempSync('svc_rm_thumb_'); - final externalDir = Directory.systemTemp.createTempSync('svc_rm_ext2_'); - try { - final svc = ClipboardService(repo, imagesPath: imagesDir.path); - final externalFile = File(p.join(externalDir.path, 'photo.png')) - ..writeAsBytesSync([137, 80, 78, 71]); - final thumbFile = File(p.join(imagesDir.path, 'thumb-id_thumb.png')) - ..writeAsBytesSync([1, 2, 3]); - final item = ClipboardItem( - id: 'thumb-id', - content: externalFile.path, - type: ClipboardContentType.image, - contentHash: 'thumb-hash', - thumbPath: thumbFile.path, - ); - await repo.save(item); - - await svc.removeItem(item.id); - - expect(await repo.getById(item.id), isNull); - expect( - externalFile.existsSync(), - isTrue, - reason: 'external user file must never be deleted', - ); - expect( - thumbFile.existsSync(), - isFalse, - reason: 'app-owned thumb must be removed when item is deleted', - ); - - await svc.dispose(); - } finally { - imagesDir.deleteSync(recursive: true); - externalDir.deleteSync(recursive: true); - } - }); - - test('refuses to delete thumbPath outside imagesPath', () async { - final imagesDir = Directory.systemTemp.createTempSync('svc_rm_thumb2_'); - final externalDir = Directory.systemTemp.createTempSync('svc_rm_ext3_'); - try { - final svc = ClipboardService(repo, imagesPath: imagesDir.path); - final externalThumb = File(p.join(externalDir.path, 'evil_thumb.png')) - ..writeAsBytesSync([1, 2, 3]); - final item = ClipboardItem( - id: 'evil', - content: '', - type: ClipboardContentType.image, - contentHash: 'evil-hash', - thumbPath: externalThumb.path, - ); - await repo.save(item); - - await svc.removeItem(item.id); - - expect( - externalThumb.existsSync(), - isTrue, - reason: 'thumbPath outside imagesPath must be ignored', - ); - - await svc.dispose(); - } finally { - imagesDir.deleteSync(recursive: true); - externalDir.deleteSync(recursive: true); - } - }); - }); - - group('ClipboardService.processFiles single file with size', () { - test('includes file_size in metadata for single existing file', () async { - final dir = Directory.systemTemp.createTempSync('svc_files_'); - try { - final file = File(p.join(dir.path, 'test.txt')) - ..writeAsStringSync('hello world'); - final result = await service.processFiles([ - file.path, - ], ClipboardContentType.file); - expect(result, isNotNull); - expect(result!.metadata, contains('file_size')); - } finally { - dir.deleteSync(recursive: true); - } - }); - }); - - group('ClipboardService thumbnail gate methods with imagesPath', () { - test('requestThumbnailIfStale is a no-op when called', () async { - final dir = Directory.systemTemp.createTempSync('svc_gate_stale_'); - try { - final svc = ClipboardService(repo, imagesPath: dir.path); - final item = await svc.processImage( - 'gate-hash-stale', - imagePath: '/some/external.png', - ); - expect(item, isNotNull); - // Must not throw; exercises the _thumbQueue?.enqueueIfStale branch. - expect(() => svc.requestThumbnailIfStale(item!), returnsNormally); - await svc.dispose(); - } finally { - dir.deleteSync(recursive: true); - } - }); - - test('requestThumbnailRefresh enqueues a manual refresh', () async { - final dir = Directory.systemTemp.createTempSync('svc_gate_refresh_'); - try { - final svc = ClipboardService(repo, imagesPath: dir.path); - final item = await svc.processImage( - 'gate-hash-refresh', - imagePath: '/some/external.png', - ); - expect(item, isNotNull); - // Must not throw; exercises the _thumbQueue?.enqueue branch. - expect(() => svc.requestThumbnailRefresh(item!), returnsNormally); - await svc.dispose(); - } finally { - dir.deleteSync(recursive: true); - } - }); - - test( - 'updateThumbnailTypeGate assigns to _thumbnailService.isTypeEnabled', - () async { - final dir = Directory.systemTemp.createTempSync('svc_gate_type_'); - try { - final svc = ClipboardService(repo, imagesPath: dir.path); - // Setting a gate must not throw. - svc.updateThumbnailTypeGate( - (type) => type == ClipboardContentType.image, - ); - svc.updateThumbnailTypeGate(null); - await svc.dispose(); - } finally { - dir.deleteSync(recursive: true); - } - }, - ); - - test('updateMaxImageBytesGate assigns the new getter', () async { - // Works with or without imagesPath — imageQueue is always created. - service.updateMaxImageBytesGate(() => 5 * 1024 * 1024); - service.updateMaxImageBytesGate(null); - // No error means the branch is exercised. - }); - }); - - group('ClipboardService.processText legacy reclassification', () { - test( - 'upgrades existing text item to resolved type on duplicate submission', - () async { - // Save an email address as plain text (simulates an item captured before - // the TextClassifier gained email classification). - const email = 'legacy.user@example.com'; - final legacy = ClipboardItem( - content: email, - type: ClipboardContentType.text, // stored with wrong type - ); - await repo.save(legacy); - - ClipboardItem? reactivated; - service.onItemReactivated.listen((item) => reactivated = item); - - // Submit the same content; TextClassifier now classifies it as 'email'. - // findByContentAndType('email') returns null, so the legacy path runs. - final result = await service.processText( - email, - ClipboardContentType.text, - ); - await Future.delayed(Duration.zero); - - expect(result, isNotNull); - expect(result!.type, equals(ClipboardContentType.email)); - expect(result.id, equals(legacy.id)); - expect(reactivated?.id, equals(legacy.id)); - expect(reactivated?.type, equals(ClipboardContentType.email)); - }, - ); - }); - - group('ClipboardService.processImage BMP write failure', () { - test( - 'falls back gracefully when temp BMP cannot be written', - () async { - final dir = Directory.systemTemp.createTempSync('svc_bmp_fail_'); - try { - // Make the images directory read-only so File.writeAsBytes throws. - await Process.run('chmod', ['444', dir.path]); - - final svc = ClipboardService(repo, imagesPath: dir.path); - // Should not throw; the catch block logs a warning and saves anyway. - final result = await svc.processImage( - 'bmp-fail-hash', - imageBytes: [1, 2, 3, 4], - ); - expect(result, isNotNull); - // Item is saved even though the BMP write failed; content is empty. - expect(result!.type, equals(ClipboardContentType.image)); - - await svc.dispose(); - } finally { - await Process.run('chmod', ['755', dir.path]); - dir.deleteSync(recursive: true); - } - }, - skip: Platform.isWindows - ? 'Requires POSIX directory permissions (chmod)' - : false, - ); - }); -} diff --git a/core/test/crash_logger_test.dart b/core/test/crash_logger_test.dart deleted file mode 100644 index 7c72a430..00000000 --- a/core/test/crash_logger_test.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; - -void main() { - late Directory tempDir; - - setUp(() { - tempDir = Directory.systemTemp.createTempSync('crash_logger_test_'); - CrashLogger.initialize(tempDir.path); - }); - - tearDown(() { - CrashLogger.initialize(''); - tempDir.deleteSync(recursive: true); - }); - - group('CrashLogger.initialize', () { - test('sets filePath after initialize', () { - expect(CrashLogger.filePath, equals(p.join(tempDir.path, 'crash.log'))); - }); - - test('creates the base directory if it does not exist', () async { - final sub = Directory(p.join(tempDir.path, 'newdir')); - expect(sub.existsSync(), isFalse); - CrashLogger.initialize(sub.path); - expect(sub.existsSync(), isTrue); - }); - - test('does not throw on invalid path', () { - expect(() => CrashLogger.initialize('\x00invalid\x00'), returnsNormally); - }); - }); - - group('CrashLogger.report', () { - test('creates crash.log on first report', () { - final path = p.join(tempDir.path, 'crash.log'); - expect(File(path).existsSync(), isFalse); - CrashLogger.report(Exception('boom'), null); - expect(File(path).existsSync(), isTrue); - }); - - test('written content contains timestamp marker', () { - CrashLogger.report(Exception('test'), null); - final content = File( - p.join(tempDir.path, 'crash.log'), - ).readAsStringSync(); - expect(content, contains('====')); - }); - - test('written content contains platform name', () { - CrashLogger.report(Exception('test'), null); - final content = File( - p.join(tempDir.path, 'crash.log'), - ).readAsStringSync(); - expect(content, contains('Platform:')); - }); - - test('written content contains Dart version', () { - CrashLogger.report(Exception('test'), null); - final content = File( - p.join(tempDir.path, 'crash.log'), - ).readAsStringSync(); - expect(content, contains('Dart:')); - }); - - test('written content includes the error message', () { - CrashLogger.report(Exception('specific_error_XYZ'), null); - final content = File( - p.join(tempDir.path, 'crash.log'), - ).readAsStringSync(); - expect(content, contains('specific_error_XYZ')); - }); - - test('written content includes context when provided', () { - CrashLogger.report(Exception('e'), null, context: 'myContext'); - final content = File( - p.join(tempDir.path, 'crash.log'), - ).readAsStringSync(); - expect(content, contains('myContext')); - }); - - test('written content omits context line when context is empty', () { - CrashLogger.report(Exception('e'), null); - final content = File( - p.join(tempDir.path, 'crash.log'), - ).readAsStringSync(); - expect(content, isNot(contains('Context:'))); - }); - - test('written content includes stack trace when provided', () { - final stack = StackTrace.fromString('frame at crash_logger_test.dart:1'); - CrashLogger.report(Exception('e'), stack); - final content = File( - p.join(tempDir.path, 'crash.log'), - ).readAsStringSync(); - expect(content, contains('Stack:')); - expect(content, contains('crash_logger_test.dart')); - }); - - test('multiple reports are appended', () { - CrashLogger.report(Exception('first'), null); - CrashLogger.report(Exception('second'), null); - final content = File( - p.join(tempDir.path, 'crash.log'), - ).readAsStringSync(); - expect(content, contains('first')); - expect(content, contains('second')); - }); - - test('overridePath writes to custom path', () { - final custom = p.join(tempDir.path, 'custom.log'); - CrashLogger.report(Exception('override'), null, overridePath: custom); - expect(File(custom).existsSync(), isTrue); - final content = File(custom).readAsStringSync(); - expect(content, contains('override')); - expect(File(p.join(tempDir.path, 'crash.log')).existsSync(), isFalse); - }); - - test('truncates file when it exceeds max size', () { - final path = p.join(tempDir.path, 'crash.log'); - final filler = 'x' * (512 * 1024 + 1); - File(path).writeAsStringSync(filler); - CrashLogger.report(Exception('after_truncate'), null); - final content = File(path).readAsStringSync(); - expect(content, isNot(contains('x' * 100))); - expect(content, contains('after_truncate')); - }); - - test( - 'does not throw when filePath is null and bootstrap path unavailable', - () { - CrashLogger.initialize(''); - expect( - () => CrashLogger.report(Exception('e'), null, overridePath: null), - returnsNormally, - ); - }, - ); - }); - - group('CrashLogger.redact — HOME substitution', () { - test('replaces USERPROFILE/HOME value with ', () { - final home = - Platform.environment['USERPROFILE'] ?? - Platform.environment['HOME'] ?? - ''; - if (home.isEmpty) return; - final input = 'path is $home\\something'; - expect(CrashLogger.redact(input), isNot(contains(home))); - expect(CrashLogger.redact(input), contains('')); - }); - - test('does not modify string with no sensitive data', () { - expect(CrashLogger.redact('hello world'), equals('hello world')); - }); - }); - - group('CrashLogger.redact — username substitution', () { - final username = - Platform.environment['USERNAME'] ?? Platform.environment['USER'] ?? ''; - - test('replaces /home/ on posix-style paths', () { - if (username.isEmpty || username.length <= 1) return; - final input = '/home/$username/config/file.db'; - expect(CrashLogger.redact(input), isNot(contains('/home/$username'))); - }); - - test('replaces /Users/ on macOS-style paths', () { - if (username.isEmpty || username.length <= 1) return; - final input = '/Users/$username/Library/file.db'; - expect(CrashLogger.redact(input), isNot(contains('/Users/$username'))); - }); - - test('replaces \\Users\\ on Windows-style paths', () { - if (username.isEmpty || username.length <= 1) return; - final input = 'C:\\Users\\$username\\AppData\\file.db'; - expect(CrashLogger.redact(input), isNot(contains('\\$username\\'))); - }); - - test('does not crash on empty string', () { - expect(() => CrashLogger.redact(''), returnsNormally); - expect(CrashLogger.redact(''), equals('')); - }); - }); - - group('CrashLogger.redact — email', () { - test('replaces plain email address', () { - final result = CrashLogger.redact('contact user@example.com for help'); - expect(result, contains('')); - expect(result, isNot(contains('user@example.com'))); - }); - - test('replaces email in a stack trace line', () { - const line = 'Exception: auth failed for admin@corp.io at line 42'; - final result = CrashLogger.redact(line); - expect(result, contains('')); - expect(result, isNot(contains('admin@corp.io'))); - }); - - test('replaces multiple emails in one string', () { - const input = 'a@a.com and b@b.org both failed'; - final result = CrashLogger.redact(input); - expect(result, isNot(contains('@'))); - }); - - test('does not alter strings without an @', () { - const input = 'no email here, just text'; - expect(CrashLogger.redact(input), equals(input)); - }); - - test('does not treat non-email @ as email', () { - const input = '@handle is not an email'; - expect(CrashLogger.redact(input), equals(input)); - }); - }); - - group('CrashLogger.redact — no false positives', () { - test('preserves normal log lines', () { - const line = '[12:00:01.123] [INFO] Bootstrap: CopyPaste 2.0 starting'; - expect(CrashLogger.redact(line), equals(line)); - }); - - test('preserves error codes and hex addresses', () { - const line = 'Error 0x80070005 at kernel32.dll+0x1234'; - expect(CrashLogger.redact(line), equals(line)); - }); - }); -} diff --git a/core/test/image_processing_queue_test.dart b/core/test/image_processing_queue_test.dart deleted file mode 100644 index 4fddd250..00000000 --- a/core/test/image_processing_queue_test.dart +++ /dev/null @@ -1,250 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:core/models/card_color.dart'; -import 'package:core/models/clipboard_content_type.dart'; -import 'package:core/models/clipboard_item.dart'; -import 'package:core/repository/i_clipboard_repository.dart'; -import 'package:core/services/image_processing_queue.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:image/image.dart' as img; -import 'package:path/path.dart' as p; - -class _RecordingRepo implements IClipboardRepository { - final updates = []; - - @override - Future update(ClipboardItem item) async { - updates.add(item); - } - - // Unused in these tests. - @override - Future save(ClipboardItem item) async {} - @override - Future getById(String id) async => null; - @override - Future getLatest() async => null; - @override - Future findByContentAndType( - String content, - ClipboardContentType type, - ) async => null; - @override - Future findByContentHash(String contentHash) async => null; - @override - Future> getAll() async => const []; - @override - Future delete(String id) async {} - @override - Future clearOldItems(int days, {bool excludePinned = true}) async => 0; - @override - Future deleteAllUnpinned() async => 0; - @override - Future count() async => 0; - @override - Future> search( - String query, { - int limit = 50, - int skip = 0, - }) async => const []; - @override - Future> searchAdvanced({ - String? query, - List? types, - List? colors, - bool? isPinned, - required int limit, - required int skip, - }) async => const []; - @override - Future> getImagePaths() async => const []; - @override - Future> getThumbPaths() async => const []; - @override - Future walCheckpoint() async {} - @override - Future close() async {} -} - -Uint8List _smallPng() { - final image = img.Image(width: 32, height: 32); - for (var x = 0; x < 32; x++) { - for (var y = 0; y < 32; y++) { - image.setPixelRgb(x, y, x * 8, y * 8, 64); - } - } - return Uint8List.fromList(img.encodePng(image)); -} - -ClipboardItem _item(String id) => - ClipboardItem(id: id, content: '$id.bmp', type: ClipboardContentType.image); - -void main() { - late Directory tempDir; - late _RecordingRepo repo; - late ImageProcessingQueue queue; - - setUp(() { - tempDir = Directory.systemTemp.createTempSync('img_queue_test_'); - repo = _RecordingRepo(); - queue = ImageProcessingQueue(repository: repo); - }); - - tearDown(() async { - await queue.dispose(); - if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); - }); - - group('ImageProcessingQueue.getMaxImageBytes (PR #10)', () { - test('drops job when input exceeds cap', () async { - final bytes = _smallPng(); - queue.getMaxImageBytes = () => bytes.length - 1; // strict cap - - queue.enqueue( - item: _item('drop-me'), - imageBytes: bytes, - imagesPath: tempDir.path, - ); - - // Give the isolate time to (not) run. - await Future.delayed(const Duration(milliseconds: 200)); - expect( - repo.updates, - isEmpty, - reason: 'queue must skip oversized buffers without invoking the repo', - ); - }); - - test('processes job when input is under cap', () async { - final bytes = _smallPng(); - queue.getMaxImageBytes = () => bytes.length + 1024; - - queue.enqueue( - item: _item('keep-me'), - imageBytes: bytes, - imagesPath: tempDir.path, - ); - - // The isolate writes a real PNG; wait for it to complete. - await Future.delayed(const Duration(seconds: 3)); - expect(repo.updates, isNotEmpty); - final pngPath = p.join(tempDir.path, 'keep-me.png'); - expect(File(pngPath).existsSync(), isTrue); - }); - - test('cap of 0 disables the gate (bytes flow through)', () async { - final bytes = _smallPng(); - queue.getMaxImageBytes = () => 0; - - queue.enqueue( - item: _item('zero-cap'), - imageBytes: bytes, - imagesPath: tempDir.path, - ); - - await Future.delayed(const Duration(seconds: 3)); - expect(repo.updates, isNotEmpty); - }); - }); - - group('ImageProcessingQueue depth warning', () { - test('logs warn when more than 10 items are pending', () async { - // Enqueue 12 items synchronously — first starts asynchronously while - // items 2-12 accumulate. When item 12 is added, _queue.length > 10 - // triggers AppLogger.warn (line 81). - for (var i = 0; i < 12; i++) { - queue.enqueue( - item: _item('depth-warn-$i'), - imageBytes: _smallPng(), - imagesPath: tempDir.path, - ); - } - // Just verify no exception was thrown and items were accepted. - await Future.delayed(const Duration(seconds: 4)); - expect(repo.updates.length, greaterThanOrEqualTo(12)); - }); - }); - - group('ImageProcessingQueue timeout', () { - test('TimeoutException is handled and no update is emitted', () async { - final slowRepo = _RecordingRepo(); - final slowQueue = ImageProcessingQueue( - repository: slowRepo, - jobTimeout: const Duration(milliseconds: 100), - ); - - // Pass valid PNG bytes but a non-existent imagesPath. - // ImageProcessor.processSync decodes OK, then throws FileSystemException - // on File.writeAsBytesSync — the isolate exits without sending a result. - // resultCompleter never completes → timeout fires after 100ms. - slowQueue.enqueue( - item: _item('slow'), - imageBytes: _smallPng(), - imagesPath: '/nonexistent_copypaste_timeout_test_path', - ); - - // Wait enough for the 100ms timeout to fire. - await Future.delayed(const Duration(milliseconds: 500)); - // Timeout fired → no update recorded for the item. - expect(slowRepo.updates.where((u) => u.id == 'slow'), isEmpty); - }); - }); - - group('ImageProcessingQueue deleteOwned', () { - late Directory dir; - - setUp(() => dir = Directory.systemTemp.createTempSync('img_delete_')); - tearDown(() => dir.deleteSync(recursive: true)); - - test('retries a locked file and gives up without throwing', () async { - final target = File(p.join(dir.path, 'locked.bmp')) - ..writeAsBytesSync([1]); - var attempts = 0; - - await ImageProcessingQueue.deleteOwned( - target.path, - dir.path, - delete: (_) { - attempts++; - throw const FileSystemException('held by another process'); - }, - ); - - expect(attempts, 3); - expect(target.existsSync(), isTrue); - }); - - test('succeeds when a later attempt gets the handle', () async { - final target = File(p.join(dir.path, 'transient.bmp')) - ..writeAsBytesSync([1]); - var attempts = 0; - - await ImageProcessingQueue.deleteOwned( - target.path, - dir.path, - delete: (file) { - attempts++; - if (attempts == 1) { - throw const FileSystemException('held by another process'); - } - file.deleteSync(); - }, - ); - - expect(attempts, 2); - expect(target.existsSync(), isFalse); - }); - - test('refuses paths outside the images directory', () async { - var called = false; - await ImageProcessingQueue.deleteOwned( - p.join(dir.parent.path, 'outside.bmp'), - dir.path, - delete: (_) => called = true, - ); - - expect(called, isFalse); - }); - }); -} diff --git a/core/test/image_processor_test.dart b/core/test/image_processor_test.dart deleted file mode 100644 index 515ad263..00000000 --- a/core/test/image_processor_test.dart +++ /dev/null @@ -1,258 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:image/image.dart' as img; - -import 'package:core/services/image_processor.dart'; - -void main() { - late Directory tempDir; - - setUp(() { - tempDir = Directory.systemTemp.createTempSync('img_proc_test_'); - }); - - tearDown(() => tempDir.deleteSync(recursive: true)); - - Uint8List makeTestPng({int width = 4, int height = 4}) { - final image = img.Image(width: width, height: height); - for (var x = 0; x < width; x++) { - for (var y = 0; y < height; y++) { - image.setPixelRgb(x, y, 255, 0, 0); - } - } - return Uint8List.fromList(img.encodePng(image)); - } - - group('ImageProcessResult', () { - test('stores all properties correctly', () { - const result = ImageProcessResult( - imagePath: '/tmp/test.png', - width: 1920, - height: 1080, - fileSize: 98765, - ); - expect(result.imagePath, equals('/tmp/test.png')); - expect(result.width, equals(1920)); - expect(result.height, equals(1080)); - expect(result.fileSize, equals(98765)); - }); - }); - - group('ImageProcessor.processAndSave', () { - test('returns result with correct dimensions for valid PNG', () async { - final pngBytes = makeTestPng(width: 4, height: 4); - - final result = await ImageProcessor.processAndSave( - imageBytes: pngBytes, - id: 'test-id', - imagesDir: tempDir.path, - ); - - expect(result, isNotNull); - expect(result!.width, equals(4)); - expect(result.height, equals(4)); - expect(result.fileSize, greaterThan(0)); - }); - - test('saves PNG file to imagesDir', () async { - final pngBytes = makeTestPng(width: 2, height: 2); - - final result = await ImageProcessor.processAndSave( - imageBytes: pngBytes, - id: 'saved-image', - imagesDir: tempDir.path, - ); - - expect(result, isNotNull); - expect(File(result!.imagePath).existsSync(), isTrue); - expect(result.imagePath, endsWith('saved-image.png')); - }); - - test('saved file is valid PNG', () async { - final pngBytes = makeTestPng(width: 3, height: 3); - - final result = await ImageProcessor.processAndSave( - imageBytes: pngBytes, - id: 'valid-png', - imagesDir: tempDir.path, - ); - - expect(result, isNotNull); - final savedBytes = File(result!.imagePath).readAsBytesSync(); - expect(savedBytes.length, greaterThan(0)); - // PNG files start with PNG magic bytes - expect(savedBytes[0], equals(0x89)); - expect(savedBytes[1], equals(0x50)); // P - expect(savedBytes[2], equals(0x4E)); // N - expect(savedBytes[3], equals(0x47)); // G - }); - - test('returns null for invalid image bytes', () async { - final result = await ImageProcessor.processAndSave( - imageBytes: Uint8List.fromList([1, 2, 3, 4, 5]), - id: 'bad-image', - imagesDir: tempDir.path, - ); - - expect(result, isNull); - }); - - test('returns null for empty bytes', () async { - final result = await ImageProcessor.processAndSave( - imageBytes: Uint8List(0), - id: 'empty-image', - imagesDir: tempDir.path, - ); - - expect(result, isNull); - }); - - test('imagePath contains the provided id', () async { - final pngBytes = makeTestPng(); - - final result = await ImageProcessor.processAndSave( - imageBytes: pngBytes, - id: 'my-custom-id', - imagesDir: tempDir.path, - ); - - expect(result, isNotNull); - expect(result!.imagePath, contains('my-custom-id')); - }); - - test('fileSize matches actual saved file size', () async { - final pngBytes = makeTestPng(width: 8, height: 8); - - final result = await ImageProcessor.processAndSave( - imageBytes: pngBytes, - id: 'size-check', - imagesDir: tempDir.path, - ); - - expect(result, isNotNull); - final savedSize = File(result!.imagePath).lengthSync(); - expect(result.fileSize, equals(savedSize)); - }); - }); - - group('alpha normalization', () { - test('all-transparent RGBA8 pixels are opaquified with RGB preserved', () { - final src = img.Image(width: 4, height: 4, numChannels: 4); - for (final px in src) { - px.r = 100; - px.g = 150; - px.b = 200; - px.a = 0; - } - final inputBytes = Uint8List.fromList(img.encodePng(src)); - - final result = ImageProcessor.processSync( - imageBytes: inputBytes, - id: 'all-transparent', - imagesDir: tempDir.path, - ); - - expect(result, isNotNull); - final decoded = img.decodeImage( - File(result!.imagePath).readAsBytesSync(), - ); - expect(decoded, isNotNull); - for (final px in decoded!) { - expect(px.a, equals(255)); - expect(px.r, equals(100)); - expect(px.g, equals(150)); - expect(px.b, equals(200)); - } - }); - - test('partial-transparency RGBA8 alpha distribution is preserved', () { - final src = img.Image(width: 4, height: 4, numChannels: 4); - var i = 0; - for (final px in src) { - px.r = 80; - px.g = 80; - px.b = 80; - px.a = switch (i % 3) { - 0 => 0, - 1 => 128, - _ => 255, - }; - i++; - } - final inputBytes = Uint8List.fromList(img.encodePng(src)); - - final result = ImageProcessor.processSync( - imageBytes: inputBytes, - id: 'partial-transparent', - imagesDir: tempDir.path, - ); - - expect(result, isNotNull); - final decoded = img.decodeImage( - File(result!.imagePath).readAsBytesSync(), - ); - expect(decoded, isNotNull); - final alphas = decoded!.map((px) => px.a).toList(); - expect(alphas.where((a) => a == 0).length, greaterThan(0)); - expect(alphas.where((a) => a == 128).length, greaterThan(0)); - expect(alphas.where((a) => a == 255).length, greaterThan(0)); - }); - - test( - 'all-transparent RGBA16 alpha is not mutated (bitsPerChannel guard)', - () { - final src = img.Image( - width: 4, - height: 4, - numChannels: 4, - format: img.Format.uint16, - ); - for (final px in src) { - px.r = 1000; - px.g = 2000; - px.b = 3000; - px.a = 0; - } - final inputBytes = Uint8List.fromList(img.encodePng(src)); - - final result = ImageProcessor.processSync( - imageBytes: inputBytes, - id: 'rgba16-transparent', - imagesDir: tempDir.path, - ); - - expect(result, isNotNull); - final decoded = img.decodeImage( - File(result!.imagePath).readAsBytesSync(), - ); - expect(decoded, isNotNull); - for (final px in decoded!) { - expect(px.a, equals(0)); - } - }, - ); - - test('RGB8 (no alpha channel) round-trips with correct dimensions', () { - final src = img.Image(width: 4, height: 4); - for (final px in src) { - px.r = 10; - px.g = 20; - px.b = 30; - } - final inputBytes = Uint8List.fromList(img.encodePng(src)); - - final result = ImageProcessor.processSync( - imageBytes: inputBytes, - id: 'rgb-no-alpha', - imagesDir: tempDir.path, - ); - - expect(result, isNotNull); - expect(result!.width, equals(4)); - expect(result.height, equals(4)); - expect(File(result.imagePath).existsSync(), isTrue); - }); - }); -} diff --git a/core/test/native_thumbnail_provider_test.dart b/core/test/native_thumbnail_provider_test.dart deleted file mode 100644 index f67c33c7..00000000 --- a/core/test/native_thumbnail_provider_test.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:core/core.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:image/image.dart' as img; -import 'package:path/path.dart' as p; - -/// Test stub: returns whatever bytes the test sets, records each call. -class _StubProvider implements NativeThumbnailProvider { - Uint8List? bytes; - bool throwIt = false; - Duration? delay; - final List<({String path, int sizePx})> calls = []; - - @override - Future request(String path, {int sizePx = 256}) async { - calls.add((path: path, sizePx: sizePx)); - if (delay != null) await Future.delayed(delay!); - if (throwIt) throw StateError('boom'); - return bytes; - } -} - -Uint8List _validPng() { - final image = img.Image(width: 4, height: 4); - return Uint8List.fromList(img.encodePng(image)); -} - -Future _waitForThumb( - SqliteRepository repo, - String id, { - Duration timeout = const Duration(seconds: 3), -}) async { - final deadline = DateTime.now().add(timeout); - while (DateTime.now().isBefore(deadline)) { - final item = await repo.getById(id); - if (item?.thumbPath != null) return item!; - await Future.delayed(const Duration(milliseconds: 25)); - } - throw TimeoutException('thumbPath was never set for $id'); -} - -void main() { - group('NoopNativeThumbnailProvider', () { - test('always returns null', () async { - const provider = NoopNativeThumbnailProvider(); - final result = await provider.request('whatever', sizePx: 256); - expect(result, isNull); - }); - }); - - group('ClipboardService with NativeThumbnailProvider', () { - late SqliteRepository repo; - late Directory tmp; - late String imagesPath; - - setUp(() async { - repo = SqliteRepository.inMemory(); - tmp = await Directory.systemTemp.createTemp('cp_native_thumb_'); - imagesPath = p.join(tmp.path, 'images'); - await Directory(imagesPath).create(recursive: true); - }); - - tearDown(() async { - await repo.close(); - if (tmp.existsSync()) await tmp.delete(recursive: true); - }); - - test('writes native bytes as _thumb.png for video items', () async { - final stub = _StubProvider()..bytes = _validPng(); - final service = ClipboardService( - repo, - imagesPath: imagesPath, - nativeThumbnailProvider: stub, - ); - addTearDown(service.dispose); - - // External video file (path must exist + be outside imagesPath). - final videoPath = p.join(tmp.path, 'sample.mp4'); - await File(videoPath).writeAsBytes([0, 1, 2, 3, 4, 5]); - - final item = await service.processFiles([ - videoPath, - ], ClipboardContentType.video); - expect(item, isNotNull); - - final updated = await _waitForThumb(repo, item!.id); - expect(updated.thumbPath, isNotNull); - expect(File(updated.thumbPath!).existsSync(), isTrue); - expect(p.dirname(updated.thumbPath!), equals(imagesPath)); - expect(stub.calls, hasLength(1)); - expect(stub.calls.single.path, equals(videoPath)); - }); - - test('does not enqueue when nativeProvider is absent (audio)', () async { - final service = ClipboardService(repo, imagesPath: imagesPath); - addTearDown(service.dispose); - - final audioPath = p.join(tmp.path, 'jingle.mp3'); - await File(audioPath).writeAsBytes([0, 1, 2]); - - final item = await service.processFiles([ - audioPath, - ], ClipboardContentType.audio); - expect(item, isNotNull); - - // Give the queue a moment in case it would have run. - await Future.delayed(const Duration(milliseconds: 200)); - final fetched = await repo.getById(item!.id); - expect(fetched?.thumbPath, isNull); - }); - - test( - 'falls back to Dart pipeline when native returns null on image', - () async { - final stub = _StubProvider()..bytes = null; - final service = ClipboardService( - repo, - imagesPath: imagesPath, - nativeThumbnailProvider: stub, - ); - addTearDown(service.dispose); - - final imagePath = p.join(tmp.path, 'pic.png'); - await File(imagePath).writeAsBytes(_validPng()); - - final item = await service.processFiles([ - imagePath, - ], ClipboardContentType.image); - expect(item, isNotNull); - - final updated = await _waitForThumb(repo, item!.id); - expect(updated.thumbPath, isNotNull); - expect(File(updated.thumbPath!).existsSync(), isTrue); - expect(stub.calls, hasLength(1)); - }, - ); - - test('swallows native provider errors and falls back', () async { - final stub = _StubProvider()..throwIt = true; - final service = ClipboardService( - repo, - imagesPath: imagesPath, - nativeThumbnailProvider: stub, - ); - addTearDown(service.dispose); - - final imagePath = p.join(tmp.path, 'pic2.png'); - await File(imagePath).writeAsBytes(_validPng()); - - final item = await service.processFiles([ - imagePath, - ], ClipboardContentType.image); - expect(item, isNotNull); - - final updated = await _waitForThumb(repo, item!.id); - expect(updated.thumbPath, isNotNull); - expect(stub.calls, hasLength(1)); - }); - }); -} diff --git a/core/test/repository_search_integration_test.dart b/core/test/repository_search_integration_test.dart deleted file mode 100644 index 56f3ed58..00000000 --- a/core/test/repository_search_integration_test.dart +++ /dev/null @@ -1,393 +0,0 @@ -/// Cross-platform repository search integration tests. -/// Verifies that FTS5, LIKE fallback, and Unicode normalization work correctly -/// across Windows and macOS — all using the in-memory SQLite instance. -library; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:core/core.dart'; - -void main() { - late SqliteRepository repo; - - setUp(() { - repo = SqliteRepository.inMemory(); - }); - - tearDown(() => repo.close()); - - group('Repository search – FTS5 path', () { - test('finds item by exact word', () async { - await repo.save( - ClipboardItem( - content: 'flutter desktop app', - type: ClipboardContentType.text, - ), - ); - await repo.save( - ClipboardItem( - content: 'mobile development', - type: ClipboardContentType.text, - ), - ); - - final results = await repo.searchAdvanced( - query: 'flutter', - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.content, contains('flutter')); - }); - - test('finds item by prefix (FTS5 prefix query)', () async { - await repo.save( - ClipboardItem( - content: 'clipboard manager', - type: ClipboardContentType.text, - ), - ); - await repo.save( - ClipboardItem( - content: 'clipboard history', - type: ClipboardContentType.text, - ), - ); - await repo.save( - ClipboardItem( - content: 'unrelated content', - type: ClipboardContentType.text, - ), - ); - - final results = await repo.searchAdvanced( - query: 'clipboard', - limit: 50, - skip: 0, - ); - expect(results.length, equals(2)); - }); - - test('search is case-insensitive', () async { - await repo.save( - ClipboardItem(content: 'Hello World', type: ClipboardContentType.text), - ); - - final lower = await repo.searchAdvanced( - query: 'hello', - limit: 50, - skip: 0, - ); - final upper = await repo.searchAdvanced( - query: 'HELLO', - limit: 50, - skip: 0, - ); - expect(lower.length, equals(1)); - expect(upper.length, equals(1)); - }); - - test('returns empty when no match', () async { - await repo.save( - ClipboardItem(content: 'some content', type: ClipboardContentType.text), - ); - - final results = await repo.searchAdvanced( - query: 'zxqvnomatch', - limit: 50, - skip: 0, - ); - expect(results, isEmpty); - }); - }); - - group('Repository search – LIKE fallback path (symbol queries)', () { - test('dot query matches file extensions', () async { - await repo.save( - ClipboardItem( - content: '/home/user/document.pdf', - type: ClipboardContentType.file, - ), - ); - await repo.save( - ClipboardItem( - content: '/home/user/image.jpg', - type: ClipboardContentType.file, - ), - ); - await repo.save( - ClipboardItem(content: 'plain text', type: ClipboardContentType.text), - ); - - final results = await repo.searchAdvanced( - query: '.pdf', - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.content, contains('.pdf')); - }); - - test('at-sign query finds email addresses', () async { - await repo.save( - ClipboardItem( - content: 'user@gmail.com', - type: ClipboardContentType.email, - ), - ); - await repo.save( - ClipboardItem( - content: 'admin@company.io', - type: ClipboardContentType.email, - ), - ); - await repo.save( - ClipboardItem( - content: 'no email here', - type: ClipboardContentType.text, - ), - ); - - final results = await repo.searchAdvanced(query: '@', limit: 50, skip: 0); - expect(results.length, equals(2)); - for (final item in results) { - expect(item.content, contains('@')); - } - }); - - test('hyphen query matches UUIDs and phone numbers', () async { - await repo.save( - ClipboardItem( - content: '550e8400-e29b-41d4-a716-446655440000', - type: ClipboardContentType.uuid, - ), - ); - await repo.save( - ClipboardItem( - content: '+1-800-555-0100', - type: ClipboardContentType.phone, - ), - ); - await repo.save( - ClipboardItem(content: 'no hyphens', type: ClipboardContentType.text), - ); - - final results = await repo.searchAdvanced(query: '-', limit: 50, skip: 0); - expect(results.length, greaterThanOrEqualTo(2)); - }); - - test('symbol query paginates correctly', () async { - for (var i = 0; i < 7; i++) { - await repo.save( - ClipboardItem( - content: 'file$i@example.com', - type: ClipboardContentType.email, - ), - ); - } - for (var i = 0; i < 3; i++) { - await repo.save( - ClipboardItem(content: 'no-at-$i', type: ClipboardContentType.text), - ); - } - - final page1 = await repo.searchAdvanced(query: '@', limit: 4, skip: 0); - final page2 = await repo.searchAdvanced(query: '@', limit: 4, skip: 4); - - expect(page1.length, equals(4)); - expect(page2.length, equals(3)); - for (final item in [...page1, ...page2]) { - expect(item.content, contains('@')); - } - }); - }); - - group('Repository search – combined filters', () { - test('query + type filter returns precise results', () async { - await repo.save( - ClipboardItem( - content: 'python script', - type: ClipboardContentType.text, - ), - ); - await repo.save( - ClipboardItem(content: 'python link', type: ClipboardContentType.link), - ); - await repo.save( - ClipboardItem(content: 'ruby script', type: ClipboardContentType.text), - ); - - final results = await repo.searchAdvanced( - query: 'python', - types: [ClipboardContentType.text], - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.content, equals('python script')); - }); - - test('query + color filter', () async { - final colored = ClipboardItem( - content: 'important note', - type: ClipboardContentType.text, - cardColor: CardColor.red, - ); - final plain = ClipboardItem( - content: 'important update', - type: ClipboardContentType.text, - ); - await repo.save(colored); - await repo.save(plain); - - final results = await repo.searchAdvanced( - query: 'important', - colors: [CardColor.red], - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.id, equals(colored.id)); - }); - - test('pinned filter + query', () async { - final pinnedItem = ClipboardItem( - content: 'pinned secret', - type: ClipboardContentType.text, - isPinned: true, - ); - final normalItem = ClipboardItem( - content: 'normal secret', - type: ClipboardContentType.text, - isPinned: false, - ); - await repo.save(pinnedItem); - await repo.save(normalItem); - - final results = await repo.searchAdvanced( - query: 'secret', - isPinned: true, - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.isPinned, isTrue); - }); - - test('multiple type filter returns all matching types', () async { - await repo.save( - ClipboardItem( - content: 'my@email.com', - type: ClipboardContentType.email, - ), - ); - await repo.save( - ClipboardItem( - content: 'https://site.com', - type: ClipboardContentType.link, - ), - ); - await repo.save( - ClipboardItem(content: 'plain text', type: ClipboardContentType.text), - ); - - final results = await repo.searchAdvanced( - types: [ClipboardContentType.email, ClipboardContentType.link], - limit: 50, - skip: 0, - ); - expect(results.length, equals(2)); - for (final item in results) { - expect( - item.type == ClipboardContentType.email || - item.type == ClipboardContentType.link, - isTrue, - ); - } - }); - }); - - group('Repository search – ordering', () { - test('results are ordered by modifiedAt descending', () async { - final old = ClipboardItem( - content: 'older item', - type: ClipboardContentType.text, - modifiedAt: DateTime.utc(2023, 1, 1), - ); - final mid = ClipboardItem( - content: 'middle item', - type: ClipboardContentType.text, - modifiedAt: DateTime.utc(2024, 6, 15), - ); - final recent = ClipboardItem( - content: 'recent item', - type: ClipboardContentType.text, - modifiedAt: DateTime.utc(2025, 3, 1), - ); - await repo.save(old); - await repo.save(mid); - await repo.save(recent); - - final results = await repo.searchAdvanced(limit: 10, skip: 0); - expect(results[0].content, equals('recent item')); - expect(results[1].content, equals('middle item')); - expect(results[2].content, equals('older item')); - }); - }); - - group('Repository search – edge cases', () { - test('empty query returns all items ordered by date', () async { - await repo.save( - ClipboardItem(content: 'alpha', type: ClipboardContentType.text), - ); - await repo.save( - ClipboardItem(content: 'beta', type: ClipboardContentType.text), - ); - - final results = await repo.searchAdvanced(limit: 50, skip: 0); - expect(results.length, equals(2)); - }); - - test('null query returns all items', () async { - await repo.save( - ClipboardItem(content: 'item a', type: ClipboardContentType.text), - ); - final results = await repo.searchAdvanced( - query: null, - limit: 50, - skip: 0, - ); - expect(results, isNotEmpty); - }); - - test('query longer than content does not crash', () async { - await repo.save( - ClipboardItem(content: 'short', type: ClipboardContentType.text), - ); - final results = await repo.searchAdvanced( - query: 'a' * 500, - limit: 50, - skip: 0, - ); - expect(results, isEmpty); - }); - - test('search on empty database returns empty list', () async { - final results = await repo.searchAdvanced( - query: 'anything', - limit: 50, - skip: 0, - ); - expect(results, isEmpty); - }); - - test('skip beyond total items returns empty list', () async { - await repo.save( - ClipboardItem(content: 'only item', type: ClipboardContentType.text), - ); - - final results = await repo.searchAdvanced(limit: 10, skip: 100); - expect(results, isEmpty); - }); - }); -} diff --git a/core/test/repository_test.dart b/core/test/repository_test.dart deleted file mode 100644 index c5e40927..00000000 --- a/core/test/repository_test.dart +++ /dev/null @@ -1,558 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; - -void main() { - late SqliteRepository repo; - - setUp(() { - repo = SqliteRepository.inMemory(); - }); - - tearDown(() => repo.close()); - - group('SqliteRepository', () { - test('save and getById', () async { - final item = ClipboardItem( - content: 'hello', - type: ClipboardContentType.text, - ); - await repo.save(item); - final found = await repo.getById(item.id); - expect(found, isNotNull); - expect(found!.content, equals('hello')); - expect(found.type, equals(ClipboardContentType.text)); - }); - - test('update modifies existing item', () async { - final item = ClipboardItem( - content: 'original', - type: ClipboardContentType.text, - ); - await repo.save(item); - final updated = item.copyWith(isPinned: true); - await repo.update(updated); - final found = await repo.getById(item.id); - expect(found!.isPinned, isTrue); - }); - - test('delete removes item', () async { - final item = ClipboardItem( - content: 'delete me', - type: ClipboardContentType.text, - ); - await repo.save(item); - await repo.delete(item.id); - final found = await repo.getById(item.id); - expect(found, isNull); - }); - - test('findByContentAndType returns existing item', () async { - final item = ClipboardItem( - content: 'test content', - type: ClipboardContentType.text, - ); - await repo.save(item); - final found = await repo.findByContentAndType( - 'test content', - ClipboardContentType.text, - ); - expect(found, isNotNull); - expect(found!.id, equals(item.id)); - }); - - test('findByContentAndType returns null for wrong type', () async { - final item = ClipboardItem( - content: 'test', - type: ClipboardContentType.text, - ); - await repo.save(item); - final found = await repo.findByContentAndType( - 'test', - ClipboardContentType.image, - ); - expect(found, isNull); - }); - - test('findByContentHash returns matching item', () async { - final item = ClipboardItem( - content: '', - type: ClipboardContentType.image, - contentHash: 'abc123', - ); - await repo.save(item); - final found = await repo.findByContentHash('abc123'); - expect(found, isNotNull); - expect(found!.contentHash, equals('abc123')); - }); - - test('getAll returns items ordered by modifiedAt desc', () async { - final first = ClipboardItem( - content: 'first', - type: ClipboardContentType.text, - modifiedAt: DateTime.utc(2024, 1, 1), - ); - final second = ClipboardItem( - content: 'second', - type: ClipboardContentType.text, - modifiedAt: DateTime.utc(2024, 1, 2), - ); - await repo.save(first); - await repo.save(second); - final all = await repo.getAll(); - expect(all.first.content, equals('second')); - }); - - test('clearOldItems removes old non-pinned entries', () async { - final old = ClipboardItem( - content: 'old', - type: ClipboardContentType.text, - createdAt: DateTime.now().toUtc().subtract(const Duration(days: 35)), - modifiedAt: DateTime.now().toUtc().subtract(const Duration(days: 35)), - ); - final fresh = ClipboardItem( - content: 'fresh', - type: ClipboardContentType.text, - ); - await repo.save(old); - await repo.save(fresh); - final deleted = await repo.clearOldItems(30); - expect(deleted, equals(1)); - final remaining = await repo.getAll(); - expect(remaining.length, equals(1)); - expect(remaining.first.content, equals('fresh')); - }); - - test('clearOldItems preserves pinned items', () async { - final pinned = ClipboardItem( - content: 'pinned old', - type: ClipboardContentType.text, - isPinned: true, - createdAt: DateTime.now().toUtc().subtract(const Duration(days: 35)), - modifiedAt: DateTime.now().toUtc().subtract(const Duration(days: 35)), - ); - await repo.save(pinned); - final deleted = await repo.clearOldItems(30); - expect(deleted, equals(0)); - }); - - test('clearOldItems uses createdAt to determine age', () async { - final oldCreatedButRecentlyUsed = ClipboardItem( - content: 'recently used', - type: ClipboardContentType.text, - createdAt: DateTime.now().toUtc().subtract(const Duration(days: 60)), - modifiedAt: DateTime.now().toUtc(), - ); - await repo.save(oldCreatedButRecentlyUsed); - // Item was created 60 days ago — should be deleted even if recently modified - final deleted = await repo.clearOldItems(30); - expect(deleted, equals(1)); - - // Item created recently should be kept - final recentItem = ClipboardItem( - content: 'recent', - type: ClipboardContentType.text, - ); - await repo.save(recentItem); - final deleted2 = await repo.clearOldItems(30); - expect(deleted2, equals(0)); - final remaining = await repo.getAll(); - expect(remaining.length, equals(1)); - }); - - test('search finds items by content', () async { - await repo.save( - ClipboardItem(content: 'hello world', type: ClipboardContentType.text), - ); - await repo.save( - ClipboardItem(content: 'another item', type: ClipboardContentType.text), - ); - final results = await repo.search('hello'); - expect(results.length, equals(1)); - expect(results.first.content, equals('hello world')); - }); - - test('searchAdvanced filters by type', () async { - await repo.save( - ClipboardItem(content: 'text item', type: ClipboardContentType.text), - ); - await repo.save( - ClipboardItem(content: 'link item', type: ClipboardContentType.link), - ); - final results = await repo.searchAdvanced( - types: [ClipboardContentType.text], - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.type, equals(ClipboardContentType.text)); - }); - - test('searchAdvanced filters by color', () async { - await repo.save( - ClipboardItem( - content: 'red item', - type: ClipboardContentType.text, - cardColor: CardColor.red, - ), - ); - await repo.save( - ClipboardItem(content: 'no color', type: ClipboardContentType.text), - ); - final results = await repo.searchAdvanced( - colors: [CardColor.red], - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.cardColor, equals(CardColor.red)); - }); - - test('searchAdvanced filters by isPinned true', () async { - await repo.save( - ClipboardItem( - content: 'pinned item', - type: ClipboardContentType.text, - isPinned: true, - ), - ); - await repo.save( - ClipboardItem(content: 'normal item', type: ClipboardContentType.text), - ); - final results = await repo.searchAdvanced( - isPinned: true, - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.isPinned, isTrue); - }); - - test('searchAdvanced with query and type filter', () async { - await repo.save( - ClipboardItem(content: 'hello text', type: ClipboardContentType.text), - ); - await repo.save( - ClipboardItem(content: 'hello link', type: ClipboardContentType.link), - ); - await repo.save( - ClipboardItem(content: 'world text', type: ClipboardContentType.text), - ); - final results = await repo.searchAdvanced( - query: 'hello', - types: [ClipboardContentType.text], - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.content, equals('hello text')); - }); - - test( - 'searchAdvanced combines query, type, color and pin filters', - () async { - await repo.save( - ClipboardItem( - content: 'alpha target', - type: ClipboardContentType.text, - cardColor: CardColor.red, - isPinned: true, - ), - ); - await repo.save( - ClipboardItem( - content: 'alpha wrong color', - type: ClipboardContentType.text, - cardColor: CardColor.blue, - isPinned: true, - ), - ); - await repo.save( - ClipboardItem( - content: 'alpha wrong pin', - type: ClipboardContentType.text, - cardColor: CardColor.red, - isPinned: false, - ), - ); - await repo.save( - ClipboardItem( - content: 'alpha wrong type', - type: ClipboardContentType.link, - cardColor: CardColor.red, - isPinned: true, - ), - ); - - final results = await repo.searchAdvanced( - query: 'alpha', - types: [ClipboardContentType.text], - colors: [CardColor.red], - isPinned: true, - limit: 50, - skip: 0, - ); - - expect(results.length, equals(1)); - expect(results.first.content, equals('alpha target')); - }, - ); - - test( - 'searchAdvanced with symbol-only query finds matching items', - () async { - // FTS5 tokenizer splits on "@" — this must fall through to LIKE path - await repo.save( - ClipboardItem( - content: 'user@gmail.com', - type: ClipboardContentType.email, - ), - ); - await repo.save( - ClipboardItem( - content: 'no match here', - type: ClipboardContentType.text, - ), - ); - final results = await repo.searchAdvanced( - query: '@', - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.content, equals('user@gmail.com')); - }, - ); - - test( - 'searchAdvanced symbol query paginates correctly beyond page 1', - () async { - // Verify LIKE-only path paginates: page 2 must return results when - // there are more than `limit` matches - for (var i = 0; i < 5; i++) { - await repo.save( - ClipboardItem( - content: 'addr$i@example.com', - type: ClipboardContentType.email, - ), - ); - } - // Insert non-matching items to pad the table - for (var i = 0; i < 3; i++) { - await repo.save( - ClipboardItem( - content: 'no-at-sign-$i', - type: ClipboardContentType.text, - ), - ); - } - final page1 = await repo.searchAdvanced(query: '@', limit: 3, skip: 0); - final page2 = await repo.searchAdvanced(query: '@', limit: 3, skip: 3); - - expect(page1.length, equals(3)); - expect( - page2.length, - equals(2), - ); // 5 total "@" items, page 2 has the rest - // All returned items must contain "@" - for (final item in [...page1, ...page2]) { - expect(item.content, contains('@')); - } - }, - ); - - test('searchAdvanced dot query matches file paths', () async { - await repo.save( - ClipboardItem( - content: '/home/user/document.pdf', - type: ClipboardContentType.file, - ), - ); - await repo.save( - ClipboardItem(content: 'plain text', type: ClipboardContentType.text), - ); - final results = await repo.searchAdvanced( - query: '.pdf', - limit: 50, - skip: 0, - ); - expect(results.length, equals(1)); - expect(results.first.content, contains('.pdf')); - }); - - test('getLatest returns most recently modified item', () async { - final older = ClipboardItem( - content: 'older', - type: ClipboardContentType.text, - modifiedAt: DateTime.utc(2024, 1, 1), - ); - final newer = ClipboardItem( - content: 'newer', - type: ClipboardContentType.text, - modifiedAt: DateTime.utc(2024, 1, 2), - ); - await repo.save(older); - await repo.save(newer); - final latest = await repo.getLatest(); - expect(latest, isNotNull); - expect(latest!.content, equals('newer')); - }); - - test('getLatest returns null on empty repository', () async { - final latest = await repo.getLatest(); - expect(latest, isNull); - }); - - test('deleteAllUnpinned removes only non-pinned items', () async { - final pinned = ClipboardItem( - content: 'keep me', - type: ClipboardContentType.text, - isPinned: true, - ); - final unpinned = ClipboardItem( - content: 'delete me', - type: ClipboardContentType.text, - ); - await repo.save(pinned); - await repo.save(unpinned); - final deleted = await repo.deleteAllUnpinned(); - expect(deleted, equals(1)); - final remaining = await repo.getAll(); - expect(remaining.length, equals(1)); - expect(remaining.first.isPinned, isTrue); - }); - - test('count returns correct number of items', () async { - expect(await repo.count(), equals(0)); - await repo.save( - ClipboardItem(content: 'a', type: ClipboardContentType.text), - ); - await repo.save( - ClipboardItem(content: 'b', type: ClipboardContentType.text), - ); - expect(await repo.count(), equals(2)); - - final all = await repo.getAll(); - await repo.delete(all.first.id); - expect(await repo.count(), equals(1)); - }); - - test('getImagePaths returns content of image items', () async { - await repo.save( - ClipboardItem( - content: '/images/photo.png', - type: ClipboardContentType.image, - ), - ); - await repo.save( - ClipboardItem(content: 'text item', type: ClipboardContentType.text), - ); - final paths = await repo.getImagePaths(); - expect(paths.length, equals(1)); - expect(paths.first, equals('/images/photo.png')); - }); - - test('getImagePaths returns empty list when no images', () async { - await repo.save( - ClipboardItem(content: 'text', type: ClipboardContentType.text), - ); - final paths = await repo.getImagePaths(); - expect(paths, isEmpty); - }); - - test('walCheckpoint completes without error', () async { - await repo.save( - ClipboardItem(content: 'test', type: ClipboardContentType.text), - ); - await expectLater(repo.walCheckpoint(), completes); - }); - - test('searchAdvanced with skip paginates results', () async { - for (var i = 0; i < 5; i++) { - await repo.save( - ClipboardItem( - content: 'item $i', - type: ClipboardContentType.text, - modifiedAt: DateTime.utc(2024, 1, i + 1), - ), - ); - } - final page1 = await repo.searchAdvanced(limit: 3, skip: 0); - final page2 = await repo.searchAdvanced(limit: 3, skip: 3); - expect(page1.length, equals(3)); - expect(page2.length, equals(2)); - }); - - test('fromPath creates working repository with persisted data', () async { - final dir = Directory.systemTemp.createTempSync('repo_path_test_'); - try { - final dbPath = p.join(dir.path, 'test.db'); - final fileRepo = SqliteRepository.fromPath(dbPath); - final item = ClipboardItem( - content: 'persisted', - type: ClipboardContentType.text, - ); - await fileRepo.save(item); - final found = await fileRepo.getById(item.id); - expect(found, isNotNull); - expect(found!.content, equals('persisted')); - await fileRepo.close(); - expect(File(dbPath).existsSync(), isTrue); - } finally { - dir.deleteSync(recursive: true); - } - }); - - test( - 'clearOldItems triggers vacuum when more than 50 items deleted', - () async { - for (var i = 0; i < 55; i++) { - await repo.save( - ClipboardItem( - content: 'old item $i', - type: ClipboardContentType.text, - createdAt: DateTime.utc(2000, 1, 1), - modifiedAt: DateTime.utc(2000, 1, 1), - ), - ); - } - final deleted = await repo.clearOldItems(1); - expect(deleted, greaterThanOrEqualTo(55)); - }, - ); - - test( - 'deleteAllUnpinned triggers vacuum when more than 50 items deleted', - () async { - for (var i = 0; i < 55; i++) { - await repo.save( - ClipboardItem( - content: 'bulk item $i', - type: ClipboardContentType.text, - ), - ); - } - final deleted = await repo.deleteAllUnpinned(); - expect(deleted, greaterThanOrEqualTo(55)); - }, - ); - - test('clearOldItems respects excludePinned=false', () async { - await repo.save( - ClipboardItem( - content: 'pinned old', - type: ClipboardContentType.text, - isPinned: true, - createdAt: DateTime.utc(2000, 1, 1), - modifiedAt: DateTime.utc(2000, 1, 1), - ), - ); - // With excludePinned=false, pinned items should also be deleted - final deleted = await repo.clearOldItems(1, excludePinned: false); - expect(deleted, greaterThan(0)); - }); - }); -} diff --git a/core/test/search_helper_test.dart b/core/test/search_helper_test.dart deleted file mode 100644 index 98c60a33..00000000 --- a/core/test/search_helper_test.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:core/search/search_helper.dart'; - -void main() { - group('SearchHelper', () { - test('normalizes accented characters', () { - expect(SearchHelper.normalize('café'), equals('cafe')); - expect(SearchHelper.normalize('España'), equals('espana')); - expect(SearchHelper.normalize('piñata'), equals('pinata')); - expect(SearchHelper.normalize('naïve'), equals('naive')); - }); - - test('converts to lowercase', () { - expect(SearchHelper.normalize('Hello World'), equals('hello world')); - expect(SearchHelper.normalize('UPPER'), equals('upper')); - }); - - test('handles empty string', () { - expect(SearchHelper.normalize(''), equals('')); - }); - - test('handles already normalized text', () { - expect(SearchHelper.normalize('hello'), equals('hello')); - }); - - test('normalizes ligatures', () { - expect(SearchHelper.normalize('Straße'), equals('strasse')); - expect(SearchHelper.normalize('Ærodynamic'), equals('aerodynamic')); - expect(SearchHelper.normalize('œuvre'), equals('oeuvre')); - }); - - test('normalizes extended Latin characters', () { - expect(SearchHelper.normalize('Łódź'), equals('lodz')); - expect(SearchHelper.normalize('Česká'), equals('ceska')); - expect(SearchHelper.normalize('Kraków'), equals('krakow')); - expect(SearchHelper.normalize('Győr'), equals('gyor')); - expect(SearchHelper.normalize('Zürich'), equals('zurich')); - }); - }); -} diff --git a/core/test/sqlite_repository_migration_test.dart b/core/test/sqlite_repository_migration_test.dart deleted file mode 100644 index 8fb64be9..00000000 --- a/core/test/sqlite_repository_migration_test.dart +++ /dev/null @@ -1,203 +0,0 @@ -import 'dart:io'; - -import 'package:drift/drift.dart' hide isNotNull, isNull; -import 'package:drift/native.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; - -// --------------------------------------------------------------------------- -// Minimal test-only drift database used to create an old-schema SQLite file. -// It has schemaVersion=1 and creates only the original columns so that when -// SqliteRepository.fromPath opens the file it must run onUpgrade. -// --------------------------------------------------------------------------- -class _V1Database extends GeneratedDatabase { - _V1Database(super.e); - - @override - int get schemaVersion => 1; - - @override - Iterable> get allTables => const []; - - @override - MigrationStrategy get migration => MigrationStrategy( - onCreate: (m) async { - // Create only the v1 schema — no thumbPath, sourceModifiedAt, brokenSince. - await customStatement(''' - CREATE TABLE clipboard_items ( - id TEXT NOT NULL PRIMARY KEY, - content TEXT NOT NULL, - type INTEGER NOT NULL, - created_at INTEGER NOT NULL, - modified_at INTEGER NOT NULL, - app_source TEXT, - is_pinned INTEGER NOT NULL DEFAULT 0, - label TEXT, - card_color INTEGER NOT NULL DEFAULT 0, - metadata TEXT, - paste_count INTEGER NOT NULL DEFAULT 0, - content_hash TEXT - ) - '''); - }, - ); -} - -// --------------------------------------------------------------------------- -// Same but with schemaVersion=3 (has thumbPath + sourceModifiedAt, not -// brokenSince). Tests the from < 4 migration branch in isolation. -// --------------------------------------------------------------------------- -class _V3Database extends GeneratedDatabase { - _V3Database(super.e); - - @override - int get schemaVersion => 3; - - @override - Iterable> get allTables => const []; - - @override - MigrationStrategy get migration => MigrationStrategy( - onCreate: (m) async { - await customStatement(''' - CREATE TABLE clipboard_items ( - id TEXT NOT NULL PRIMARY KEY, - content TEXT NOT NULL, - type INTEGER NOT NULL, - created_at INTEGER NOT NULL, - modified_at INTEGER NOT NULL, - app_source TEXT, - is_pinned INTEGER NOT NULL DEFAULT 0, - label TEXT, - card_color INTEGER NOT NULL DEFAULT 0, - metadata TEXT, - paste_count INTEGER NOT NULL DEFAULT 0, - content_hash TEXT, - thumb_path TEXT, - source_modified_at INTEGER - ) - '''); - }, - ); -} - -void main() { - group('SqliteRepository schema migration', () { - test('migrates v1 → v4 and repository is fully functional', () async { - final dir = Directory.systemTemp.createTempSync('repo_migrate_v1_'); - try { - final dbPath = p.join(dir.path, 'v1.db'); - - // --- Step 1: create a v1 database file --- - final v1 = _V1Database(NativeDatabase(File(dbPath))); - // Force the DB to open by running a no-op query. - await v1.customStatement('SELECT 1'); - await v1.close(); - - // --- Step 2: open with the current SqliteRepository --- - final repo = SqliteRepository.fromPath(dbPath); - - // A simple query forces the LazyDatabase to open + run migration. - final count = await repo.count(); - expect(count, equals(0)); - - // Save an item that uses the new columns (v3 thumbPath, v4 brokenSince). - await repo.save( - ClipboardItem( - id: 'migrated', - content: 'hello after migration', - type: ClipboardContentType.text, - thumbPath: '/tmp/thumb.png', - brokenSince: null, - ), - ); - - final found = await repo.getById('migrated'); - expect(found, isNotNull); - expect(found!.content, equals('hello after migration')); - expect(found.thumbPath, equals('/tmp/thumb.png')); - - await repo.close(); - } finally { - dir.deleteSync(recursive: true); - } - }); - - test('migrates v3 → v4 and brokenSince column is accessible', () async { - final dir = Directory.systemTemp.createTempSync('repo_migrate_v3_'); - try { - final dbPath = p.join(dir.path, 'v3.db'); - - // --- Step 1: create a v3 database file --- - final v3 = _V3Database(NativeDatabase(File(dbPath))); - await v3.customStatement('SELECT 1'); - await v3.close(); - - // --- Step 2: open with the current SqliteRepository --- - final repo = SqliteRepository.fromPath(dbPath); - await repo.count(); // triggers migration - - final now = DateTime.now().toUtc(); - await repo.save( - ClipboardItem( - id: 'v3migrated', - content: 'from v3', - type: ClipboardContentType.text, - brokenSince: now, - ), - ); - - final found = await repo.getById('v3migrated'); - expect(found?.brokenSince, isNotNull); - - await repo.close(); - } finally { - dir.deleteSync(recursive: true); - } - }); - }); - - group('SqliteRepository.getThumbPaths', () { - test('returns only non-null non-empty thumbPaths', () async { - final repo = SqliteRepository.inMemory(); - try { - await repo.save( - ClipboardItem( - id: 'with-thumb', - content: '/img/photo.png', - type: ClipboardContentType.image, - thumbPath: '/thumbs/photo_thumb.png', - ), - ); - await repo.save( - ClipboardItem( - id: 'no-thumb', - content: 'hello', - type: ClipboardContentType.text, - ), - ); - - final paths = await repo.getThumbPaths(); - expect(paths.length, equals(1)); - expect(paths.first, equals('/thumbs/photo_thumb.png')); - } finally { - await repo.close(); - } - }); - - test('returns empty list when no items have thumbPath', () async { - final repo = SqliteRepository.inMemory(); - try { - await repo.save( - ClipboardItem(content: 'text', type: ClipboardContentType.text), - ); - final paths = await repo.getThumbPaths(); - expect(paths, isEmpty); - } finally { - await repo.close(); - } - }); - }); -} diff --git a/core/test/storage_config_test.dart b/core/test/storage_config_test.dart deleted file mode 100644 index 0252ee3c..00000000 --- a/core/test/storage_config_test.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:io'; - -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 config; - - setUp(() async { - tempDir = Directory.systemTemp.createTempSync('storage_test_'); - config = await StorageConfig.create(baseDir: tempDir.path); - }); - - tearDown(() => tempDir.deleteSync(recursive: true)); - - group('StorageConfig', () { - test('paths are derived from baseDir', () { - expect(config.baseDir, equals(tempDir.path)); - expect(config.databasePath, equals(p.join(tempDir.path, 'clipboard.db'))); - expect(config.imagesPath, equals(p.join(tempDir.path, 'images'))); - expect(config.configPath, equals(p.join(tempDir.path, 'config'))); - expect( - config.configFilePath, - equals(p.join(tempDir.path, 'config', AppConfig.fileName)), - ); - }); - - test('ensureDirectories creates all required directories', () async { - await config.ensureDirectories(); - expect(Directory(config.imagesPath).existsSync(), isTrue); - expect(Directory(config.configPath).existsSync(), isTrue); - }); - - test('isFirstRun is true before markAsInitialized', () { - expect(config.isFirstRun, isTrue); - }); - - test('markAsInitialized makes isFirstRun false', () { - config.markAsInitialized(); - expect(config.isFirstRun, isFalse); - }); - - test('cleanOrphanImages removes unlisted image files', () async { - await config.ensureDirectories(); - final keep = File(p.join(config.imagesPath, 'keep.png')) - ..writeAsBytesSync([1, 2, 3]); - final remove = File(p.join(config.imagesPath, 'remove.png')) - ..writeAsBytesSync([4, 5, 6]); - - config.cleanOrphanImages([keep.path]); - - expect(keep.existsSync(), isTrue); - expect(remove.existsSync(), isFalse); - }); - - test('cleanOrphanImages does not throw when directory is missing', () { - expect(() => config.cleanOrphanImages([]), returnsNormally); - }); - - test('logsPath is derived from baseDir', () { - expect(config.logsPath, equals(p.join(tempDir.path, 'logs'))); - }); - - test('markAsInitialized swallows an unwritable flag path', () { - Directory(p.join(tempDir.path, '.initialized')).createSync(); - - expect(() => config.markAsInitialized(), returnsNormally); - expect(config.isFirstRun, isTrue); - }); - - test( - 'ensureDirectories is idempotent and keeps existing content', - () async { - await config.ensureDirectories(); - final marker = File(p.join(config.imagesPath, 'kept.png')) - ..writeAsBytesSync([9]); - - await config.ensureDirectories(); - - expect(marker.existsSync(), isTrue); - expect(Directory(config.logsPath).existsSync(), isTrue); - }, - ); - - test('clearInitialized removes the init flag', () { - config.markAsInitialized(); - expect(config.isFirstRun, isFalse); - config.clearInitialized(); - expect(config.isFirstRun, isTrue); - }); - - test('clearInitialized is safe when flag does not exist', () { - expect(() => config.clearInitialized(), returnsNormally); - expect(config.isFirstRun, isTrue); - }); - - test('windowsLocalAppDataResolver overrides baseDir on Windows', () async { - if (!Platform.isWindows) return; - final customDir = Directory.systemTemp.createTempSync('resolver_test_'); - try { - final resolved = await StorageConfig.create( - windowsLocalAppDataResolver: () => customDir.path, - ); - expect(resolved.baseDir, equals(p.join(customDir.path, 'CopyPaste'))); - } finally { - customDir.deleteSync(recursive: true); - } - }); - - test( - 'windowsLocalAppDataResolver is ignored on non-Windows platforms', - () async { - if (Platform.isWindows) return; - var called = false; - await StorageConfig.create( - baseDir: tempDir.path, - windowsLocalAppDataResolver: () { - called = true; - return tempDir.path; - }, - ); - expect(called, isFalse); - }, - ); - }); -} diff --git a/core/test/support_service_test.dart b/core/test/support_service_test.dart deleted file mode 100644 index 5ad13782..00000000 --- a/core/test/support_service_test.dart +++ /dev/null @@ -1,249 +0,0 @@ -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 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 (_) { - // The shell opener may not be available in headless CI; that's acceptable - } - expect(Directory(storage.logsPath).existsSync(), isTrue); - }); - }); -} diff --git a/core/test/text_classifier_edge_cases_test.dart b/core/test/text_classifier_edge_cases_test.dart deleted file mode 100644 index 5e99b443..00000000 --- a/core/test/text_classifier_edge_cases_test.dart +++ /dev/null @@ -1,285 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:core/core.dart'; - -void main() { - group('TextClassifier edge cases', () { - group('multiline content always falls back to text or json', () { - test('multiline that starts with { but not valid JSON is text', () { - expect( - TextClassifier.classify('{ key: value\nno quotes }'), - equals(ClipboardContentType.text), - ); - }); - - test('multiline valid JSON object is json', () { - expect( - TextClassifier.classify('{\n "a": 1,\n "b": 2\n}'), - equals(ClipboardContentType.json), - ); - }); - - test('multiline that looks like email but has newline is text', () { - // newline prevents single-line email detection - expect( - TextClassifier.classify('user@example.com\nextra'), - equals(ClipboardContentType.text), - ); - }); - - test('UUID with only trailing newline is still uuid after trim()', () { - // classify() calls trim() first — trailing newline removed before - // the contains('\n') guard, so the UUID pattern is still matched. - expect( - TextClassifier.classify('550e8400-e29b-41d4-a716-446655440000\n'), - equals(ClipboardContentType.uuid), - ); - }); - - test('UUID embedded in multiline text is treated as text', () { - // A real newline in the middle means contains('\n') is true - expect( - TextClassifier.classify( - '550e8400-e29b-41d4-a716-446655440000\nmore text', - ), - equals(ClipboardContentType.text), - ); - }); - }); - - group('email edge cases', () { - test('email with underscore in local part', () { - expect( - TextClassifier.classify('first_last@company.org'), - equals(ClipboardContentType.email), - ); - }); - - test('email with dots in local part', () { - expect( - TextClassifier.classify('first.last@company.io'), - equals(ClipboardContentType.email), - ); - }); - - test('email with hyphen in domain', () { - expect( - TextClassifier.classify('user@my-company.com'), - equals(ClipboardContentType.email), - ); - }); - - test('text with @ but missing TLD is not email', () { - expect( - TextClassifier.classify('user@host'), - isNot(equals(ClipboardContentType.email)), - ); - }); - - test('text with multiple @ signs is not email', () { - expect( - TextClassifier.classify('user@@domain.com'), - isNot(equals(ClipboardContentType.email)), - ); - }); - }); - - group('color edge cases', () { - test('RGB with no spaces is color', () { - expect( - TextClassifier.classify('rgb(255,87,51)'), - equals(ClipboardContentType.color), - ); - }); - - test('HSL uppercase is color', () { - expect( - TextClassifier.classify('HSL(14, 100%, 51%)'), - equals(ClipboardContentType.color), - ); - }); - - test('7-digit hex is not a color', () { - expect( - TextClassifier.classify('#FF573'), - isNot(equals(ClipboardContentType.color)), - ); - }); - - test('hex without hash prefix is not color', () { - expect( - TextClassifier.classify('FF5733'), - isNot(equals(ClipboardContentType.color)), - ); - }); - - test('RGBA with decimal alpha is color', () { - expect( - TextClassifier.classify('rgba(10, 20, 30, 0.9)'), - equals(ClipboardContentType.color), - ); - }); - }); - - group('IP address edge cases', () { - test('leading zeros in octet — edge of valid range', () { - // 010.0.0.1 — parser accepts 01 as valid per regex - final result = TextClassifier.classify('010.0.0.1'); - // The regex accepts [01]?\d\d?, so 010 is matched as valid - expect(result, equals(ClipboardContentType.ip)); - }); - - test('single-octet number is not an IP', () { - expect( - TextClassifier.classify('192'), - isNot(equals(ClipboardContentType.ip)), - ); - }); - - test('IP with trailing dot is not valid', () { - expect( - TextClassifier.classify('192.168.1.1.'), - isNot(equals(ClipboardContentType.ip)), - ); - }); - - test('loopback 127.0.0.1 is IP', () { - expect( - TextClassifier.classify('127.0.0.1'), - equals(ClipboardContentType.ip), - ); - }); - }); - - group('UUID edge cases', () { - test('UUID with mixed case is uuid', () { - expect( - TextClassifier.classify('550E8400-e29b-41D4-A716-446655440000'), - equals(ClipboardContentType.uuid), - ); - }); - - test('UUID with wrong segment lengths is not uuid', () { - expect( - TextClassifier.classify('550e8400-e29b-41d4-a716-44665544000'), - isNot(equals(ClipboardContentType.uuid)), - ); - }); - - test('UUID with extra characters is not uuid', () { - expect( - TextClassifier.classify('550e8400-e29b-41d4-a716-4466554400001'), - isNot(equals(ClipboardContentType.uuid)), - ); - }); - }); - - group('JSON edge cases', () { - test('empty JSON object is json', () { - expect( - TextClassifier.classify('{}'), - equals(ClipboardContentType.json), - ); - }); - - test('empty JSON array is json', () { - expect( - TextClassifier.classify('[]'), - equals(ClipboardContentType.json), - ); - }); - - test('JSON with only numbers is json', () { - expect( - TextClassifier.classify('[1, 2, 3, 4]'), - equals(ClipboardContentType.json), - ); - }); - - test('JSON with boolean values is json', () { - expect( - TextClassifier.classify('{"active": true, "count": 0}'), - equals(ClipboardContentType.json), - ); - }); - - test('bare number is not json', () { - expect( - TextClassifier.classify('42'), - isNot(equals(ClipboardContentType.json)), - ); - }); - - test('bare string is not json', () { - expect( - TextClassifier.classify('"hello"'), - isNot(equals(ClipboardContentType.json)), - ); - }); - - test('JSON starting with [ but invalid is not json', () { - expect( - TextClassifier.classify('[unclosed'), - isNot(equals(ClipboardContentType.json)), - ); - }); - }); - - group('phone edge cases', () { - test('too-short phone is not phone', () { - expect( - TextClassifier.classify('+1 234'), - isNot(equals(ClipboardContentType.phone)), - ); - }); - - test('phone at exactly 7 digits (minimum) is phone', () { - expect( - TextClassifier.classify('+1 234 567'), - equals(ClipboardContentType.phone), - ); - }); - - test('phone with 14 digits (within 7–15 range) is phone', () { - // +1 234 567 890 1234 → digits: 12345678901234 = 14 digits, within range - expect( - TextClassifier.classify('+1 234 567 890 1234'), - equals(ClipboardContentType.phone), - ); - }); - - test('phone exceeding 15 digits (E.164 max) is not phone', () { - // 16 consecutive digits after + → _isPhone digit count check rejects it - // (regex matches because no spaces, but digit count > 15 fails) - expect( - TextClassifier.classify('+1234567890123456'), - isNot(equals(ClipboardContentType.phone)), - ); - }); - - test('area-code format without + still works', () { - expect( - TextClassifier.classify('(55) 2222-2222'), - equals(ClipboardContentType.phone), - ); - }); - }); - - group('whitespace handling', () { - test('content with only tabs is text', () { - expect( - TextClassifier.classify('\t\t\t'), - equals(ClipboardContentType.text), - ); - }); - - test('content with leading/trailing whitespace is trimmed', () { - // Email with surrounding spaces should still be detected - expect( - TextClassifier.classify(' user@example.com '), - equals(ClipboardContentType.email), - ); - }); - }); - }); -} diff --git a/core/test/text_classifier_test.dart b/core/test/text_classifier_test.dart deleted file mode 100644 index e743faf9..00000000 --- a/core/test/text_classifier_test.dart +++ /dev/null @@ -1,296 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:core/core.dart'; - -void main() { - group('TextClassifier.classify', () { - group('email', () { - test('detects standard email', () { - expect( - TextClassifier.classify('user@gmail.com'), - ClipboardContentType.email, - ); - }); - - test('detects email with subdomains', () { - expect( - TextClassifier.classify('user@mail.company.co.uk'), - ClipboardContentType.email, - ); - }); - - test('detects email with plus alias', () { - expect( - TextClassifier.classify('user+filter@proton.me'), - ClipboardContentType.email, - ); - }); - - test('does not classify plain text as email', () { - expect( - TextClassifier.classify('not an email'), - ClipboardContentType.text, - ); - }); - - test('does not classify incomplete email', () { - expect( - TextClassifier.classify('@gmail.com'), - ClipboardContentType.text, - ); - }); - }); - - group('phone', () { - test('detects international phone with +', () { - expect( - TextClassifier.classify('+56 9 1234 5678'), - ClipboardContentType.phone, - ); - }); - - test('detects phone with dashes', () { - expect( - TextClassifier.classify('+1-800-555-0100'), - ClipboardContentType.phone, - ); - }); - - test('detects phone with parentheses', () { - expect( - TextClassifier.classify('+44 (20) 7946 0958'), - ClipboardContentType.phone, - ); - }); - - test('detects area code format (55) number', () { - expect( - TextClassifier.classify('(55) 2222-2222'), - ClipboardContentType.phone, - ); - }); - - test('detects area code format with + prefix', () { - expect( - TextClassifier.classify('(+1) 555-1234'), - ClipboardContentType.phone, - ); - }); - - test('does not classify bare number without prefix as phone', () { - expect( - TextClassifier.classify('202117759'), - isNot(ClipboardContentType.phone), - ); - }); - - test('does not classify 10-digit bare number as phone', () { - expect( - TextClassifier.classify('1025908953'), - isNot(ClipboardContentType.phone), - ); - }); - - test('does not classify short digit sequence as phone', () { - expect( - TextClassifier.classify('12345'), - isNot(ClipboardContentType.phone), - ); - }); - - test('does not classify 16+ digit string as phone', () { - expect( - TextClassifier.classify('+1 800 555 0100 12345'), - isNot(ClipboardContentType.phone), - ); - }); - }); - - group('color', () { - test('detects 6-digit hex', () { - expect(TextClassifier.classify('#FF5733'), ClipboardContentType.color); - }); - - test('detects 3-digit hex', () { - expect(TextClassifier.classify('#F57'), ClipboardContentType.color); - }); - - test('detects 8-digit hex with alpha', () { - expect( - TextClassifier.classify('#FF5733AA'), - ClipboardContentType.color, - ); - }); - - test('detects rgb()', () { - expect( - TextClassifier.classify('rgb(255, 87, 51)'), - ClipboardContentType.color, - ); - }); - - test('detects rgba()', () { - expect( - TextClassifier.classify('rgba(255, 87, 51, 0.5)'), - ClipboardContentType.color, - ); - }); - - test('detects hsl()', () { - expect( - TextClassifier.classify('hsl(14, 100%, 51%)'), - ClipboardContentType.color, - ); - }); - - test('detects hsla()', () { - expect( - TextClassifier.classify('hsla(14, 100%, 51%, 0.8)'), - ClipboardContentType.color, - ); - }); - - test('does not classify arbitrary hash as color', () { - expect( - TextClassifier.classify('#ZZZZZZ'), - isNot(ClipboardContentType.color), - ); - }); - }); - - group('ip address', () { - test('detects valid IPv4', () { - expect(TextClassifier.classify('192.168.1.1'), ClipboardContentType.ip); - }); - - test('detects edge case 0.0.0.0', () { - expect(TextClassifier.classify('0.0.0.0'), ClipboardContentType.ip); - }); - - test('detects 255.255.255.255', () { - expect( - TextClassifier.classify('255.255.255.255'), - ClipboardContentType.ip, - ); - }); - - test('does not classify out-of-range octet', () { - expect( - TextClassifier.classify('256.0.0.1'), - isNot(ClipboardContentType.ip), - ); - }); - - test('does not classify partial IP', () { - expect( - TextClassifier.classify('192.168.1'), - isNot(ClipboardContentType.ip), - ); - }); - }); - - group('uuid', () { - test('detects v4 UUID', () { - expect( - TextClassifier.classify('550e8400-e29b-41d4-a716-446655440000'), - ClipboardContentType.uuid, - ); - }); - - test('detects uppercase UUID', () { - expect( - TextClassifier.classify('550E8400-E29B-41D4-A716-446655440000'), - ClipboardContentType.uuid, - ); - }); - - test('does not classify malformed UUID', () { - expect( - TextClassifier.classify('550e8400-e29b-41d4-a716'), - isNot(ClipboardContentType.uuid), - ); - }); - }); - - group('json', () { - test('detects JSON object', () { - expect( - TextClassifier.classify('{"key": "value"}'), - ClipboardContentType.json, - ); - }); - - test('detects JSON array', () { - expect(TextClassifier.classify('[1, 2, 3]'), ClipboardContentType.json); - }); - - test('detects multiline JSON', () { - expect( - TextClassifier.classify('{\n "name": "Mario",\n "age": 30\n}'), - ClipboardContentType.json, - ); - }); - - test('detects nested JSON', () { - expect( - TextClassifier.classify('{"user": {"id": 1, "tags": ["a", "b"]}}'), - ClipboardContentType.json, - ); - }); - - test('does not classify invalid JSON', () { - expect( - TextClassifier.classify('{invalid json}'), - isNot(ClipboardContentType.json), - ); - }); - - test('does not classify plain object-like text as json', () { - expect( - TextClassifier.classify('{not json at all'), - isNot(ClipboardContentType.json), - ); - }); - }); - - group('text fallback', () { - test('classifies empty string as text', () { - expect(TextClassifier.classify(''), ClipboardContentType.text); - }); - - test('classifies plain sentence as text', () { - expect( - TextClassifier.classify('Hello world'), - ClipboardContentType.text, - ); - }); - - test('classifies multiline prose as text', () { - expect( - TextClassifier.classify('Line one\nLine two\nLine three'), - ClipboardContentType.text, - ); - }); - - test('classifies whitespace-only as text', () { - expect(TextClassifier.classify(' '), ClipboardContentType.text); - }); - }); - - group('priority ordering', () { - test('email takes priority over phone-like pattern', () { - expect( - TextClassifier.classify('user@example.com'), - ClipboardContentType.email, - ); - }); - - test('uuid not confused with plain hex string', () { - expect( - TextClassifier.classify('550e8400e29b41d4a716446655440000'), - isNot(ClipboardContentType.uuid), - ); - }); - }); - }); -} diff --git a/core/test/thumbnail_queue_test.dart b/core/test/thumbnail_queue_test.dart deleted file mode 100644 index 99b5e386..00000000 --- a/core/test/thumbnail_queue_test.dart +++ /dev/null @@ -1,437 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:core/core.dart'; -import 'package:core/repository/i_clipboard_repository.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:image/image.dart' as img; -import 'package:path/path.dart' as p; - -class _ThrowingUpdateRepo implements IClipboardRepository { - final _items = {}; - - void seed(ClipboardItem item) => _items[item.id] = item; - - @override - Future save(ClipboardItem item) async => _items[item.id] = item; - @override - Future update(ClipboardItem item) async => - throw Exception('update deliberately failed'); - @override - Future getById(String id) async => _items[id]; - @override - Future getLatest() async => null; - @override - Future findByContentAndType( - String content, - ClipboardContentType type, - ) async => null; - @override - Future findByContentHash(String contentHash) async => null; - @override - Future> getAll() async => const []; - @override - Future delete(String id) async {} - @override - Future clearOldItems(int days, {bool excludePinned = true}) async => 0; - @override - Future deleteAllUnpinned() async => 0; - @override - Future count() async => _items.length; - @override - Future> search( - String query, { - int limit = 50, - int skip = 0, - }) async => const []; - @override - Future> searchAdvanced({ - String? query, - List? types, - List? colors, - bool? isPinned, - required int limit, - required int skip, - }) async => const []; - @override - Future> getImagePaths() async => const []; - @override - Future> getThumbPaths() async => const []; - @override - Future walCheckpoint() async {} - @override - Future close() async {} -} - -void main() { - late Directory tempDir; - late Directory imagesDir; - late Directory externalDir; - late SqliteRepository repo; - late ThumbnailService service; - late ThumbnailQueue queue; - late List updatedItems; - - setUp(() { - tempDir = Directory.systemTemp.createTempSync('thumb_queue_test_'); - imagesDir = Directory(p.join(tempDir.path, 'images')) - ..createSync(recursive: true); - externalDir = Directory(p.join(tempDir.path, 'external')) - ..createSync(recursive: true); - repo = SqliteRepository.inMemory(); - service = ThumbnailService(imagesPath: imagesDir.path); - updatedItems = []; - queue = ThumbnailQueue( - repository: repo, - service: service, - onItemUpdated: updatedItems.add, - ); - }); - - tearDown(() async { - await queue.dispose(); - await repo.close(); - if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); - }); - - Uint8List makePng({int w = 1024, int h = 1024}) { - final image = img.Image(width: w, height: h); - return Uint8List.fromList(img.encodePng(image)); - } - - Future saveImageItem(String externalPath, {String? id}) async { - final item = ClipboardItem( - id: id ?? 'item-${DateTime.now().microsecondsSinceEpoch}', - content: externalPath, - type: ClipboardContentType.image, - ); - await repo.save(item); - return item; - } - - Future drainQueue() async { - // Wait until the queue is fully idle: no pending jobs AND no in-flight - // encode. `pendingCount` alone is not enough — it drops to zero as soon - // as a job is taken off the queue, while the isolate may still be - // encoding the PNG. Poll up to ~5 s, which is generous enough for - // slow CI runners. - for (var i = 0; i < 100; i++) { - if (queue.isIdle) { - // One more pump so the `whenComplete` chain in `_scheduleNext` - // has a chance to flush its microtasks before the test asserts. - await Future.delayed(const Duration(milliseconds: 10)); - if (queue.isIdle) return; - } - await Future.delayed(const Duration(milliseconds: 50)); - } - } - - group('ThumbnailQueue.enqueue', () { - test('generates thumb, persists thumbPath, emits onItemUpdated', () async { - final src = File(p.join(externalDir.path, 'big.png')) - ..writeAsBytesSync(makePng(w: 1024, h: 512)); - final item = await saveImageItem(src.path, id: 'fresh'); - - queue.enqueue(item); - await drainQueue(); - - final stored = await repo.getById('fresh'); - expect(stored, isNotNull); - expect( - stored!.thumbPath, - equals(p.join(imagesDir.path, 'fresh_thumb.png')), - ); - expect(stored.sourceModifiedAt, isNotNull); - expect(File(stored.thumbPath!).existsSync(), isTrue); - expect(updatedItems, hasLength(1)); - expect(updatedItems.single.id, equals('fresh')); - }); - - test('ignores duplicate enqueue for same id while pending', () async { - final src = File(p.join(externalDir.path, 'dup.png')) - ..writeAsBytesSync(makePng()); - final item = await saveImageItem(src.path, id: 'dup'); - - queue.enqueue(item); - queue.enqueue(item); - queue.enqueue(item); - await drainQueue(); - - expect(updatedItems, hasLength(1)); - }); - - test('skips non-image items', () async { - final item = ClipboardItem( - id: 'txt', - content: 'hello', - type: ClipboardContentType.text, - ); - await repo.save(item); - - queue.enqueue(item); - await drainQueue(); - - final stored = await repo.getById('txt'); - expect(stored?.thumbPath, isNull); - expect(updatedItems, isEmpty); - }); - - test('skips multi-path content', () async { - final a = File(p.join(externalDir.path, 'a.png')) - ..writeAsBytesSync(makePng()); - final b = File(p.join(externalDir.path, 'b.png')) - ..writeAsBytesSync(makePng()); - final item = ClipboardItem( - id: 'multi', - content: '${a.path}\n${b.path}', - type: ClipboardContentType.image, - ); - await repo.save(item); - - queue.enqueue(item); - await drainQueue(); - - expect((await repo.getById('multi'))?.thumbPath, isNull); - }); - }); - - group('ThumbnailQueue race conditions', () { - test( - 'drops generated thumb if item was deleted during generation', - () async { - final src = File(p.join(externalDir.path, 'race.png')) - ..writeAsBytesSync(makePng(w: 2048, h: 2048)); - final item = await saveImageItem(src.path, id: 'race'); - - queue.enqueue(item); - // Delete before the encoder can finish (encoder is in an isolate - // and the file is large enough to take more than zero microtasks). - await repo.delete('race'); - await drainQueue(); - - // The generated thumb (if any) must have been cleaned up by the - // queue's race-window check. The repository row stays gone. - final orphan = File(p.join(imagesDir.path, 'race_thumb.png')); - expect(orphan.existsSync(), isFalse); - expect(updatedItems, isEmpty); - }, - ); - - test('skips entirely if item is gone before generation starts', () async { - final src = File(p.join(externalDir.path, 'gone.png')) - ..writeAsBytesSync(makePng()); - final item = await saveImageItem(src.path, id: 'gone'); - await repo.delete('gone'); - - queue.enqueue(item); - await drainQueue(); - - expect( - File(p.join(imagesDir.path, 'gone_thumb.png')).existsSync(), - isFalse, - ); - expect(updatedItems, isEmpty); - }); - }); - - group('ThumbnailQueue.enqueueIfStale', () { - test('enqueues when no sourceModifiedAt has been recorded', () async { - final src = File(p.join(externalDir.path, 'cold.png')) - ..writeAsBytesSync(makePng()); - final item = await saveImageItem(src.path, id: 'cold'); - - queue.enqueueIfStale(item); - await drainQueue(); - - expect((await repo.getById('cold'))?.thumbPath, isNotNull); - }); - - test( - 'does not enqueue when mtime matches recorded sourceModifiedAt', - () async { - final src = File(p.join(externalDir.path, 'fresh.png')) - ..writeAsBytesSync(makePng()); - final mtime = src.statSync().modified.toUtc(); - final item = ClipboardItem( - id: 'fresh-stale', - content: src.path, - type: ClipboardContentType.image, - sourceModifiedAt: mtime, - ); - await repo.save(item); - - queue.enqueueIfStale(item); - await drainQueue(); - - expect(updatedItems, isEmpty); - }, - ); - - test('enqueues when source mtime differs from recorded', () async { - final src = File(p.join(externalDir.path, 'stale.png')) - ..writeAsBytesSync(makePng()); - final past = DateTime.utc(2020, 1, 1); - final item = ClipboardItem( - id: 'stale', - content: src.path, - type: ClipboardContentType.image, - sourceModifiedAt: past, - ); - await repo.save(item); - - queue.enqueueIfStale(item); - await drainQueue(); - - final stored = await repo.getById('stale'); - expect(stored?.thumbPath, isNotNull); - expect(stored!.sourceModifiedAt, isNot(equals(past))); - }); - - test('no-op for missing source file', () async { - final item = ClipboardItem( - id: 'missing', - content: p.join(externalDir.path, 'nope.png'), - type: ClipboardContentType.image, - ); - await repo.save(item); - - queue.enqueueIfStale(item); - await drainQueue(); - - expect(updatedItems, isEmpty); - }); - }); - - group('ThumbnailQueue.dispose', () { - test('refuses new jobs after dispose', () async { - await queue.dispose(); - final src = File(p.join(externalDir.path, 'late.png')) - ..writeAsBytesSync(makePng()); - final item = await saveImageItem(src.path, id: 'late'); - - queue.enqueue(item); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(updatedItems, isEmpty); - expect((await repo.getById('late'))?.thumbPath, isNull); - }); - }); - - group('ThumbnailQueue.pendingCount', () { - test('is zero on a fresh queue', () { - expect(queue.pendingCount, equals(0)); - }); - }); - - group('ThumbnailQueue depth warning', () { - test('logs warn when more than 20 items are pending', () async { - // Enqueue 22 items synchronously — the first starts processing - // asynchronously while items 2-22 accumulate in _queue. - // When item 22 is added, _queue.length > 20 triggers AppLogger.warn. - for (var i = 0; i < 22; i++) { - final item = ClipboardItem( - id: 'depth-warn-$i', - content: p.join(externalDir.path, 'depth_$i.png'), - type: ClipboardContentType.image, - ); - queue.enqueue(item); - } - // Verify items accumulated (warning was logged). - expect(queue.pendingCount, greaterThan(0)); - }); - }); - - group('ThumbnailQueue._safeGenerate exception', () { - test( - 'catches write failure inside Isolate and emits no update', - () async { - final dir = Directory.systemTemp.createTempSync('tq_safegen_err_'); - final ext = Directory(p.join(dir.path, 'ext'))..createSync(); - final imgs = Directory(p.join(dir.path, 'imgs'))..createSync(); - final src = File(p.join(ext.path, 'source.png')) - ..writeAsBytesSync(makePng(w: 64, h: 64)); - - final localRepo = SqliteRepository.inMemory(); - final localService = ThumbnailService(imagesPath: imgs.path); - final updatedLocal = []; - final localQueue = ThumbnailQueue( - repository: localRepo, - service: localService, - onItemUpdated: updatedLocal.add, - ); - - final item = ClipboardItem( - id: 'safegen-err', - content: src.path, - type: ClipboardContentType.image, - ); - await localRepo.save(item); - - // Make imgs dir non-writable so the isolate's File.writeAsBytesSync - // throws EACCES, which propagates through Isolate.run and is caught by - // _safeGenerate (line 184). - await Process.run('chmod', ['555', imgs.path]); - - try { - localQueue.enqueue(item); - for (var i = 0; i < 100; i++) { - if (localQueue.isIdle) break; - await Future.delayed(const Duration(milliseconds: 50)); - } - expect(updatedLocal, isEmpty); - } finally { - await Process.run('chmod', ['755', imgs.path]); - await localQueue.dispose(); - await localRepo.close(); - dir.deleteSync(recursive: true); - } - }, - skip: Platform.isWindows - ? 'Requires POSIX directory permissions (chmod)' - : false, - ); - }); - - group('ThumbnailQueue update failure', () { - test('catches repo update error and deletes generated thumb', () async { - final dir = Directory.systemTemp.createTempSync('tq_update_fail_'); - final ext = Directory(p.join(dir.path, 'ext'))..createSync(); - final imgs = Directory(p.join(dir.path, 'imgs'))..createSync(); - final src = File(p.join(ext.path, 'source.png')) - ..writeAsBytesSync(makePng(w: 64, h: 64)); - - final throwingRepo = _ThrowingUpdateRepo(); - final localService = ThumbnailService(imagesPath: imgs.path); - final updatedLocal = []; - final localQueue = ThumbnailQueue( - repository: throwingRepo, - service: localService, - onItemUpdated: updatedLocal.add, - ); - - final item = ClipboardItem( - id: 'update-fail', - content: src.path, - type: ClipboardContentType.image, - ); - throwingRepo.seed(item); - - try { - localQueue.enqueue(item); - // Wait for the job to attempt update, fail, and return to idle. - for (var i = 0; i < 200; i++) { - if (localQueue.isIdle) break; - await Future.delayed(const Duration(milliseconds: 50)); - } - // update threw → onItemUpdated was never called. - expect(updatedLocal, isEmpty); - // The generated thumb was deleted by _safeDelete. - final thumbFiles = imgs.listSync().whereType().toList(); - expect(thumbFiles, isEmpty); - } finally { - await localQueue.dispose(); - dir.deleteSync(recursive: true); - } - }); - }); -} diff --git a/core/test/thumbnail_service_test.dart b/core/test/thumbnail_service_test.dart deleted file mode 100644 index 8b5c4853..00000000 --- a/core/test/thumbnail_service_test.dart +++ /dev/null @@ -1,247 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:core/models/clipboard_content_type.dart'; -import 'package:core/models/clipboard_item.dart'; -import 'package:core/services/native_thumbnail_provider.dart'; -import 'package:core/services/thumbnail_service.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:image/image.dart' as img; -import 'package:path/path.dart' as p; - -void main() { - late Directory tempDir; - late Directory imagesDir; - late Directory externalDir; - late ThumbnailService service; - - setUp(() { - tempDir = Directory.systemTemp.createTempSync('thumb_svc_test_'); - imagesDir = Directory(p.join(tempDir.path, 'images')) - ..createSync(recursive: true); - externalDir = Directory(p.join(tempDir.path, 'external')) - ..createSync(recursive: true); - service = ThumbnailService(imagesPath: imagesDir.path); - }); - - tearDown(() { - if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); - }); - - Uint8List makePng({int width = 64, int height = 32}) { - final image = img.Image(width: width, height: height); - for (var x = 0; x < width; x++) { - for (var y = 0; y < height; y++) { - image.setPixelRgb(x, y, x * 4, y * 8, 128); - } - } - return Uint8List.fromList(img.encodePng(image)); - } - - ClipboardItem imageItem(String externalPath, {String id = 'item-1'}) => - ClipboardItem( - id: id, - content: externalPath, - type: ClipboardContentType.image, - ); - - group('ThumbnailService.generateForItem', () { - test('produces 256-px PNG for large external image', () async { - final src = File(p.join(externalDir.path, 'big.png')) - ..writeAsBytesSync(makePng(width: 1024, height: 512)); - - final result = await service.generateForItem(imageItem(src.path)); - - expect(result, isNotNull); - expect( - result!.thumbPath, - equals(p.join(imagesDir.path, 'item-1_thumb.png')), - ); - expect(File(result.thumbPath).existsSync(), isTrue); - - final decoded = img.decodePng(File(result.thumbPath).readAsBytesSync()); - expect(decoded, isNotNull); - expect(decoded!.width, equals(256)); - expect(decoded.height, equals(128)); - }); - - test('does not upscale small images', () async { - final src = File(p.join(externalDir.path, 'small.png')) - ..writeAsBytesSync(makePng(width: 64, height: 32)); - - final result = await service.generateForItem(imageItem(src.path)); - - expect(result, isNotNull); - final decoded = img.decodePng(File(result!.thumbPath).readAsBytesSync()); - expect(decoded!.width, equals(64)); - expect(decoded.height, equals(32)); - }); - - test('records source mtime in result', () async { - final src = File(p.join(externalDir.path, 'mtime.png')) - ..writeAsBytesSync(makePng()); - final mtime = src.statSync().modified.toUtc(); - - final result = await service.generateForItem(imageItem(src.path)); - - expect(result, isNotNull); - expect(result!.sourceModifiedAt, equals(mtime)); - }); - - test('returns null for non-image items', () async { - final src = File(p.join(externalDir.path, 'unused.png')) - ..writeAsBytesSync(makePng()); - final item = ClipboardItem( - id: 'text-1', - content: src.path, - type: ClipboardContentType.text, - ); - - expect(await service.generateForItem(item), isNull); - }); - - test('returns null when source file does not exist', () async { - final item = imageItem(p.join(externalDir.path, 'missing.png')); - expect(await service.generateForItem(item), isNull); - }); - - test('returns null when content is empty', () async { - final item = ClipboardItem( - id: 'empty', - content: '', - type: ClipboardContentType.image, - ); - expect(await service.generateForItem(item), isNull); - }); - - test( - 'returns null for multi-path content (drag of multiple files)', - () async { - final a = File(p.join(externalDir.path, 'a.png')) - ..writeAsBytesSync(makePng()); - final b = File(p.join(externalDir.path, 'b.png')) - ..writeAsBytesSync(makePng()); - final item = ClipboardItem( - id: 'multi', - content: '${a.path}\n${b.path}', - type: ClipboardContentType.image, - ); - - expect(await service.generateForItem(item), isNull); - }, - ); - - test('skips snippets owned by imagesPath', () async { - // A snippet captured by the image processing queue would already - // live inside imagesPath. We do not create thumbs for those. - final snippet = File(p.join(imagesDir.path, 'snippet.png')) - ..writeAsBytesSync(makePng(width: 1024, height: 1024)); - final item = imageItem(snippet.path, id: 'snip'); - - expect(await service.generateForItem(item), isNull); - expect( - File(p.join(imagesDir.path, 'snip_thumb.png')).existsSync(), - isFalse, - ); - }); - - test('returns null for unreadable / non-image bytes', () async { - final src = File(p.join(externalDir.path, 'garbage.png')) - ..writeAsBytesSync(Uint8List.fromList(List.filled(64, 0xAB))); - - expect(await service.generateForItem(imageItem(src.path)), isNull); - }); - - test('returns null when source exceeds maxSourceBytes', () async { - final smallService = ThumbnailService( - imagesPath: imagesDir.path, - maxSourceBytes: 16, - ); - final src = File(p.join(externalDir.path, 'too_big.png')) - ..writeAsBytesSync(makePng(width: 32, height: 32)); - - expect(await smallService.generateForItem(imageItem(src.path)), isNull); - }); - - test('writes thumb only inside imagesPath', () async { - final src = File(p.join(externalDir.path, 'safe.png')) - ..writeAsBytesSync(makePng()); - - final result = await service.generateForItem(imageItem(src.path)); - expect(result, isNotNull); - expect( - p.isWithin(imagesDir.path, result!.thumbPath), - isTrue, - reason: 'thumb must live inside imagesPath', - ); - }); - }); - - group('ThumbnailService isTypeEnabled gate (PR #10)', () { - test('skips generation when callback returns false for the type', () async { - service.isTypeEnabled = (_) => false; - final src = File(p.join(externalDir.path, 'gated.png')) - ..writeAsBytesSync(makePng()); - - final result = await service.generateForItem(imageItem(src.path)); - - expect(result, isNull); - expect(service.acceptsType(ClipboardContentType.image), isFalse); - }); - - test('proceeds when callback returns true', () async { - service.isTypeEnabled = (_) => true; - final src = File(p.join(externalDir.path, 'allowed.png')) - ..writeAsBytesSync(makePng()); - - final result = await service.generateForItem(imageItem(src.path)); - - expect(result, isNotNull); - expect(service.acceptsType(ClipboardContentType.image), isTrue); - }); - - test('mutating the gate is honored on the next call', () async { - final src = File(p.join(externalDir.path, 'mutating.png')) - ..writeAsBytesSync(makePng()); - - service.isTypeEnabled = (_) => false; - expect(await service.generateForItem(imageItem(src.path)), isNull); - - service.isTypeEnabled = (_) => true; - expect(await service.generateForItem(imageItem(src.path)), isNotNull); - }); - }); - - group('ThumbnailService.acceptsType with nativeProvider', () { - test('returns true for audio when nativeProvider is set', () { - final nativeService = ThumbnailService( - imagesPath: imagesDir.path, - nativeProvider: const NoopNativeThumbnailProvider(), - ); - expect(nativeService.acceptsType(ClipboardContentType.audio), isTrue); - }); - }); - - group('ThumbnailService._downscale portrait image', () { - test('constrains height when image is taller than maxDimension', () async { - // Portrait: width=64, height=512 — height > maxDimension(256) so - // _downscale uses copyResize(src, height: maxDim) branch (line 215). - final portraitImage = img.Image(width: 64, height: 512); - for (var x = 0; x < 64; x++) { - for (var y = 0; y < 512; y++) { - portraitImage.setPixelRgb(x, y, x * 4, y ~/ 2, 128); - } - } - final pngBytes = Uint8List.fromList(img.encodePng(portraitImage)); - final src = File(p.join(externalDir.path, 'portrait.png')) - ..writeAsBytesSync(pngBytes); - - final result = await service.generateForItem(imageItem(src.path)); - - expect(result, isNotNull); - final thumb = img.decodePng(await File(result!.thumbPath).readAsBytes())!; - expect(thumb.height, lessThanOrEqualTo(256)); - expect(thumb.height, greaterThan(thumb.width)); - }); - }); -} diff --git a/crates/cp-core/Cargo.toml b/crates/cp-core/Cargo.toml new file mode 100644 index 00000000..63644c64 --- /dev/null +++ b/crates/cp-core/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cp-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +thiserror.workspace = true +unicode-normalization.workspace = true + +[lints] +workspace = true diff --git a/crates/cp-core/src/lib.rs b/crates/cp-core/src/lib.rs new file mode 100644 index 00000000..c0d77af8 --- /dev/null +++ b/crates/cp-core/src/lib.rs @@ -0,0 +1,8 @@ +pub mod search; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Text, + Image, + Files, +} diff --git a/crates/cp-core/src/search.rs b/crates/cp-core/src/search.rs new file mode 100644 index 00000000..6be482c8 --- /dev/null +++ b/crates/cp-core/src/search.rs @@ -0,0 +1,41 @@ +use unicode_normalization::UnicodeNormalization; + +pub fn fold(input: &str) -> String { + input + .nfkd() + .filter(|c| !is_combining_mark(*c)) + .flat_map(expand_ligature) + .flat_map(char::to_lowercase) + .collect() +} + +fn is_combining_mark(c: char) -> bool { + matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x20D0..=0x20FF) +} + +fn expand_ligature(c: char) -> std::vec::IntoIter { + let expanded: Vec = match c { + 'ß' => vec!['s', 's'], + 'æ' | 'Æ' => vec!['a', 'e'], + 'œ' | 'Œ' => vec!['o', 'e'], + 'ø' | 'Ø' => vec!['o'], + 'ł' | 'Ł' => vec!['l'], + 'đ' | 'Đ' => vec!['d'], + 'þ' | 'Þ' => vec!['t', 'h'], + other => vec![other], + }; + expanded.into_iter() +} + +#[cfg(test)] +mod tests { + use super::fold; + + #[test] + fn folds_both_sides_of_the_index() { + assert_eq!(fold("Straße"), "strasse"); + assert_eq!(fold("encyclopædia"), "encyclopaedia"); + assert_eq!(fold("Łódź"), "lodz"); + assert_eq!(fold("el café"), "el cafe"); + } +} diff --git a/crates/cp-mac-sys/Cargo.toml b/crates/cp-mac-sys/Cargo.toml new file mode 100644 index 00000000..9db33daa --- /dev/null +++ b/crates/cp-mac-sys/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "cp-mac-sys" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[target.'cfg(target_os = "macos")'.dependencies] +objc2.workspace = true +objc2-foundation.workspace = true +objc2-app-kit.workspace = true +objc2-core-graphics.workspace = true + +[lints.rust] +unsafe_code = "allow" + +[lints.clippy] +all = { level = "deny", priority = -1 } +undocumented_unsafe_blocks = "deny" +multiple_unsafe_ops_per_block = "deny" diff --git a/crates/cp-mac-sys/src/lib.rs b/crates/cp-mac-sys/src/lib.rs new file mode 100644 index 00000000..76d88c64 --- /dev/null +++ b/crates/cp-mac-sys/src/lib.rs @@ -0,0 +1,3 @@ +#![cfg(target_os = "macos")] + +pub mod pasteboard; diff --git a/crates/cp-mac-sys/src/pasteboard.rs b/crates/cp-mac-sys/src/pasteboard.rs new file mode 100644 index 00000000..2f8b29ae --- /dev/null +++ b/crates/cp-mac-sys/src/pasteboard.rs @@ -0,0 +1,8 @@ +use objc2_app_kit::NSPasteboard; +use objc2_foundation::MainThreadMarker; + +/// `NSPasteboard` tiene un fallo de concurrencia con file promises, así que solo +/// se toca desde el hilo principal: el `MainThreadMarker` lo obliga en compilación. +pub fn change_count(_mtm: MainThreadMarker) -> isize { + NSPasteboard::generalPasteboard().changeCount() +} diff --git a/crates/cp-mac/Cargo.toml b/crates/cp-mac/Cargo.toml new file mode 100644 index 00000000..50b3e76e --- /dev/null +++ b/crates/cp-mac/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "cp-mac" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[target.'cfg(target_os = "macos")'.dependencies] +cp-core.workspace = true +cp-mac-sys.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/cp-mac/src/lib.rs b/crates/cp-mac/src/lib.rs new file mode 100644 index 00000000..1164671b --- /dev/null +++ b/crates/cp-mac/src/lib.rs @@ -0,0 +1,5 @@ +#![cfg(target_os = "macos")] + +/// El pegado nunca se intenta sin el destino en primer plano: medido el +/// 12/09/2026, ni `CGEventPostToPid` ni `AXPress` entregan a una app de fondo. +pub const REQUIRES_FOREGROUND_TARGET: bool = true; diff --git a/crates/cp-store/Cargo.toml b/crates/cp-store/Cargo.toml new file mode 100644 index 00000000..435f3e1b --- /dev/null +++ b/crates/cp-store/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cp-store" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +cp-core.workspace = true +rusqlite.workspace = true +blake3.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/cp-store/src/lib.rs b/crates/cp-store/src/lib.rs new file mode 100644 index 00000000..a71a0b4d --- /dev/null +++ b/crates/cp-store/src/lib.rs @@ -0,0 +1 @@ +pub const SCHEMA_VERSION: u32 = 1; diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..28e86d0d --- /dev/null +++ b/deny.toml @@ -0,0 +1,23 @@ +[advisories] +yanked = "deny" + +[licenses] +allow = [ + "GPL-3.0", + "Apache-2.0", + "MIT", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Zlib", +] +confidence-threshold = 0.9 + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/core/lib/config/.gitkeep b/fixtures/.gitkeep similarity index 100% rename from core/lib/config/.gitkeep rename to fixtures/.gitkeep diff --git a/listener/.gitignore b/listener/.gitignore deleted file mode 100644 index b9d7f25b..00000000 --- a/listener/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. -/pubspec.lock -**/doc/api/ -.dart_tool/ -.flutter-plugins-dependencies -/build/ -/coverage/ diff --git a/listener/.metadata b/listener/.metadata deleted file mode 100644 index 2667e966..00000000 --- a/listener/.metadata +++ /dev/null @@ -1,33 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "48c32af0345e9ad5747f78ddce828c7f795f7159" - channel: "stable" - -project_type: plugin - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - - platform: macos - create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - - platform: windows - create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/listener/analysis_options.yaml b/listener/analysis_options.yaml deleted file mode 100644 index 5e2133eb..00000000 --- a/listener/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../analysis_options.yaml diff --git a/listener/lib/base_native_thumbnail_provider.dart b/listener/lib/base_native_thumbnail_provider.dart deleted file mode 100644 index 20babada..00000000 --- a/listener/lib/base_native_thumbnail_provider.dart +++ /dev/null @@ -1,74 +0,0 @@ -// coverage:ignore-file -import 'dart:async'; -import 'dart:ui' as ui show PlatformDispatcher; - -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; - -/// Shared bridge to the native `getNativeThumbnail` handler. Subclasses only -/// declare which platform they serve, a debug label, and (optionally) how to -/// map a platform-specific error code to a dedicated log line. -abstract class BaseNativeThumbnailProvider implements NativeThumbnailProvider { - BaseNativeThumbnailProvider({MethodChannel? channel}) - : channel = channel ?? const MethodChannel('copypaste/clipboard_writer'); - - final MethodChannel channel; - - bool get isCurrentPlatform; - - String get debugLabel; - - /// Returns true when the error was already logged with a dedicated message, - /// so the generic warning is skipped. - bool handlePlatformException(PlatformException e, String path) => false; - - @override - Future request(String path, {int sizePx = 256}) async { - if (!isCurrentPlatform) return null; - if (path.isEmpty || sizePx <= 0) return null; - - final scaled = (sizePx * _maxDevicePixelRatio()).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) { - if (!handlePlatformException(e, path)) { - AppLogger.warn( - '$debugLabel: platform error: ${e.code} ${e.message}\n$s', - ); - } - return null; - } on MissingPluginException { - return null; - } - } - - /// Largest device pixel ratio across connected displays, so the OS produces a - /// bitmap big enough for the sharpest screen. - double _maxDevicePixelRatio() { - 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/clipboard_event.dart b/listener/lib/clipboard_event.dart deleted file mode 100644 index e7d48eae..00000000 --- a/listener/lib/clipboard_event.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:typed_data'; - -import 'package:core/core.dart'; - -class ClipboardEvent { - const ClipboardEvent({ - required this.type, - required this.contentHash, - this.text, - this.bytes, - this.files, - this.source, - this.rtfBytes, - this.htmlBytes, - }); - - factory ClipboardEvent.fromMap(Map map) { - final typeVal = map['type'] as int? ?? -1; - return ClipboardEvent( - type: ClipboardContentType.fromValue(typeVal), - contentHash: map['contentHash'] as String? ?? '', - text: map['text'] as String?, - bytes: map['bytes'] as Uint8List?, - files: (map['files'] as List?)?.whereType().toList(), - source: map['source'] as String?, - rtfBytes: map['rtf'] as Uint8List?, - htmlBytes: map['html'] as Uint8List?, - ); - } - - final ClipboardContentType type; - - final String contentHash; - - final String? text; - - final Uint8List? bytes; - - final List? files; - - final String? source; - - final Uint8List? rtfBytes; - - final Uint8List? htmlBytes; -} diff --git a/listener/lib/clipboard_listener.dart b/listener/lib/clipboard_listener.dart deleted file mode 100644 index dea9c851..00000000 --- a/listener/lib/clipboard_listener.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/services.dart'; - -import 'clipboard_event.dart'; - -class ClipboardListener { - static const EventChannel _channel = EventChannel('copypaste/clipboard'); - - late final Stream onEvent = _channel - .receiveBroadcastStream() - .where((dynamic event) => event is Map) - .map((dynamic event) { - final map = Map.from(event as Map); - return ClipboardEvent.fromMap(map); - }); -} diff --git a/listener/lib/clipboard_writer.dart b/listener/lib/clipboard_writer.dart deleted file mode 100644 index 34e1e3d6..00000000 --- a/listener/lib/clipboard_writer.dart +++ /dev/null @@ -1,206 +0,0 @@ -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; - } - - /// Starts a native OLE drag offering [paths] as CF_HDROP items. A drop target - /// (e.g. a browser upload zone) receives the files with their real, unique - /// names, sidestepping the fixed "image.png" Chromium assigns to pasted - /// bitmaps. The native call blocks until the drag ends; resolves true when the - /// files were dropped onto a target. - static Future startFileDrag(List paths) async { - if (paths.isEmpty) return false; - try { - final result = await _channel.invokeMethod( - 'startFileDrag', - {'paths': paths}, - ); - return result ?? false; - } catch (e) { - AppLogger.error('ClipboardWriter.startFileDrag failed: $e'); - return 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; -} diff --git a/listener/lib/listener.dart b/listener/lib/listener.dart deleted file mode 100644 index 5b4c8ac7..00000000 --- a/listener/lib/listener.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'clipboard_event.dart'; -export 'clipboard_listener.dart'; -export 'clipboard_writer.dart'; -export 'macos_native_thumbnail_provider.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 deleted file mode 100644 index 63be513f..00000000 --- a/listener/lib/macos_native_thumbnail_provider.dart +++ /dev/null @@ -1,34 +0,0 @@ -// coverage:ignore-file -import 'dart:io' show Platform; - -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; - -import 'base_native_thumbnail_provider.dart'; - -/// macOS-backed [BaseNativeThumbnailProvider]. The native handler uses -/// `QLThumbnailGenerator.generateBestRepresentation(for:)` and re-encodes the -/// representation as PNG. -/// -/// TCC: when macOS denies access to the source file (`~/Documents`, -/// `~/Downloads`, `~/Desktop`, iCloud Drive, etc.), the native handler surfaces -/// a `permissionDenied` PlatformException; it is logged distinctly so the UI -/// can render a TCC-specific message instead of a generic "file not found". -class MacOSNativeThumbnailProvider extends BaseNativeThumbnailProvider { - MacOSNativeThumbnailProvider({super.channel}); - - @override - bool get isCurrentPlatform => Platform.isMacOS; - - @override - String get debugLabel => 'MacOSNativeThumbnailProvider'; - - @override - bool handlePlatformException(PlatformException e, String path) { - if (e.code == 'permissionDenied') { - AppLogger.warn('[NativeThumb] TCC denied for $path: ${e.message}'); - return true; - } - return false; - } -} diff --git a/listener/lib/windows_native_thumbnail_provider.dart b/listener/lib/windows_native_thumbnail_provider.dart deleted file mode 100644 index 16f6bf52..00000000 --- a/listener/lib/windows_native_thumbnail_provider.dart +++ /dev/null @@ -1,19 +0,0 @@ -// coverage:ignore-file -import 'dart:io' show Platform; - -import 'base_native_thumbnail_provider.dart'; - -/// Windows-backed [BaseNativeThumbnailProvider]. The native handler uses -/// `IShellItemImageFactory::GetImage(SIIGBF_THUMBNAILONLY | SIIGBF_INCACHEONLY)` -/// and re-encodes the resulting bitmap as PNG before returning the bytes. -/// The C++ side enforces a 64-px minimum heuristic to reject generic -/// file-type icons. -class WindowsNativeThumbnailProvider extends BaseNativeThumbnailProvider { - WindowsNativeThumbnailProvider({super.channel}); - - @override - bool get isCurrentPlatform => Platform.isWindows; - - @override - String get debugLabel => 'WindowsNativeThumbnailProvider'; -} diff --git a/listener/macos/Classes/ListenerPlugin.swift b/listener/macos/Classes/ListenerPlugin.swift deleted file mode 100644 index d286ae6d..00000000 --- a/listener/macos/Classes/ListenerPlugin.swift +++ /dev/null @@ -1,729 +0,0 @@ -import Cocoa -import FlutterMacOS -import AVFoundation -import ApplicationServices -import QuickLookThumbnailing - -public class ListenerPlugin: NSObject, FlutterPlugin { - - private var eventSink: FlutterEventSink? - private var pollingTimer: Timer? - private var lastChangeCount: Int = 0 - private var lastContentHash: String = "" - private var lastChangeTick: UInt64 = 0 - private var lastForeignBundleId: String? - private var activationObserver: NSObjectProtocol? - - private static let debounceMs: UInt64 = 250 - private static let pollingIntervalSec: TimeInterval = 0.25 - - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = ListenerPlugin() - instance.startTrackingActivation() - - let eventChannel = FlutterEventChannel( - name: "copypaste/clipboard", - binaryMessenger: registrar.messenger - ) - eventChannel.setStreamHandler(instance) - - let methodChannel = FlutterMethodChannel( - name: "copypaste/clipboard_writer", - binaryMessenger: registrar.messenger - ) - methodChannel.setMethodCallHandler(instance.handleMethodCall) - } - - // MARK: - Paste Destination Tracking - - /// Unlike Windows, hiding the panel on macOS is `orderOut`, which leaves - /// CopyPaste the active application. Reading frontmostApplication at that - /// point captures ourselves and the paste is delivered back to the panel, - /// so the last application that was not us is tracked separately. - private func startTrackingActivation() { - activationObserver = NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.didActivateApplicationNotification, - object: nil, - queue: .main - ) { [weak self] notification in - guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] - as? NSRunningApplication else { return } - if app.processIdentifier == ProcessInfo.processInfo.processIdentifier { - return - } - if let bundleId = app.bundleIdentifier { - self?.lastForeignBundleId = bundleId - } - } - } - - private func captureDestinationBundleId() -> String? { - let front = NSWorkspace.shared.frontmostApplication - let isSelf = - front?.processIdentifier == ProcessInfo.processInfo.processIdentifier - if let bundleId = front?.bundleIdentifier, !isSelf { - lastForeignBundleId = bundleId - return bundleId - } - return lastForeignBundleId - } - - deinit { - if let observer = activationObserver { - NSWorkspace.shared.notificationCenter.removeObserver(observer) - } - } - - // MARK: - Method Channel Handler - - private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "setClipboardContent": - handleSetClipboard(call: call, result: result) - case "getMediaInfo": - handleGetMediaInfo(call: call, result: result) - case "getNativeThumbnail": - handleGetNativeThumbnail(call: call, result: result) - case "captureFrontmostApp": - result(captureDestinationBundleId()) - case "activateAndPaste": - handleActivateAndPaste(call: call, result: result) - case "getCursorAndScreenInfo": - handleCursorAndScreenInfo(result: result) - case "checkAccessibility": - result(AXIsProcessTrusted()) - case "requestAccessibility": - let options = [ - kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true, - ] as CFDictionary - result(AXIsProcessTrustedWithOptions(options)) - case "openAccessibilitySettings": - if #available(macOS 13.0, *) { - NSWorkspace.shared.open( - URL(string: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility")! - ) - } else { - NSWorkspace.shared.open( - URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! - ) - } - result(true) - default: - result(FlutterMethodNotImplemented) - } - } - - // MARK: - Clipboard Monitoring - - private func startPolling() { - lastChangeCount = NSPasteboard.general.changeCount - pollingTimer = Timer.scheduledTimer( - withTimeInterval: ListenerPlugin.pollingIntervalSec, - repeats: true - ) { [weak self] _ in - self?.checkClipboard() - } - } - - private func stopPolling() { - pollingTimer?.invalidate() - pollingTimer = nil - } - - private func checkClipboard() { - let pb = NSPasteboard.general - let currentCount = pb.changeCount - guard currentCount != lastChangeCount else { return } - lastChangeCount = currentCount - onClipboardChanged() - } - - /// Pasteboard types whose presence means "do not record this". - /// - /// `ConcealedType` is what password managers set to keep their output out of - /// clipboard history. It is the mechanism behind the exclusion CopyPaste - /// promises, and the macOS counterpart of Windows' - /// `ExcludeClipboardContentFromMonitorProcessing`. `TransientType` marks - /// content the source application explicitly does not want persisted. - private static let excludedTypes: [NSPasteboard.PasteboardType] = [ - NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType"), - NSPasteboard.PasteboardType("org.nspasteboard.TransientType"), - ] - - private func shouldExclude(_ pb: NSPasteboard) -> Bool { - return pb.availableType(from: Self.excludedTypes) != nil - } - - private func onClipboardChanged() { - let pb = NSPasteboard.general - - // Checked before anything reads the content, so excluded data never - // reaches the hash, the event, or the database. - if shouldExclude(pb) { return } - - let hash = computeClipboardHash(pb) - if !hash.isEmpty && isDuplicate(hash) { return } - - let source = getClipboardSource() - - var event: [String: Any]? - - if let fileUrls = pb.readObjects(forClasses: [NSURL.self], options: [ - .urlReadingFileURLsOnly: true, - ]) as? [URL], !fileUrls.isEmpty { - event = buildFileEvent(fileUrls: fileUrls, source: source, hash: hash) - } else if let text = pb.string(forType: .string), !text.isEmpty { - event = buildTextEvent(pb: pb, text: text, source: source, hash: hash) - } else if let tiffData = pb.data(forType: .tiff), !tiffData.isEmpty { - event = buildImageEvent(imageData: tiffData, source: source, hash: hash) - } - - guard let eventMap = event else { return } - DispatchQueue.main.async { [weak self] in - self?.eventSink?(eventMap) - } - } - - // MARK: - Event Builders - - private func buildTextEvent( - pb: NSPasteboard, - text: String, - source: String, - hash: String - ) -> [String: Any] { - let isUrl = Self.isUrl(text) - let eventType: Int = isUrl ? 4 : 0 - - var event: [String: Any] = [ - "type": eventType, - "text": text, - "source": source, - "contentHash": hash, - ] - - if let rtfData = pb.data(forType: .rtf), !rtfData.isEmpty { - event["rtf"] = FlutterStandardTypedData(bytes: rtfData) - } - if let htmlData = pb.data(forType: .html), !htmlData.isEmpty { - event["html"] = FlutterStandardTypedData(bytes: htmlData) - } - - return event - } - - private func buildImageEvent( - imageData: Data, - source: String, - hash: String - ) -> [String: Any] { - guard let bitmap = NSBitmapImageRep(data: imageData) else { return [:] } - guard let bmpData = bitmap.representation(using: .bmp, properties: [:]) else { return [:] } - - return [ - "type": 1, - "bytes": FlutterStandardTypedData(bytes: bmpData), - "source": source, - "contentHash": hash, - ] - } - - private func buildFileEvent( - fileUrls: [URL], - source: String, - hash: String - ) -> [String: Any] { - let paths = fileUrls.map { $0.path } - var eventType = 2 - - if fileUrls.count == 1 { - eventType = Self.detectFileType(url: fileUrls[0]) - } - - return [ - "type": eventType, - "files": paths, - "source": source, - "contentHash": hash, - ] - } - - // MARK: - Deduplication - - private func isDuplicate(_ hash: String) -> Bool { - let now = DispatchTime.now().uptimeNanoseconds / 1_000_000 - if hash == lastContentHash && (now - lastChangeTick) < ListenerPlugin.debounceMs { - return true - } - lastContentHash = hash - lastChangeTick = now - return false - } - - private func computeClipboardHash(_ pb: NSPasteboard) -> String { - var signature = "" - - if let text = pb.string(forType: .string), !text.isEmpty { - let sample = text.count > 100 ? String(text.prefix(100)) : text - signature += "T:" + sample - } else if let fileUrls = pb.readObjects(forClasses: [NSURL.self], options: [ - .urlReadingFileURLsOnly: true, - ]) as? [URL], !fileUrls.isEmpty { - for url in fileUrls { - signature += "F:" + url.path + "|" - } - } else if let tiffData = pb.data(forType: .tiff), !tiffData.isEmpty { - // A head-only sample makes same-sized captures collide, and a collision - // silently discards the new image in processImage. - let blocks = 16 - let blockLen = min(tiffData.count, 64) - let span = tiffData.count - blockLen - // Offsets are relative to startIndex: a Data slice does not rebase to 0. - let base = tiffData.startIndex - var sampled = Data() - for b in 0.. String { - guard let frontApp = NSWorkspace.shared.frontmostApplication else { return "" } - return frontApp.localizedName ?? frontApp.bundleIdentifier ?? "" - } - - // MARK: - File Type Detection - - static func detectFileType(url: URL) -> Int { - var isDirectory: ObjCBool = false - if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), - isDirectory.boolValue { - return 3 - } - - let ext = url.pathExtension.lowercased() - - let audioExts: Set = ["mp3", "wav", "flac", "aac", "ogg", "wma", "m4a"] - let videoExts: Set = ["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"] - let imageExts: Set = [ - "png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico", "tiff", "heic", - ] - - if audioExts.contains(ext) { return 5 } - if videoExts.contains(ext) { return 6 } - if imageExts.contains(ext) { return 1 } - return 2 - } - - // MARK: - URL Detection - - static func isUrl(_ text: String) -> Bool { - guard text.count >= 5 else { return false } - let lower = text.lowercased() - let prefixes = ["https://", "http://", "ftp://", "file:///", "mailto:"] - guard prefixes.contains(where: { lower.hasPrefix($0) }) else { return false } - return !text.contains(" ") && !text.contains("\n") - } - - // MARK: - FNV-1a Hash - - static func computeFnv1a(_ data: String) -> String { - var hash: UInt64 = 14695981039346656037 - for byte in data.utf8 { - hash ^= UInt64(byte) - hash &*= 1099511628211 - } - return String(hash, radix: 16) - } - - // MARK: - Set Clipboard Content - - private func handleSetClipboard(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let type = args["type"] as? Int else { - result(FlutterError( - code: "invalid_args", message: "Expected map with 'type'", details: nil - )) - return - } - - let success: Bool - - switch type { - case 0, 4: - let content = args["content"] as? String ?? "" - let plainText = args["plainText"] as? Bool ?? (type == 4) - - var rtfData: Data? - var htmlData: Data? - if !plainText { - if let rtfTyped = args["rtf"] as? FlutterStandardTypedData { - rtfData = rtfTyped.data - } - if let htmlTyped = args["html"] as? FlutterStandardTypedData { - htmlData = htmlTyped.data - } - } - - success = setTextToClipboard(text: content, rtf: rtfData, html: htmlData) - - case 1: - let imagePath = args["content"] as? String ?? "" - success = setImageToClipboard(imagePath: imagePath) - - case 2, 3, 5, 6: - let content = args["content"] as? String ?? "" - let paths = content.split(separator: "\n").map(String.init).filter { !$0.isEmpty } - success = setFilesToClipboard(paths: paths) - - default: - success = false - } - - lastChangeCount = NSPasteboard.general.changeCount - result(success) - } - - private func setTextToClipboard(text: String, rtf: Data?, html: Data?) -> Bool { - guard !text.isEmpty else { return false } - let pb = NSPasteboard.general - pb.clearContents() - - var types: [NSPasteboard.PasteboardType] = [.string] - if let rtf, !rtf.isEmpty { types.append(.rtf) } - if let html, !html.isEmpty { types.append(.html) } - - pb.declareTypes(types, owner: nil) - pb.setString(text, forType: .string) - - if let rtf, !rtf.isEmpty { - pb.setData(rtf, forType: .rtf) - } - if let html, !html.isEmpty { - pb.setData(html, forType: .html) - } - - return true - } - - private func setImageToClipboard(imagePath: String) -> Bool { - guard !imagePath.isEmpty else { return false } - let url = URL(fileURLWithPath: imagePath) - guard let image = NSImage(contentsOf: url) else { return false } - guard let tiffData = image.tiffRepresentation else { return false } - - let pb = NSPasteboard.general - pb.clearContents() - pb.declareTypes([.tiff, .fileURL], owner: nil) - pb.setData(tiffData, forType: .tiff) - pb.setString(url.absoluteString, forType: .fileURL) - - return true - } - - private func setFilesToClipboard(paths: [String]) -> Bool { - guard !paths.isEmpty else { return false } - let urls = paths.compactMap { path -> URL? in - let url = URL(fileURLWithPath: path) - return FileManager.default.fileExists(atPath: url.path) ? url : nil - } - guard !urls.isEmpty else { return false } - - let pb = NSPasteboard.general - pb.clearContents() - pb.writeObjects(urls as [NSPasteboardWriting]) - - return true - } - - // MARK: - Activate & Paste (CGEvent) - - private func handleActivateAndPaste(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let bundleId = args["bundleId"] as? String, - let delayMs = args["delayMs"] as? Int else { - result(Self.pasteFailure("invalidArguments")) - return - } - - if !AXIsProcessTrusted() { - result( - FlutterError( - code: "ACCESSIBILITY_DENIED", - message: "Accessibility permission not granted", - details: nil - ) - ) - return - } - - guard let app = NSRunningApplication.runningApplications( - withBundleIdentifier: bundleId - ).first else { - result(Self.pasteFailure("destinationGone")) - return - } - - app.activate() - - // focusTimeoutMs bounds how long we wait for the destination to come - // forward; delayMs is the settle time applied once it does. Conflating - // them, as this used to, means a generous paste delay silently becomes a - // longer wait and no settle at all. - let focusTimeoutMs = min(max(args["focusTimeoutMs"] as? Int ?? 250, 50), 2000) - let maxAttempts = max(focusTimeoutMs / 10, 5) - waitForFocusThenPaste( - bundleId: bundleId, - attempt: 0, - maxAttempts: maxAttempts, - settleMs: max(delayMs, 0), - result: result - ) - } - - private func waitForFocusThenPaste( - bundleId: String, - attempt: Int, - maxAttempts: Int, - settleMs: Int, - result: @escaping FlutterResult - ) { - if NSWorkspace.shared.frontmostApplication?.bundleIdentifier == bundleId { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(settleMs)) { - self.simulatePaste(result: result) - } - return - } - - if attempt >= maxAttempts { - // Posting Cmd+V now would fire it at whatever app is frontmost instead. - result(Self.pasteFailure("focusTimeout")) - return - } - - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - self.waitForFocusThenPaste( - bundleId: bundleId, - attempt: attempt + 1, - maxAttempts: maxAttempts, - settleMs: settleMs, - result: result - ) - } - } - - private static func pasteFailure(_ code: String) -> [String: Any] { - return ["success": false, "errorCode": code] - } - - private func simulatePaste(result: @escaping FlutterResult) { - let src = CGEventSource(stateID: .combinedSessionState) - let vKey: CGKeyCode = 0x09 - - guard let keyDown = CGEvent(keyboardEventSource: src, virtualKey: vKey, keyDown: true), - let keyUp = CGEvent(keyboardEventSource: src, virtualKey: vKey, keyDown: false) else { - result(Self.pasteFailure("eventCreationFailed")) - return - } - - keyDown.flags = .maskCommand - keyUp.flags = .maskCommand - keyDown.post(tap: .cghidEventTap) - keyUp.post(tap: .cghidEventTap) - result(["success": true]) - } - - // MARK: - Cursor & Screen Info - - private func handleCursorAndScreenInfo(result: @escaping FlutterResult) { - let mouseLocation = NSEvent.mouseLocation - guard let mainScreen = NSScreen.main else { - result(nil) - return - } - - let mainH = mainScreen.frame.height - let cursorX = mouseLocation.x - let cursorY = mainH - mouseLocation.y - - var info: [String: Double] = ["cursorX": cursorX, "cursorY": cursorY] - - for screen in NSScreen.screens { - if screen.frame.contains(mouseLocation) { - let vf = screen.visibleFrame - info["waLeft"] = vf.origin.x - info["waTop"] = mainH - vf.origin.y - vf.height - info["waRight"] = vf.origin.x + vf.width - info["waBottom"] = mainH - vf.origin.y - break - } - } - - if info["waLeft"] == nil { - let vf = mainScreen.visibleFrame - info["waLeft"] = vf.origin.x - info["waTop"] = mainH - vf.origin.y - vf.height - info["waRight"] = vf.origin.x + vf.width - info["waBottom"] = mainH - vf.origin.y - } - - result(info) - } - - // MARK: - Media Info - - private func handleGetMediaInfo(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let path = args["path"] as? String else { - result(nil) - return - } - - guard FileManager.default.fileExists(atPath: path) else { - result(nil) - return - } - - let url = URL(fileURLWithPath: path) - var info: [String: Any] = [:] - let asset = AVURLAsset(url: url) - - let duration = CMTimeGetSeconds(asset.duration) - if duration.isFinite && duration > 0 { - info["duration"] = Int(duration) - } - - if let videoTrack = asset.tracks(withMediaType: .video).first { - let size = videoTrack.naturalSize - let transform = videoTrack.preferredTransform - let transformedSize = size.applying(transform) - info["video_width"] = Int(abs(transformedSize.width)) - info["video_height"] = Int(abs(transformedSize.height)) - } - - for item in asset.commonMetadata { - if item.commonKey == .commonKeyArtist, - let artist = item.stringValue, !artist.isEmpty { - info["artist"] = artist - } - if item.commonKey == .commonKeyTitle, - let title = item.stringValue, !title.isEmpty { - info["title"] = title - } - if item.commonKey == .commonKeyAlbumName, - let album = item.stringValue, !album.isEmpty { - info["album"] = album - } - } - - result(info.isEmpty ? nil : info) - } - - // MARK: - Native Thumbnails (QuickLook) - - private static let nativeThumbTimeoutSec: TimeInterval = 2.0 - private static let nativeThumbBarrier = DispatchQueue(label: "copypaste.nativeThumb.barrier") - - private final class OnceFlag { var done = false } - - private func handleGetNativeThumbnail(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let path = args["path"] as? String, - !path.isEmpty, - let sizePx = args["sizePx"] as? Int, - sizePx > 0 else { - result(nil) - return - } - - let url = URL(fileURLWithPath: path) - guard FileManager.default.fileExists(atPath: url.path) else { - result(nil) - return - } - - // Dart side already pre-scales by devicePixelRatio. Treat the incoming - // value as pixels: pass `scale = 1.0` and a points size equal to the - // requested pixel side. This mirrors the Windows provider behavior so - // both platforms produce thumbs at the same effective resolution. - let pixelSide = CGFloat(sizePx) - let request = QLThumbnailGenerator.Request( - fileAt: url, - size: CGSize(width: pixelSide, height: pixelSide), - scale: 1.0, - representationTypes: .thumbnail - ) - - let flag = OnceFlag() - let returnOnce: (Any?) -> Void = { value in - ListenerPlugin.nativeThumbBarrier.async { - if flag.done { return } - flag.done = true - DispatchQueue.main.async { result(value) } - } - } - - DispatchQueue.global().asyncAfter(deadline: .now() + ListenerPlugin.nativeThumbTimeoutSec) { - QLThumbnailGenerator.shared.cancel(request) - returnOnce(nil) - } - - QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { rep, error in - if let nsError = error as NSError? { - // TCC: distinguish permission denied so the UI can surface a - // specific message (Settings → Privacy & Security) instead of a - // generic "file not found". - if nsError.domain == NSCocoaErrorDomain && - nsError.code == NSFileReadNoPermissionError { - returnOnce( - FlutterError( - code: "permissionDenied", - message: "TCC permission denied for \(path)", - details: nil - ) - ) - return - } - returnOnce(nil) - return - } - guard let rep = rep else { - returnOnce(nil) - return - } - let nsImage = rep.nsImage - guard let tiff = nsImage.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiff), - let png = bitmap.representation(using: .png, properties: [:]) else { - returnOnce(nil) - return - } - returnOnce(FlutterStandardTypedData(bytes: png)) - } - } -} - -// MARK: - FlutterStreamHandler - -extension ListenerPlugin: FlutterStreamHandler { - public func onListen( - withArguments arguments: Any?, - eventSink events: @escaping FlutterEventSink - ) -> FlutterError? { - eventSink = events - startPolling() - return nil - } - - public func onCancel(withArguments arguments: Any?) -> FlutterError? { - stopPolling() - eventSink = nil - return nil - } -} diff --git a/listener/macos/Resources/PrivacyInfo.xcprivacy b/listener/macos/Resources/PrivacyInfo.xcprivacy deleted file mode 100644 index 918d80be..00000000 --- a/listener/macos/Resources/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,12 +0,0 @@ - - - - - NSPrivacyTrackingDomains - - NSPrivacyCollectedDataTypes - - NSPrivacyTracking - - - diff --git a/listener/macos/listener.podspec b/listener/macos/listener.podspec deleted file mode 100644 index 13344d76..00000000 --- a/listener/macos/listener.podspec +++ /dev/null @@ -1,31 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint listener.podspec` to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'listener' - s.version = '0.0.1' - s.summary = 'A new Flutter plugin project.' - s.description = <<-DESC -A new Flutter plugin project. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - - # If your plugin requires a privacy manifest, for example if it collects user - # data, update the PrivacyInfo.xcprivacy file to describe your plugin's - # privacy impact, and then uncomment this line. For more information, - # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files - # s.resource_bundles = {'listener_privacy' => ['Resources/PrivacyInfo.xcprivacy']} - - s.dependency 'FlutterMacOS' - - s.platform = :osx, '10.15' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } - s.swift_version = '5.0' - s.frameworks = ['AVFoundation', 'QuickLookThumbnailing'] -end diff --git a/listener/pubspec.yaml b/listener/pubspec.yaml deleted file mode 100644 index f2f1cbdf..00000000 --- a/listener/pubspec.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: listener -description: "CopyPaste — Clipboard listener plugin (native per OS)." -version: 0.0.1 -publish_to: 'none' -resolution: workspace - -environment: - sdk: ^3.11.1 - flutter: '>=3.3.0' - -dependencies: - flutter: - sdk: flutter - plugin_platform_interface: ^2.0.2 - core: - path: ../core - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - -flutter: - plugin: - platforms: - macos: - pluginClass: ListenerPlugin - windows: - pluginClass: ListenerPluginCApi diff --git a/listener/test/clipboard_event_platform_test.dart b/listener/test/clipboard_event_platform_test.dart deleted file mode 100644 index e4f375b1..00000000 --- a/listener/test/clipboard_event_platform_test.dart +++ /dev/null @@ -1,262 +0,0 @@ -/// Platform-agnostic tests that verify ClipboardEvent parsing is robust -/// across all content types and unusual native payloads (Windows and macOS -/// both send `Map` via BasicMessageChannel). -library; - -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:listener/clipboard_event.dart'; -import 'package:core/core.dart'; - -void main() { - group('ClipboardEvent.fromMap – all content types', () { - test('parses type 0 as text', () { - final event = ClipboardEvent.fromMap({'type': 0, 'text': 'hello'}); - expect(event.type, equals(ClipboardContentType.text)); - expect(event.text, equals('hello')); - }); - - test('parses type 1 as image', () { - final bytes = Uint8List.fromList([137, 80, 78, 71]); - final event = ClipboardEvent.fromMap({'type': 1, 'bytes': bytes}); - expect(event.type, equals(ClipboardContentType.image)); - expect(event.bytes, isNotNull); - }); - - test('parses type 2 as file', () { - final event = ClipboardEvent.fromMap({ - 'type': 2, - 'files': ['/home/user/doc.pdf'], - }); - expect(event.type, equals(ClipboardContentType.file)); - expect(event.files, contains('/home/user/doc.pdf')); - }); - - test('parses type 3 as folder', () { - final event = ClipboardEvent.fromMap({ - 'type': 3, - 'files': ['/home/user/folder'], - }); - expect(event.type, equals(ClipboardContentType.folder)); - }); - - test('parses type 4 as link', () { - final event = ClipboardEvent.fromMap({ - 'type': 4, - 'text': 'https://example.com', - }); - expect(event.type, equals(ClipboardContentType.link)); - }); - - test('parses type 5 as audio', () { - final event = ClipboardEvent.fromMap({ - 'type': 5, - 'files': ['/music/song.mp3'], - }); - expect(event.type, equals(ClipboardContentType.audio)); - }); - - test('parses type 6 as video', () { - final event = ClipboardEvent.fromMap({ - 'type': 6, - 'files': ['/video/clip.mp4'], - }); - expect(event.type, equals(ClipboardContentType.video)); - }); - - test('parses type 7 as email', () { - final event = ClipboardEvent.fromMap({ - 'type': 7, - 'text': 'user@example.com', - }); - expect(event.type, equals(ClipboardContentType.email)); - }); - - test('parses type 8 as phone', () { - final event = ClipboardEvent.fromMap({ - 'type': 8, - 'text': '+1 800 555 0100', - }); - expect(event.type, equals(ClipboardContentType.phone)); - }); - - test('parses type 9 as color', () { - final event = ClipboardEvent.fromMap({'type': 9, 'text': '#FF5733'}); - expect(event.type, equals(ClipboardContentType.color)); - }); - - test('parses type 10 as ip', () { - final event = ClipboardEvent.fromMap({'type': 10, 'text': '192.168.1.1'}); - expect(event.type, equals(ClipboardContentType.ip)); - }); - - test('parses type 11 as uuid', () { - final event = ClipboardEvent.fromMap({ - 'type': 11, - 'text': '550e8400-e29b-41d4-a716-446655440000', - }); - expect(event.type, equals(ClipboardContentType.uuid)); - }); - - test('parses type 12 as json', () { - final event = ClipboardEvent.fromMap({ - 'type': 12, - 'text': '{"key":"value"}', - }); - expect(event.type, equals(ClipboardContentType.json)); - }); - - test('parses unknown type as unknown', () { - final event = ClipboardEvent.fromMap({'type': 999, 'text': 'anything'}); - expect(event.type, equals(ClipboardContentType.unknown)); - }); - }); - - group('ClipboardEvent.fromMap – source / contentHash', () { - test('parses source field', () { - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'text': 'hello', - 'source': 'com.apple.finder', - }); - expect(event.source, equals('com.apple.finder')); - }); - - test('source is null when not provided', () { - final event = ClipboardEvent.fromMap({'type': 0, 'text': 'hi'}); - expect(event.source, isNull); - }); - - test('parses contentHash field', () { - final event = ClipboardEvent.fromMap({ - 'type': 1, - 'contentHash': 'sha256-abc', - }); - expect(event.contentHash, equals('sha256-abc')); - }); - - test('contentHash defaults to empty string when not provided', () { - final event = ClipboardEvent.fromMap({'type': 0, 'text': 'hello'}); - expect(event.contentHash, equals('')); - }); - }); - - group('ClipboardEvent.fromMap – RTF and HTML bytes', () { - // Native layer sends these under the keys 'rtf' and 'html' - test('parses rtfBytes from Uint8List (key: rtf)', () { - final rtf = Uint8List.fromList([0x7B, 0x5C, 0x72, 0x74, 0x66]); - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'text': 'rich', - 'rtf': rtf, - }); - expect(event.rtfBytes, equals(rtf)); - }); - - test('parses htmlBytes from Uint8List (key: html)', () { - final html = Uint8List.fromList([0x3C, 0x62, 0x3E]); - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'text': 'rich', - 'html': html, - }); - expect(event.htmlBytes, equals(html)); - }); - - test('rtfBytes is null when rtf key not provided', () { - final event = ClipboardEvent.fromMap({'type': 0, 'text': 'plain'}); - expect(event.rtfBytes, isNull); - }); - - test('htmlBytes is null when html key not provided', () { - final event = ClipboardEvent.fromMap({'type': 0, 'text': 'plain'}); - expect(event.htmlBytes, isNull); - }); - }); - - group('ClipboardEvent.fromMap – files list edge cases', () { - test('empty files list results in empty list', () { - final event = ClipboardEvent.fromMap({'type': 2, 'files': []}); - expect(event.files, isEmpty); - }); - - test('files list with multiple paths is preserved', () { - final event = ClipboardEvent.fromMap({ - 'type': 2, - 'files': ['/a/file1.txt', '/a/file2.txt', '/a/file3.txt'], - }); - expect(event.files, hasLength(3)); - }); - - test('non-string entries in files list are filtered out', () { - final event = ClipboardEvent.fromMap({ - 'type': 2, - 'files': ['/valid/path.txt', 42, null, '/other/path.txt'], - }); - // Only string entries must be kept — non-strings filtered by whereType - expect(event.files, hasLength(2)); - expect(event.files, containsAll(['/valid/path.txt', '/other/path.txt'])); - }); - - test('missing files field results in null', () { - final event = ClipboardEvent.fromMap({'type': 2}); - expect(event.files, isNull); - }); - }); - - group('ClipboardEvent.fromMap – defaults and missing fields', () { - test('missing type defaults to unknown', () { - final event = ClipboardEvent.fromMap({}); - expect(event.type, equals(ClipboardContentType.unknown)); - }); - - test('missing text defaults to null', () { - final event = ClipboardEvent.fromMap({'type': 0}); - expect(event.text, isNull); - }); - - test('missing bytes defaults to null', () { - final event = ClipboardEvent.fromMap({'type': 1}); - expect(event.bytes, isNull); - }); - }); - - group('ClipboardEvent.fromMap – Windows-style paths', () { - test('Windows file path is preserved verbatim', () { - final event = ClipboardEvent.fromMap({ - 'type': 2, - 'files': [r'C:\Users\user\Desktop\file.txt'], - }); - expect(event.files, contains(r'C:\Users\user\Desktop\file.txt')); - }); - - test('Windows UNC path is preserved verbatim', () { - final event = ClipboardEvent.fromMap({ - 'type': 2, - 'files': [r'\\server\share\file.pdf'], - }); - expect(event.files, contains(r'\\server\share\file.pdf')); - }); - }); - - group('ClipboardEvent.fromMap – large payloads', () { - test('handles very long text without truncation', () { - final longText = 'A' * 100000; - final event = ClipboardEvent.fromMap({'type': 0, 'text': longText}); - expect(event.text!.length, equals(100000)); - }); - - test('handles large image bytes without truncation', () { - final bigImage = Uint8List(50000); - final event = ClipboardEvent.fromMap({'type': 1, 'bytes': bigImage}); - expect(event.bytes!.length, equals(50000)); - }); - - test('handles many file paths', () { - final manyPaths = List.generate(200, (i) => '/path/file_$i.txt'); - final event = ClipboardEvent.fromMap({'type': 2, 'files': manyPaths}); - expect(event.files, hasLength(200)); - }); - }); -} diff --git a/listener/test/clipboard_event_test.dart b/listener/test/clipboard_event_test.dart deleted file mode 100644 index 3ab3ca6f..00000000 --- a/listener/test/clipboard_event_test.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:listener/listener.dart'; -import 'package:core/core.dart'; - -void main() { - group('ClipboardEvent.fromMap', () { - test('parses text event with all fields', () { - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'contentHash': 'abc123', - 'text': 'Hello World', - 'source': 'Notepad', - }); - expect(event.type, equals(ClipboardContentType.text)); - expect(event.contentHash, equals('abc123')); - expect(event.text, equals('Hello World')); - expect(event.source, equals('Notepad')); - expect(event.bytes, isNull); - expect(event.files, isNull); - expect(event.rtfBytes, isNull); - expect(event.htmlBytes, isNull); - }); - - test('parses image event with bytes', () { - final bytes = Uint8List.fromList([137, 80, 78, 71]); // PNG magic - final event = ClipboardEvent.fromMap({ - 'type': 1, - 'contentHash': 'img_hash', - 'bytes': bytes, - }); - expect(event.type, equals(ClipboardContentType.image)); - expect(event.contentHash, equals('img_hash')); - expect(event.bytes, isNotNull); - expect(event.bytes!.length, equals(4)); - expect(event.text, isNull); - }); - - test('parses file event with file list', () { - final event = ClipboardEvent.fromMap({ - 'type': 2, - 'contentHash': 'file_hash', - 'files': ['C:\\file1.txt', 'C:\\file2.txt'], - }); - expect(event.type, equals(ClipboardContentType.file)); - expect(event.files, isNotNull); - expect(event.files!.length, equals(2)); - expect(event.files![0], equals('C:\\file1.txt')); - expect(event.files![1], equals('C:\\file2.txt')); - }); - - test('parses folder event', () { - final event = ClipboardEvent.fromMap({ - 'type': 3, - 'contentHash': 'folder_hash', - 'files': ['C:\\MyFolder'], - }); - expect(event.type, equals(ClipboardContentType.folder)); - }); - - test('parses link event', () { - final event = ClipboardEvent.fromMap({ - 'type': 4, - 'contentHash': 'link_hash', - 'text': 'https://example.com', - }); - expect(event.type, equals(ClipboardContentType.link)); - expect(event.text, equals('https://example.com')); - }); - - test('parses audio event', () { - final event = ClipboardEvent.fromMap({ - 'type': 5, - 'contentHash': 'audio_hash', - 'files': ['C:\\song.mp3'], - }); - expect(event.type, equals(ClipboardContentType.audio)); - }); - - test('parses video event', () { - final event = ClipboardEvent.fromMap({ - 'type': 6, - 'contentHash': 'video_hash', - 'files': ['C:\\video.mp4'], - }); - expect(event.type, equals(ClipboardContentType.video)); - }); - - test('parses rtf and html bytes', () { - final rtf = Uint8List.fromList([72, 84, 70]); - final html = Uint8List.fromList([60, 104, 116]); - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'contentHash': 'rich', - 'text': 'rich text', - 'rtf': rtf, - 'html': html, - }); - expect(event.rtfBytes, isNotNull); - expect(event.rtfBytes!.length, equals(3)); - expect(event.htmlBytes, isNotNull); - expect(event.htmlBytes!.length, equals(3)); - }); - - test('handles missing optional fields gracefully', () { - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'contentHash': 'minimal', - }); - expect(event.text, isNull); - expect(event.source, isNull); - expect(event.bytes, isNull); - expect(event.files, isNull); - expect(event.rtfBytes, isNull); - expect(event.htmlBytes, isNull); - }); - - test('handles unknown type value returns unknown', () { - final event = ClipboardEvent.fromMap({'type': 999, 'contentHash': 'u'}); - expect(event.type, equals(ClipboardContentType.unknown)); - }); - - test('handles missing type defaults to unknown', () { - final event = ClipboardEvent.fromMap({'contentHash': 'u'}); - expect(event.type, equals(ClipboardContentType.unknown)); - }); - - test('handles null type defaults to unknown', () { - final event = ClipboardEvent.fromMap({'type': null, 'contentHash': 'u'}); - expect(event.type, equals(ClipboardContentType.unknown)); - }); - - test('handles missing contentHash defaults to empty string', () { - final event = ClipboardEvent.fromMap({'type': 0}); - expect(event.contentHash, equals('')); - }); - - test('filters non-string items from files list', () { - final event = ClipboardEvent.fromMap({ - 'type': 2, - 'contentHash': 'h', - 'files': ['valid.txt', 42, null, 'also_valid.txt'], - }); - expect(event.files!.length, equals(2)); - expect(event.files![0], equals('valid.txt')); - expect(event.files![1], equals('also_valid.txt')); - }); - - test('empty files list results in empty list', () { - final event = ClipboardEvent.fromMap({ - 'type': 2, - 'contentHash': 'h', - 'files': [], - }); - expect(event.files, isNotNull); - expect(event.files, isEmpty); - }); - - test('all content types parse correctly', () { - final cases = { - 0: ClipboardContentType.text, - 1: ClipboardContentType.image, - 2: ClipboardContentType.file, - 3: ClipboardContentType.folder, - 4: ClipboardContentType.link, - 5: ClipboardContentType.audio, - 6: ClipboardContentType.video, - -1: ClipboardContentType.unknown, - }; - for (final entry in cases.entries) { - final event = ClipboardEvent.fromMap({ - 'type': entry.key, - 'contentHash': 'h', - }); - expect( - event.type, - equals(entry.value), - reason: 'type ${entry.key} should map to ${entry.value}', - ); - } - }); - }); -} diff --git a/listener/test/clipboard_writer_test.dart b/listener/test/clipboard_writer_test.dart deleted file mode 100644 index 95bc3fb5..00000000 --- a/listener/test/clipboard_writer_test.dart +++ /dev/null @@ -1,605 +0,0 @@ -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.startFileDrag', () { - test('sends method name and paths, returns drop result', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - final result = await ClipboardWriter.startFileDrag([ - '/img/photo.png', - '/docs/a.pdf', - ]); - expect(result, isTrue); - expect(captured!.method, equals('startFileDrag')); - expect( - captured!.arguments['paths'], - equals(['/img/photo.png', '/docs/a.pdf']), - ); - }); - - test('returns false without invoking channel on empty paths', () async { - var invoked = false; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - invoked = true; - return true; - }); - final result = await ClipboardWriter.startFileDrag([]); - expect(result, isFalse); - expect(invoked, isFalse); - }); - - test('returns false when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async => null); - final result = await ClipboardWriter.startFileDrag(['/img/photo.png']); - expect(result, isFalse); - }); - - test('returns false when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - throw PlatformException(code: 'error'); - }); - final result = await ClipboardWriter.startFileDrag(['/img/photo.png']); - expect(result, isFalse); - }); - }); - - 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('plain text mode strips rich clipboard metadata', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - final metadata = jsonEncode({ - 'rtf': base64Encode(utf8.encode('{\\rtf1 formatted}')), - 'html': base64Encode(utf8.encode('formatted')), - }); - - final result = await ClipboardWriter.setFromItem( - typeValue: 0, - content: 'formatted', - metadata: metadata, - plainText: true, - ); - - expect(result, isTrue); - expect(captured!.arguments['plainText'], isTrue); - expect(captured!.arguments, isNot(contains('rtf'))); - expect(captured!.arguments, isNot(contains('html'))); - }); - - 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: 'com.example.editor', - delayMs: 0, - ); - expect(result.success, isFalse); - expect(result.errorCode, equals('focusTimeout')); - }); - - 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/listener_expanded_test.dart b/listener/test/listener_expanded_test.dart deleted file mode 100644 index 6f5b7151..00000000 --- a/listener/test/listener_expanded_test.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:listener/listener.dart'; -import 'package:core/core.dart'; - -void main() { - group('Listener Plugin Interface', () { - test('creates ClipboardEvent correctly from native event', () { - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'text': 'test content', - 'contentHash': 'hash123', - 'source': 'TestApp', - }); - - expect(event.type, ClipboardContentType.text); - expect(event.text, 'test content'); - expect(event.contentHash, 'hash123'); - expect(event.source, 'TestApp'); - }); - - test('ClipboardEvent handles empty text', () { - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'text': '', - 'contentHash': 'empty_hash', - }); - - expect(event.text, isEmpty); - expect(event.contentHash, 'empty_hash'); - }); - - test('ClipboardEvent with very long text', () { - final longText = 'x' * 100000; - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'text': longText, - 'contentHash': 'big_hash', - }); - - expect(event.text, equals(longText)); - expect(event.text!.length, equals(100000)); - }); - - test('ClipboardEvent image with large URI', () { - final event = ClipboardEvent.fromMap({ - 'type': 1, - 'contentHash': 'image_hash', - 'bytes': Uint8List.fromList(List.filled(1000, 255)), - }); - - expect(event.type, ClipboardContentType.image); - expect(event.bytes!.length, equals(1000)); - }); - - test('ClipboardEvent files with multiple paths', () { - const paths = [ - 'C:\\Documents\\file1.pdf', - 'C:\\Documents\\file2.docx', - 'D:\\Photos\\image.jpg', - 'E:\\Videos\\movie.mp4', - ]; - - final event = ClipboardEvent.fromMap({ - 'type': 2, - 'contentHash': 'files_hash', - 'files': paths, - }); - - expect(event.files, hasLength(4)); - expect(event.files, containsAll(paths)); - }); - - test('ClipboardEvent with RTF and HTML formatting', () { - final rtf = Uint8List.fromList([ - 0x7B, - 0x5C, - 0x72, - 0x74, - 0x66, - 0x31, - 0x20, - 0x74, - 0x65, - 0x73, - 0x74, - 0x7D, - ]); // {\\rtf1 test} - final html = Uint8List.fromList([ - 0x3C, - 0x62, - 0x3E, - 0x74, - 0x65, - 0x73, - 0x74, - 0x3C, - 0x2F, - 0x62, - 0x3E, - ]); // test - - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'text': 'formatted text', - 'contentHash': 'formatted', - 'rtf': rtf, - 'html': html, - }); - - expect(event.rtfBytes, isNotNull); - expect(event.htmlBytes, isNotNull); - expect(event.rtfBytes!.length, equals(12)); - expect(event.htmlBytes!.length, equals(11)); - }); - - test('ClipboardEvent link type specialized event', () { - const url = 'https://github.com/user/project/issues/123'; - final event = ClipboardEvent.fromMap({ - 'type': 4, - 'text': url, - 'contentHash': 'url_hash', - 'source': 'Chrome', - }); - - expect(event.type, ClipboardContentType.link); - expect(event.text, url); - expect(event.source, 'Chrome'); - }); - - test('ClipboardEvent audio type with metadata', () { - final event = ClipboardEvent.fromMap({ - 'type': 5, - 'files': ['C:\\Music\\song.mp3'], - 'contentHash': 'audio_hash', - 'source': 'Windows Explorer', - }); - - expect(event.type, ClipboardContentType.audio); - expect(event.files!.first, contains('song.mp3')); - }); - - test('ClipboardEvent video type with multiple files', () { - const files = ['C:\\Video1.mp4', 'C:\\Video2.mp4', 'C:\\Subtitle.srt']; - - final event = ClipboardEvent.fromMap({ - 'type': 6, - 'files': files, - 'contentHash': 'video_hash', - }); - - expect(event.type, ClipboardContentType.video); - expect(event.files, hasLength(3)); - expect(event.files, equals(files)); - }); - - test('ClipboardEvent handles mixed file types in files list', () { - // Some implementations might have non-string items that need filtering - final event = ClipboardEvent.fromMap({ - 'type': 2, - 'files': ['path1.txt', 'path2.txt', 'path3.txt'], - 'contentHash': 'mixed_hash', - }); - - expect(event.files, isNotNull); - expect(event.files!.isNotEmpty, true); - }); - - test('ClipboardEvent maintains content hash uniqueness', () { - final event1 = ClipboardEvent.fromMap({ - 'type': 0, - 'text': 'content A', - 'contentHash': 'hash_A', - }); - - final event2 = ClipboardEvent.fromMap({ - 'type': 0, - 'text': 'content A', - 'contentHash': 'hash_A', - }); - - final event3 = ClipboardEvent.fromMap({ - 'type': 0, - 'text': 'content B', - 'contentHash': 'hash_B', - }); - - expect(event1.contentHash, event2.contentHash); - expect(event1.contentHash, isNot(event3.contentHash)); - }); - - test('ClipboardEvent folder type', () { - final event = ClipboardEvent.fromMap({ - 'type': 3, - 'files': ['C:\\Users\\User\\AppData'], - 'contentHash': 'folder_hash', - }); - - expect(event.type, ClipboardContentType.folder); - expect(event.files, isNotEmpty); - }); - }); -} diff --git a/listener/test/listener_test.dart b/listener/test/listener_test.dart deleted file mode 100644 index 57e33a45..00000000 --- a/listener/test/listener_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'dart:typed_data'; - -import 'package:core/core.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:listener/listener.dart'; - -void main() { - group('ClipboardEvent.fromMap', () { - test('parses text event', () { - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'text': 'hello world', - 'source': 'notepad', - 'contentHash': 'abc123', - }); - expect(event.type, ClipboardContentType.text); - expect(event.text, 'hello world'); - expect(event.source, 'notepad'); - expect(event.contentHash, 'abc123'); - expect(event.bytes, isNull); - expect(event.files, isNull); - }); - - test('parses link event', () { - final event = ClipboardEvent.fromMap({ - 'type': 4, - 'text': 'https://example.com', - 'contentHash': 'def456', - }); - expect(event.type, ClipboardContentType.link); - expect(event.text, 'https://example.com'); - }); - - test('parses files event', () { - final event = ClipboardEvent.fromMap({ - 'type': 2, - 'files': ['C:\\file1.txt', 'C:\\file2.txt'], - 'contentHash': 'xyz', - }); - expect(event.type, ClipboardContentType.file); - expect(event.files, hasLength(2)); - expect(event.files!.first, 'C:\\file1.txt'); - }); - - test('uses defaults for missing fields', () { - final event = ClipboardEvent.fromMap({}); - expect(event.type, ClipboardContentType.unknown); - expect(event.contentHash, ''); - expect(event.source, isNull); - expect(event.rtfBytes, isNull); - expect(event.htmlBytes, isNull); - }); - - test('parses rtf and html bytes', () { - final rtf = Uint8List.fromList([72, 69, 76, 76, 79]); - final html = Uint8List.fromList([60, 104, 62]); - final event = ClipboardEvent.fromMap({ - 'type': 0, - 'text': 'test', - 'contentHash': 'h1', - 'rtf': rtf, - 'html': html, - }); - expect(event.rtfBytes, equals(rtf)); - expect(event.htmlBytes, equals(html)); - }); - }); -} diff --git a/listener/test/macos_native_thumbnail_provider_test.dart b/listener/test/macos_native_thumbnail_provider_test.dart deleted file mode 100644 index a6f9356f..00000000 --- a/listener/test/macos_native_thumbnail_provider_test.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'dart:io' show Platform; - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:listener/macos_native_thumbnail_provider.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const channel = MethodChannel('copypaste/clipboard_writer'); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - group('MacOSNativeThumbnailProvider', () { - test('returns null when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async => null); - - final provider = MacOSNativeThumbnailProvider(); - final result = await provider.request( - '/Users/me/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 = MacOSNativeThumbnailProvider(); - final result = await provider.request('/Users/me/video.mp4', sizePx: 128); - - // On non-macOS hosts the platform guard short-circuits and the channel - // is never reached. Assert the bytes round-trip only when it was. - if (receivedPath != null) { - expect(result, equals(fakeBytes)); - expect(receivedPath, equals('/Users/me/video.mp4')); - 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 = MacOSNativeThumbnailProvider(); - final result = await provider.request('/Users/me/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 = MacOSNativeThumbnailProvider(); - final result = await provider.request('/Users/me/whatever.png'); - expect(result, isNull); - }); - - test('TCC permissionDenied surfaces as null without throwing', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'getNativeThumbnail') { - throw PlatformException( - code: 'permissionDenied', - message: 'TCC denied', - ); - } - return null; - }); - - final provider = MacOSNativeThumbnailProvider(); - final result = await provider.request('/Users/me/Documents/x.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 = MacOSNativeThumbnailProvider(); - 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('returns null on non-macOS hosts (platform guard)', () async { - var called = false; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - called = true; - return Uint8List.fromList([1, 2, 3]); - }); - - final provider = MacOSNativeThumbnailProvider(); - final result = await provider.request('/x', sizePx: 256); - if (!Platform.isMacOS) { - expect(result, isNull); - expect(called, isFalse); - } - }); - }); -} diff --git a/listener/test/windows_native_thumbnail_provider_test.dart b/listener/test/windows_native_thumbnail_provider_test.dart deleted file mode 100644 index 86c548a4..00000000 --- a/listener/test/windows_native_thumbnail_provider_test.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:listener/windows_native_thumbnail_provider.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const channel = MethodChannel('copypaste/clipboard_writer'); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - group('WindowsNativeThumbnailProvider', () { - test('returns null on non-Windows hosts', () async { - // The test runner here is Windows in CI/local; this test still - // covers the early-return branch because we mock the channel to - // throw, which would surface as null only via the platform guard. - // On non-Windows hosts the early `Platform.isWindows` guard takes - // over before any channel call happens. - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async => null); - - final provider = WindowsNativeThumbnailProvider(); - final result = await provider.request('C:/missing.txt', sizePx: 256); - // On Windows the mock returns null → expect null; on others the - // platform guard returns null first. Same observable behavior. - 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 = WindowsNativeThumbnailProvider(); - final result = await provider.request('C:/video.mp4', sizePx: 128); - - // Outside the platform guard this is a no-op on non-Windows hosts. - // We assert behavior conditionally: when the channel was reached, - // the bytes round-trip and the path was forwarded verbatim. - if (receivedPath != null) { - expect(result, equals(fakeBytes)); - expect(receivedPath, equals('C:/video.mp4')); - // sizePx is scaled by devicePixelRatio (>= 1.0) and clamped >= 64. - 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 = WindowsNativeThumbnailProvider(); - final result = await provider.request('C:/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 = WindowsNativeThumbnailProvider(); - final result = await provider.request('C:/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 = WindowsNativeThumbnailProvider(); - expect(await provider.request(''), isNull); - expect(await provider.request('x', sizePx: 0), isNull); - expect(await provider.request('x', sizePx: -1), isNull); - expect(called, isFalse); - }, - ); - }); -} diff --git a/listener/windows/.gitignore b/listener/windows/.gitignore deleted file mode 100644 index b3eb2be1..00000000 --- a/listener/windows/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -flutter/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ diff --git a/listener/windows/CMakeLists.txt b/listener/windows/CMakeLists.txt deleted file mode 100644 index fcd92731..00000000 --- a/listener/windows/CMakeLists.txt +++ /dev/null @@ -1,100 +0,0 @@ -# The Flutter tooling requires that developers have a version of Visual Studio -# installed that includes CMake 3.14 or later. You should not increase this -# version, as doing so will cause the plugin to fail to compile for some -# customers of the plugin. -cmake_minimum_required(VERSION 3.14) - -# Project-level configuration. -set(PROJECT_NAME "listener") -project(${PROJECT_NAME} LANGUAGES CXX) - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(VERSION 3.14...3.25) - -# This value is used when generating builds using this plugin, so it must -# not be changed -set(PLUGIN_NAME "listener_plugin") - -# Any new source files that you add to the plugin should be added here. -list(APPEND PLUGIN_SOURCES - "listener_plugin.cpp" - "listener_plugin.h" -) - -# Define the plugin library target. Its name must not be changed (see comment -# on PLUGIN_NAME above). -add_library(${PLUGIN_NAME} SHARED - "include/listener/listener_plugin_c_api.h" - "listener_plugin_c_api.cpp" - ${PLUGIN_SOURCES} -) - -# Apply a standard set of build settings that are configured in the -# application-level CMakeLists.txt. This can be removed for plugins that want -# full control over build settings. -apply_standard_settings(${PLUGIN_NAME}) - -# Symbols are hidden by default to reduce the chance of accidental conflicts -# between plugins. This should not be removed; any symbols that should be -# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. -set_target_properties(${PLUGIN_NAME} PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) - -# Source include directories and library dependencies. Add any plugin-specific -# dependencies here. -target_include_directories(${PLUGIN_NAME} INTERFACE - "${CMAKE_CURRENT_SOURCE_DIR}/include") -target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin shell32 propsys gdiplus) - -# List of absolute paths to libraries that should be bundled with the plugin. -# This list could contain prebuilt libraries, or libraries created by an -# external build triggered from this build file. -set(listener_bundled_libraries - "" - PARENT_SCOPE -) - -# === Tests === -# These unit tests can be run from a terminal after building the example, or -# from Visual Studio after opening the generated solution file. - -# Only enable test builds when building the example (which sets this variable) -# so that plugin clients aren't building the tests. -if (${include_${PROJECT_NAME}_tests}) -set(TEST_RUNNER "${PROJECT_NAME}_test") -enable_testing() - -# Add the Google Test dependency. -include(FetchContent) -FetchContent_Declare( - googletest - URL https://github.com/google/googletest/archive/release-1.11.0.zip -) -# Prevent overriding the parent project's compiler/linker settings -set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) -# Disable install commands for gtest so it doesn't end up in the bundle. -set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) -FetchContent_MakeAvailable(googletest) - -# The plugin's C API is not very useful for unit testing, so build the sources -# directly into the test binary rather than using the DLL. -add_executable(${TEST_RUNNER} - test/listener_plugin_test.cpp - ${PLUGIN_SOURCES} -) -apply_standard_settings(${TEST_RUNNER}) -target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") -target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin) -target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) -# flutter_wrapper_plugin has link dependencies on the Flutter DLL. -add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${FLUTTER_LIBRARY}" $ -) - -# Enable automatic test discovery. -include(GoogleTest) -gtest_discover_tests(${TEST_RUNNER}) -endif() diff --git a/listener/windows/include/listener/listener_plugin_c_api.h b/listener/windows/include/listener/listener_plugin_c_api.h deleted file mode 100644 index b3cf7267..00000000 --- a/listener/windows/include/listener/listener_plugin_c_api.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifndef FLUTTER_PLUGIN_LISTENER_PLUGIN_C_API_H_ -#define FLUTTER_PLUGIN_LISTENER_PLUGIN_C_API_H_ - -#include - -#ifdef FLUTTER_PLUGIN_IMPL -#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) -#else -#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) -#endif - -#if defined(__cplusplus) -extern "C" { -#endif - -FLUTTER_PLUGIN_EXPORT void ListenerPluginCApiRegisterWithRegistrar( - FlutterDesktopPluginRegistrarRef registrar); - -#if defined(__cplusplus) -} // extern "C" -#endif - -#endif // FLUTTER_PLUGIN_LISTENER_PLUGIN_C_API_H_ diff --git a/listener/windows/listener_plugin.cpp b/listener/windows/listener_plugin.cpp deleted file mode 100644 index eda82ae7..00000000 --- a/listener/windows/listener_plugin.cpp +++ /dev/null @@ -1,1415 +0,0 @@ -#include "listener_plugin.h" - -#include -#include -#include -#include -#include -#include - -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include -#include -#include -#include -#include -#include -#include -#include - -#pragma comment(lib, "gdiplus.lib") - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#pragma comment(lib, "shell32.lib") -#pragma comment(lib, "propsys.lib") -#pragma comment(lib, "ole32.lib") - -namespace listener { - -namespace { - -std::vector ConvertDibToBmp(const std::vector& dib) { - if (dib.size() < sizeof(BITMAPINFOHEADER)) return {}; - - const auto* bih = reinterpret_cast(dib.data()); - if (bih->biSize < sizeof(BITMAPINFOHEADER) || bih->biSize > dib.size()) { - return {}; - } - - // Masks and palette stack in this order, so they accumulate rather than - // replace each other. - DWORD colorTableSize = 0; - if (bih->biCompression == BI_BITFIELDS && - bih->biSize == sizeof(BITMAPINFOHEADER)) { - // BI_BITFIELDS masks only follow the header for the classic - // BITMAPINFOHEADER (40 bytes). For BITMAPV4HEADER (108) and - // BITMAPV5HEADER (124) — produced by the Windows Snipping Tool — the - // masks are embedded inside the header itself, so no extra offset. - colorTableSize += 3 * sizeof(DWORD); - } - if (bih->biBitCount <= 8) { - DWORD colors = bih->biClrUsed ? bih->biClrUsed : (1u << bih->biBitCount); - colorTableSize += colors * sizeof(RGBQUAD); - } else if (bih->biClrUsed != 0) { - // Above 8 bpp the palette is optional but still shifts the pixel offset. - // Producers leave biClrUsed dirty often enough that honouring it blindly - // misplaces bfOffBits, so only apply it when the buffer can hold it. - const uint64_t claimed = - static_cast(bih->biClrUsed) * sizeof(RGBQUAD); - if (bih->biSize + colorTableSize + claimed <= dib.size()) { - colorTableSize += static_cast(claimed); - } - } - if (bih->biSize + colorTableSize > dib.size()) return {}; - - BITMAPFILEHEADER bfh = {}; - bfh.bfType = 0x4D42; - bfh.bfSize = static_cast(sizeof(BITMAPFILEHEADER) + dib.size()); - bfh.bfOffBits = sizeof(BITMAPFILEHEADER) + bih->biSize + colorTableSize; - - std::vector bmp(sizeof(BITMAPFILEHEADER) + dib.size()); - std::memcpy(bmp.data(), &bfh, sizeof(BITMAPFILEHEADER)); - std::memcpy(bmp.data() + sizeof(BITMAPFILEHEADER), dib.data(), dib.size()); - return bmp; -} - -flutter::EncodableMap GetMediaInfo(const std::wstring& filePath) { - flutter::EncodableMap info; - - IPropertyStore* pStore = nullptr; - HRESULT hr = SHGetPropertyStoreFromParsingName( - filePath.c_str(), nullptr, GPS_DEFAULT, IID_PPV_ARGS(&pStore)); - if (FAILED(hr) || !pStore) return info; - - // Duration (100-nanosecond units → seconds as int) - PROPVARIANT pv; - PropVariantInit(&pv); - if (SUCCEEDED(pStore->GetValue(PKEY_Media_Duration, &pv)) && - pv.vt == VT_UI8) { - auto seconds = - static_cast(pv.uhVal.QuadPart / 10000000ULL); - info[flutter::EncodableValue("duration")] = - flutter::EncodableValue(seconds); - } - PropVariantClear(&pv); - - // Video dimensions - PropVariantInit(&pv); - if (SUCCEEDED(pStore->GetValue(PKEY_Video_FrameWidth, &pv)) && - pv.vt == VT_UI4) { - info[flutter::EncodableValue("video_width")] = - flutter::EncodableValue(static_cast(pv.ulVal)); - } - PropVariantClear(&pv); - - PropVariantInit(&pv); - if (SUCCEEDED(pStore->GetValue(PKEY_Video_FrameHeight, &pv)) && - pv.vt == VT_UI4) { - info[flutter::EncodableValue("video_height")] = - flutter::EncodableValue(static_cast(pv.ulVal)); - } - PropVariantClear(&pv); - - // Artist (album artist — single-valued, matches v1's FirstAlbumArtist) - PropVariantInit(&pv); - if (SUCCEEDED(pStore->GetValue(PKEY_Music_AlbumArtist, &pv))) { - PWSTR str = nullptr; - if (SUCCEEDED(PropVariantToStringAlloc(pv, &str)) && str) { - if (wcslen(str) > 0) { - info[flutter::EncodableValue("artist")] = - flutter::EncodableValue(ListenerPlugin::WideToUtf8(std::wstring(str))); - } - CoTaskMemFree(str); - } - } - PropVariantClear(&pv); - - // Title - PropVariantInit(&pv); - if (SUCCEEDED(pStore->GetValue(PKEY_Title, &pv))) { - PWSTR str = nullptr; - if (SUCCEEDED(PropVariantToStringAlloc(pv, &str)) && str) { - if (wcslen(str) > 0) { - info[flutter::EncodableValue("title")] = - flutter::EncodableValue(ListenerPlugin::WideToUtf8(std::wstring(str))); - } - CoTaskMemFree(str); - } - } - PropVariantClear(&pv); - - // Album - PropVariantInit(&pv); - if (SUCCEEDED(pStore->GetValue(PKEY_Music_AlbumTitle, &pv))) { - PWSTR str = nullptr; - if (SUCCEEDED(PropVariantToStringAlloc(pv, &str)) && str) { - if (wcslen(str) > 0) { - info[flutter::EncodableValue("album")] = - flutter::EncodableValue(ListenerPlugin::WideToUtf8(std::wstring(str))); - } - CoTaskMemFree(str); - } - } - PropVariantClear(&pv); - - pStore->Release(); - return info; -} - -class ClipboardStreamHandler - : public flutter::StreamHandler { - public: - explicit ClipboardStreamHandler(ListenerPlugin* plugin) : plugin_(plugin) {} - - protected: - std::unique_ptr> - OnListenInternal( - const flutter::EncodableValue* arguments, - std::unique_ptr>&& - events) override { - plugin_->StartListening(std::move(events)); - return nullptr; - } - - std::unique_ptr> - OnCancelInternal(const flutter::EncodableValue* arguments) override { - plugin_->StopListening(); - return nullptr; - } - - private: - ListenerPlugin* plugin_; -}; - -// Builds a CF_HDROP global (DROPFILES header + double-null-terminated wide -// paths). Caller owns the handle until it is handed to SetClipboardData. -HGLOBAL BuildDropFilesGlobal(const std::vector& wpaths) { - if (wpaths.empty()) return nullptr; - - size_t totalChars = 0; - for (const auto& wp : wpaths) totalChars += wp.size() + 1; - totalChars += 1; // extra terminator closes the double-null list - - size_t sz = sizeof(DROPFILES) + totalChars * sizeof(wchar_t); - HGLOBAL hMem = GlobalAlloc(GHND, sz); - if (!hMem) return nullptr; - - auto* df = static_cast(GlobalLock(hMem)); - if (!df) { - GlobalFree(hMem); - return nullptr; - } - - df->pFiles = sizeof(DROPFILES); - df->fWide = TRUE; - - auto* dest = reinterpret_cast( - reinterpret_cast(df) + sizeof(DROPFILES)); - for (const auto& wp : wpaths) { - memcpy(dest, wp.c_str(), (wp.size() + 1) * sizeof(wchar_t)); - dest += wp.size() + 1; - } - *dest = L'\0'; - - GlobalUnlock(hMem); - return hMem; -} - -std::wstring BaseNameW(const std::wstring& path) { - size_t pos = path.find_last_of(L"\\/"); - return pos == std::wstring::npos ? path : path.substr(pos + 1); -} - -UINT CfFileDescriptorW() { - static UINT cf = RegisterClipboardFormatW(CFSTR_FILEDESCRIPTORW); - return cf; -} - -UINT CfFileContents() { - static UINT cf = RegisterClipboardFormatW(CFSTR_FILECONTENTS); - return cf; -} - -// Names a single virtual file on the clipboard. A unique name stops Chromium -// from inventing the fixed "image.png" (localized "imagen.png") that web -// uploaders such as Gemini reject as a duplicate on the second paste. -HGLOBAL BuildFileGroupDescriptorGlobal(const std::wstring& fileName, - uint64_t fileSize) { - HGLOBAL hMem = GlobalAlloc(GHND, sizeof(FILEGROUPDESCRIPTORW)); - if (!hMem) return nullptr; - auto* fgd = static_cast(GlobalLock(hMem)); - if (!fgd) { - GlobalFree(hMem); - return nullptr; - } - fgd->cItems = 1; - auto& fd = fgd->fgd[0]; - fd.dwFlags = static_cast(FD_UNICODE) | static_cast(FD_FILESIZE); - fd.nFileSizeHigh = static_cast(fileSize >> 32); - fd.nFileSizeLow = static_cast(fileSize & 0xFFFFFFFF); - wcsncpy_s(fd.cFileName, MAX_PATH, fileName.c_str(), _TRUNCATE); - GlobalUnlock(hMem); - return hMem; -} - -// Reads a file fully into an HGLOBAL for CFSTR_FILECONTENTS (single file, -// lindex 0). Returns null on any I/O error or if the size exceeds a 2 GB single -// ReadFile; the caller then skips the virtual-file offer and keeps CF_DIBV5. -HGLOBAL BuildFileContentsGlobal(const std::wstring& filePath, - uint64_t* outSize) { - HANDLE hFile = CreateFileW(filePath.c_str(), GENERIC_READ, FILE_SHARE_READ, - nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, - nullptr); - if (hFile == INVALID_HANDLE_VALUE) return nullptr; - LARGE_INTEGER size; - if (!GetFileSizeEx(hFile, &size) || size.QuadPart <= 0 || - size.QuadPart > 0x7FFFFFFF) { - CloseHandle(hFile); - return nullptr; - } - DWORD bytes = static_cast(size.QuadPart); - HGLOBAL hMem = GlobalAlloc(GHND, bytes); - if (!hMem) { - CloseHandle(hFile); - return nullptr; - } - void* ptr = GlobalLock(hMem); - if (!ptr) { - GlobalFree(hMem); - CloseHandle(hFile); - return nullptr; - } - DWORD read = 0; - BOOL ok = ReadFile(hFile, ptr, bytes, &read, nullptr); - GlobalUnlock(hMem); - CloseHandle(hFile); - if (!ok || read != bytes) { - GlobalFree(hMem); - return nullptr; - } - if (outSize) *outSize = bytes; - return hMem; -} - -} // namespace - -void ListenerPlugin::RegisterWithRegistrar( - flutter::PluginRegistrarWindows* registrar) { - auto plugin = std::make_unique(registrar); - - auto channel = - std::make_unique>( - registrar->messenger(), "copypaste/clipboard", - &flutter::StandardMethodCodec::GetInstance()); - - auto handler = std::make_unique(plugin.get()); - channel->SetStreamHandler(std::move(handler)); - - auto method_channel = - std::make_unique>( - registrar->messenger(), "copypaste/clipboard_writer", - &flutter::StandardMethodCodec::GetInstance()); - auto* plugin_ptr = plugin.get(); - method_channel->SetMethodCallHandler( - [plugin_ptr]( - const flutter::MethodCall& call, - std::unique_ptr> - result) { - plugin_ptr->HandleMethodCall(call, std::move(result)); - }); - - registrar->AddPlugin(std::move(plugin)); -} - -ListenerPlugin::ListenerPlugin(flutter::PluginRegistrarWindows* registrar) - : registrar_(registrar) { - cf_rtf_ = RegisterClipboardFormat(L"Rich Text Format"); - cf_html_ = RegisterClipboardFormat(L"HTML Format"); - cf_exclude_history_ = RegisterClipboardFormat( - L"ExcludeClipboardContentFromMonitorProcessing"); - cf_can_include_ = - RegisterClipboardFormat(L"CanIncludeInClipboardHistory"); - - Gdiplus::GdiplusStartupInput gdipInput; - Gdiplus::GdiplusStartup(&gdip_token_, &gdipInput, nullptr); -} - -ListenerPlugin::~ListenerPlugin() { - StopListening(); - if (gdip_token_) Gdiplus::GdiplusShutdown(gdip_token_); -} - -void ListenerPlugin::StartListening( - std::unique_ptr> sink) { - std::lock_guard lock(sink_mutex_); - sink_ = std::move(sink); - - clipboard_format_registered_ = false; - - HWND hwnd = registrar_->GetView() - ? registrar_->GetView()->GetNativeWindow() - : nullptr; - // Use the top-level window for AddClipboardFormatListener so that - // WM_CLIPBOARDUPDATE arrives at the same WndProc that dispatches - // to RegisterTopLevelWindowProcDelegate callbacks. - HWND topHwnd = hwnd ? GetAncestor(hwnd, GA_ROOT) : nullptr; - if (topHwnd) { - AddClipboardFormatListener(topHwnd); - clipboard_format_registered_ = true; - } - - window_proc_id_ = registrar_->RegisterTopLevelWindowProcDelegate( - [this](HWND hwnd, UINT message, WPARAM wparam, - LPARAM lparam) -> std::optional { - return HandleWindowMessage(hwnd, message, wparam, lparam); - }); -} - -void ListenerPlugin::StopListening() { - if (window_proc_id_ >= 0) { - registrar_->UnregisterTopLevelWindowProcDelegate(window_proc_id_); - window_proc_id_ = -1; - } - - HWND hwnd = registrar_->GetView() ? registrar_->GetView()->GetNativeWindow() - : nullptr; - HWND topHwnd = hwnd ? GetAncestor(hwnd, GA_ROOT) : nullptr; - if (topHwnd) { - KillTimer(topHwnd, kClipboardTimerId); - RemoveClipboardFormatListener(topHwnd); - } - clipboard_format_registered_ = false; - - std::lock_guard lock(sink_mutex_); - sink_ = nullptr; -} - -std::optional ListenerPlugin::HandleWindowMessage( - HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { - // Deferred clipboard registration: if AddClipboardFormatListener was not - // called during StartListening (e.g. window handle not ready at plugin-start - // time after an MSIX/standalone update), register it here on the first - // window message. The hwnd passed to RegisterTopLevelWindowProcDelegate is - // always the top-level window, so no GetAncestor() call is needed. - if (!clipboard_format_registered_) { - if (AddClipboardFormatListener(hwnd)) { - clipboard_format_registered_ = true; - OutputDebugStringA( - "[CopyPaste Listener] AddClipboardFormatListener registered " - "deferred (was not ready at StartListening)\n"); - } - } - - if (message == WM_CLIPBOARDUPDATE) { - KillTimer(hwnd, kClipboardTimerId); - SetTimer(hwnd, kClipboardTimerId, kClipboardTimerDelayMs, nullptr); - } else if (message == WM_TIMER && wparam == kClipboardTimerId) { - KillTimer(hwnd, kClipboardTimerId); - OnClipboardChanged(); - } - return std::nullopt; -} - -void ListenerPlugin::OnClipboardChanged() { - HWND hwnd = registrar_->GetView() ? registrar_->GetView()->GetNativeWindow() - : nullptr; - if (!hwnd) return; - // Self-write guard: only skip while WE still own the clipboard. If an external - // app grabbed ownership inside the window, GetClipboardOwner() != hwnd and we - // must process it — otherwise a copy made right after our own write is lost. - if (last_write_tick_ > 0 && - (GetTickCount64() - last_write_tick_) < kSelfWriteIgnoreMs && - GetClipboardOwner() == hwnd) { - return; - } - - if (!OpenClipboardWithRetry(hwnd)) { - OutputDebugStringA("[ClipboardListener] OpenClipboard failed after retries\n"); - return; - } - - flutter::EncodableMap event; - - try { - if (ShouldExclude()) { - CloseClipboard(); - return; - } - - std::string hash = ComputeClipboardHash(); - if (!hash.empty() && IsDuplicate(hash)) { - CloseClipboard(); - return; - } - - std::string source = GetClipboardSource(); - - if (IsClipboardFormatAvailable(CF_HDROP)) { - auto files = ExtractFilePaths(); - if (!files.empty()) { - flutter::EncodableList file_list; - file_list.reserve(files.size()); - int event_type = 2; // file - - if (files.size() == 1) { - event_type = DetectFileType(files[0]); - } - - for (const auto& f : files) { - file_list.push_back(flutter::EncodableValue(WideToUtf8(f))); - } - - event = { - {flutter::EncodableValue("type"), - flutter::EncodableValue(event_type)}, - {flutter::EncodableValue("files"), - flutter::EncodableValue(file_list)}, - {flutter::EncodableValue("source"), - flutter::EncodableValue(source)}, - {flutter::EncodableValue("contentHash"), - flutter::EncodableValue(hash)}, - }; - } - } else if (IsClipboardFormatAvailable(CF_UNICODETEXT)) { - std::wstring text = ExtractText(); - if (!text.empty()) { - int event_type = IsUrl(text) ? 4 : 0; // link=4, text=0 - - std::vector rtf_bytes; - std::vector html_bytes; - if (cf_rtf_ && IsClipboardFormatAvailable(cf_rtf_)) { - rtf_bytes = ExtractBytes(cf_rtf_); - } - if (cf_html_ && IsClipboardFormatAvailable(cf_html_)) { - html_bytes = ExtractBytes(cf_html_); - } - - event = { - {flutter::EncodableValue("type"), - flutter::EncodableValue(event_type)}, - {flutter::EncodableValue("text"), - flutter::EncodableValue(WideToUtf8(text))}, - {flutter::EncodableValue("source"), - flutter::EncodableValue(source)}, - {flutter::EncodableValue("contentHash"), - flutter::EncodableValue(hash)}, - }; - if (!rtf_bytes.empty()) { - event[flutter::EncodableValue("rtf")] = - flutter::EncodableValue(rtf_bytes); - } - if (!html_bytes.empty()) { - event[flutter::EncodableValue("html")] = - flutter::EncodableValue(html_bytes); - } - } - } else if (IsClipboardFormatAvailable(CF_DIB)) { - auto dib = ExtractBytes(CF_DIB); - if (!dib.empty()) { - auto bytes = ConvertDibToBmp(dib); - if (!bytes.empty()) { - event = { - {flutter::EncodableValue("type"), - flutter::EncodableValue(1)}, // image=1 - {flutter::EncodableValue("bytes"), - flutter::EncodableValue(bytes)}, - {flutter::EncodableValue("source"), - flutter::EncodableValue(source)}, - {flutter::EncodableValue("contentHash"), - flutter::EncodableValue(hash)}, - }; - } - } - } - } catch (const std::exception& e) { - std::cerr << "[CopyPaste Listener] Clipboard read error: " << e.what() - << std::endl; - } catch (...) { - std::cerr << "[CopyPaste Listener] Unknown clipboard read error" - << std::endl; - } - - CloseClipboard(); - - if (!event.empty()) { - std::lock_guard lock(sink_mutex_); - if (sink_) { - sink_->Success(flutter::EncodableValue(event)); - } - } -} - -bool ListenerPlugin::ShouldExclude() const { - if (cf_exclude_history_ && IsClipboardFormatAvailable(cf_exclude_history_)) { - return true; - } - if (cf_can_include_ && IsClipboardFormatAvailable(cf_can_include_)) { - HANDLE hData = GetClipboardData(cf_can_include_); - if (hData && GlobalSize(hData) >= 4) { - const void* ptr = GlobalLock(hData); - if (ptr) { - const auto* bytes = static_cast(ptr); - int val = static_cast(bytes[0]) | (static_cast(bytes[1]) << 8) | - (static_cast(bytes[2]) << 16) | - (static_cast(bytes[3]) << 24); - GlobalUnlock(hData); - if (val == 0) return true; - } - } - } - return false; -} - -bool ListenerPlugin::IsDuplicate(const std::string& hash) { - ULONGLONG now = GetTickCount64(); - if (hash == last_content_hash_ && (now - last_change_tick_) < kDebounceMs) { - return true; - } - last_content_hash_ = hash; - last_change_tick_ = now; - return false; -} - -std::string ListenerPlugin::ComputeClipboardHash() const { - std::string signature; - - if (IsClipboardFormatAvailable(CF_UNICODETEXT)) { - std::wstring text = ExtractText(); - if (!text.empty()) { - std::wstring sample = text.size() > 100 ? text.substr(0, 100) : text; - signature += "T:" + WideToUtf8(sample); - } - } else if (IsClipboardFormatAvailable(CF_HDROP)) { - auto files = ExtractFilePaths(); - for (const auto& f : files) { - signature += "F:" + WideToUtf8(f) + "|"; - } - } else if (IsClipboardFormatAvailable(CF_DIB)) { - HANDLE hData = GetClipboardData(CF_DIB); - if (hData) { - SIZE_T sz = GlobalSize(hData); - void* ptr = GlobalLock(hData); - if (ptr) { - const uint8_t* bytes = static_cast(ptr); - std::ostringstream oss; - oss << "I:" << sz; - if (sz >= sizeof(BITMAPINFOHEADER)) { - const auto* bih = reinterpret_cast(ptr); - oss << ':' << bih->biWidth << 'x' << bih->biHeight << ':' - << bih->biBitCount << ':' << bih->biCompression << ':' - << bih->biSizeImage; - } - signature += oss.str(); - - // Sampling only the head would compare the bottom rows of a bottom-up - // DIB, so two screenshots sharing a taskbar collide. Raw bytes go into - // the signature directly: a hex dump needs zero padding to stay - // unambiguous, and getting that wrong silently drops captures. - constexpr size_t kBlocks = 16; - constexpr size_t kBlockBytes = 64; - const size_t blockLen = - static_cast((std::min)(sz, static_cast(kBlockBytes))); - const size_t span = sz > blockLen ? sz - blockLen : 0; - for (size_t b = 0; b < kBlocks; ++b) { - const size_t offset = span * b / (kBlocks - 1); - signature.append(reinterpret_cast(bytes + offset), - blockLen); - } - GlobalUnlock(hData); - } - } - } - - if (signature.empty()) return {}; - return ComputeSimpleHash(signature); -} - -std::string ListenerPlugin::GetClipboardSource() const { - HWND owner = GetClipboardOwner(); - if (!owner) return {}; - - DWORD pid = 0; - GetWindowThreadProcessId(owner, &pid); - if (!pid) return {}; - - HANDLE proc = - OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); - if (!proc) return {}; - - wchar_t name[MAX_PATH] = {}; - DWORD size = MAX_PATH; - QueryFullProcessImageNameW(proc, 0, name, &size); - CloseHandle(proc); - - std::filesystem::path p(name); - return WideToUtf8(p.stem().wstring()); -} - -std::wstring ListenerPlugin::ExtractText() { - HANDLE hData = GetClipboardData(CF_UNICODETEXT); - if (!hData) return {}; - - void* ptr = GlobalLock(hData); - if (!ptr) return {}; - - SIZE_T sz = GlobalSize(hData); - size_t maxChars = sz / sizeof(wchar_t); - if (maxChars == 0) { - GlobalUnlock(hData); - return {}; - } - - const wchar_t* wptr = static_cast(ptr); - size_t len = wcsnlen(wptr, maxChars); - std::wstring text(wptr, len); - GlobalUnlock(hData); - return text; -} - -std::vector ListenerPlugin::ExtractBytes(UINT format) { - HANDLE hData = GetClipboardData(format); - if (!hData) return {}; - - void* ptr = GlobalLock(hData); - if (!ptr) return {}; - - SIZE_T sz = GlobalSize(hData); - std::vector result(sz); - memcpy(result.data(), ptr, sz); - GlobalUnlock(hData); - return result; -} - -std::vector ListenerPlugin::ExtractFilePaths() { - HANDLE hData = GetClipboardData(CF_HDROP); - if (!hData) return {}; - - UINT count = DragQueryFileW(static_cast(hData), 0xFFFFFFFF, - nullptr, 0); - if (count == 0 || count > 10000) return {}; - - std::vector files; - files.reserve(count); - - for (UINT i = 0; i < count; ++i) { - UINT len = DragQueryFileW(static_cast(hData), i, nullptr, 0); - if (len == 0 || len > 32767) continue; - - std::vector buf(len + 1, L'\0'); - if (DragQueryFileW(static_cast(hData), i, buf.data(), - static_cast(buf.size())) > 0) { - std::wstring path(buf.data()); - if (!path.empty()) files.push_back(std::move(path)); - } - } - - return files; -} - -bool ListenerPlugin::IsUrl(const std::wstring& text) { - if (text.size() < 5) return false; - - static const std::wstring kPrefixes[] = { - L"https://", L"http://", L"ftp://", L"file:///", L"mailto:", - }; - - static constexpr size_t kMaxPrefix = 9; // longest prefix is "file:///" - const size_t checkLen = (std::min)(text.size(), kMaxPrefix); - std::wstring head(text.data(), checkLen); - std::transform(head.begin(), head.end(), head.begin(), ::towlower); - - bool matched = false; - for (const auto& prefix : kPrefixes) { - if (head.size() >= prefix.size() && - head.compare(0, prefix.size(), prefix) == 0) { - matched = true; - break; - } - } - if (!matched) return false; - - return text.find(L' ') == std::wstring::npos && - text.find(L'\n') == std::wstring::npos; -} - -int ListenerPlugin::DetectFileType(const std::wstring& path) { - DWORD attrs = GetFileAttributesW(path.c_str()); - if (attrs != INVALID_FILE_ATTRIBUTES && - (attrs & FILE_ATTRIBUTE_DIRECTORY)) { - return 3; // folder - } - - std::filesystem::path p(path); - std::wstring ext = p.extension().wstring(); - std::transform(ext.begin(), ext.end(), ext.begin(), ::towupper); - - static const std::unordered_map kExtMap = { - {L".MP3", 5}, {L".WAV", 5}, {L".FLAC", 5}, {L".AAC", 5}, - {L".OGG", 5}, {L".WMA", 5}, {L".M4A", 5}, - {L".MP4", 6}, {L".AVI", 6}, {L".MKV", 6}, {L".MOV", 6}, - {L".WMV", 6}, {L".FLV", 6}, {L".WEBM", 6}, - {L".PNG", 1}, {L".JPG", 1}, {L".JPEG", 1}, {L".GIF", 1}, - {L".BMP", 1}, {L".WEBP", 1}, {L".SVG", 1}, {L".ICO", 1}, - }; - - auto it = kExtMap.find(ext); - return it != kExtMap.end() ? it->second : 2; // default: file -} - -std::string ListenerPlugin::WideToUtf8(const std::wstring& wide) { - if (wide.empty()) return {}; - int sz = WideCharToMultiByte(CP_UTF8, 0, wide.data(), - static_cast(wide.size()), - nullptr, 0, nullptr, nullptr); - if (sz <= 0) return {}; - std::string result(sz, '\0'); - WideCharToMultiByte(CP_UTF8, 0, wide.data(), - static_cast(wide.size()), - result.data(), sz, nullptr, nullptr); - return result; -} - -// Another app can hold the clipboard lock briefly — Chromium and Electron do -// it on every copy — so a single attempt drops the operation silently. -bool ListenerPlugin::OpenClipboardWithRetry(HWND hwnd) { - for (int attempt = 0; attempt < kOpenClipboardRetries; ++attempt) { - if (OpenClipboard(hwnd)) return true; - Sleep(kOpenClipboardBackoffMs[attempt]); - } - return false; -} - -std::string ListenerPlugin::ComputeSimpleHash(const std::string& data) { - // FNV-1a 64-bit hash - uint64_t hash = 14695981039346656037ULL; - for (unsigned char c : data) { - hash ^= c; - hash *= 1099511628211ULL; - } - std::ostringstream oss; - oss << std::hex << hash; - return oss.str(); -} - -std::wstring ListenerPlugin::Utf8ToWide(const std::string& utf8) { - if (utf8.empty()) return {}; - int sz = MultiByteToWideChar(CP_UTF8, 0, utf8.data(), - static_cast(utf8.size()), - nullptr, 0); - if (sz <= 0) return {}; - std::wstring result(sz, L'\0'); - MultiByteToWideChar(CP_UTF8, 0, utf8.data(), - static_cast(utf8.size()), - result.data(), sz); - return result; -} - -void ListenerPlugin::HandleMethodCall( - const flutter::MethodCall& call, - std::unique_ptr> result) { - if (call.method_name() == "getMediaInfo") { - const auto* args = - std::get_if(call.arguments()); - if (!args) { - result->Success(flutter::EncodableValue()); - return; - } - auto path_it = args->find(flutter::EncodableValue("path")); - if (path_it == args->end()) { - result->Success(flutter::EncodableValue()); - return; - } - std::string pathUtf8 = std::get(path_it->second); - auto info = GetMediaInfo(Utf8ToWide(pathUtf8)); - if (info.empty()) { - result->Success(flutter::EncodableValue()); - } else { - result->Success(flutter::EncodableValue(info)); - } - return; - } - - if (call.method_name() == "getNativeThumbnail") { - const auto* args = - std::get_if(call.arguments()); - if (!args) { - result->Success(flutter::EncodableValue()); - return; - } - auto path_it = args->find(flutter::EncodableValue("path")); - if (path_it == args->end()) { - result->Success(flutter::EncodableValue()); - return; - } - const std::string path_utf8 = std::get(path_it->second); - - int size_px = 256; - auto size_it = args->find(flutter::EncodableValue("sizePx")); - if (size_it != args->end()) { - size_px = std::get(size_it->second); - } - if (size_px <= 0) size_px = 256; - - auto bytes = GetNativeThumbnail(Utf8ToWide(path_utf8), size_px); - if (bytes.empty()) { - result->Success(flutter::EncodableValue()); - } else { - result->Success(flutter::EncodableValue(std::move(bytes))); - } - return; - } - - if (call.method_name() == "startFileDrag") { - const auto* args = - std::get_if(call.arguments()); - if (!args) { - result->Success(flutter::EncodableValue(false)); - return; - } - auto paths_it = args->find(flutter::EncodableValue("paths")); - if (paths_it == args->end()) { - result->Success(flutter::EncodableValue(false)); - return; - } - const auto* list = - std::get_if(&paths_it->second); - if (!list) { - result->Success(flutter::EncodableValue(false)); - return; - } - std::vector paths; - paths.reserve(list->size()); - for (const auto& v : *list) { - if (const auto* s = std::get_if(&v)) paths.push_back(*s); - } - result->Success(flutter::EncodableValue(StartFileDrag(paths))); - return; - } - - if (call.method_name() != "setClipboardContent") { - result->NotImplemented(); - return; - } - - const auto* args = - std::get_if(call.arguments()); - if (!args) { - result->Error("invalid_args", "Expected map arguments"); - return; - } - - auto type_it = args->find(flutter::EncodableValue("type")); - if (type_it == args->end()) { - result->Error("missing_type", "Missing 'type' argument"); - return; - } - int type = std::get(type_it->second); - - bool success = false; - - if (type == 0 || type == 4) { // text or link - auto content_it = args->find(flutter::EncodableValue("content")); - std::string content = - content_it != args->end() - ? std::get(content_it->second) - : ""; - - std::vector rtf; - auto rtf_it = args->find(flutter::EncodableValue("rtf")); - if (rtf_it != args->end()) { - rtf = std::get>(rtf_it->second); - } - - std::vector html; - auto html_it = args->find(flutter::EncodableValue("html")); - if (html_it != args->end()) { - html = std::get>(html_it->second); - } - - bool plain = (type == 4); - auto plain_it = args->find(flutter::EncodableValue("plainText")); - if (plain_it != args->end()) { - plain = std::get(plain_it->second); - } - - if (plain) { - success = SetTextToClipboard(content, {}, {}); - } else { - success = SetTextToClipboard(content, rtf, html); - } - } else if (type == 1) { // image - auto content_it = args->find(flutter::EncodableValue("content")); - std::string imagePath = - content_it != args->end() - ? std::get(content_it->second) - : ""; - success = SetImageToClipboard(imagePath); - } else if (type >= 2 && type <= 6) { // file, folder, audio, video - auto content_it = args->find(flutter::EncodableValue("content")); - std::string content = - content_it != args->end() - ? std::get(content_it->second) - : ""; - std::vector paths; - std::istringstream iss(content); - std::string line; - while (std::getline(iss, line)) { - if (!line.empty()) paths.push_back(line); - } - success = SetFilesToClipboard(paths); - } - - result->Success(flutter::EncodableValue(success)); -} - -bool ListenerPlugin::SetTextToClipboard( - const std::string& text, - const std::vector& rtf, - const std::vector& html) { - if (text.empty()) return false; - - HWND hwnd = registrar_->GetView() - ? registrar_->GetView()->GetNativeWindow() - : nullptr; - if (!OpenClipboardWithRetry(hwnd)) return false; - - EmptyClipboard(); - bool ok = false; - - std::wstring wide = Utf8ToWide(text); - if (wide.empty()) { - CloseClipboard(); - return false; - } - size_t sz = (wide.size() + 1) * sizeof(wchar_t); - HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, sz); - if (hMem) { - void* ptr = GlobalLock(hMem); - if (ptr) { - memcpy(ptr, wide.c_str(), sz); - GlobalUnlock(hMem); - if (SetClipboardData(CF_UNICODETEXT, hMem)) { - ok = true; - } else { - GlobalFree(hMem); - } - } else { - GlobalFree(hMem); - } - } - - if (ok && !rtf.empty() && cf_rtf_) { - HGLOBAL hRtf = GlobalAlloc(GMEM_MOVEABLE, rtf.size() + 1); - if (hRtf) { - void* ptr = GlobalLock(hRtf); - if (ptr) { - memcpy(ptr, rtf.data(), rtf.size()); - static_cast(ptr)[rtf.size()] = '\0'; - GlobalUnlock(hRtf); - if (!SetClipboardData(cf_rtf_, hRtf)) { - GlobalFree(hRtf); - } - } else { - GlobalFree(hRtf); - } - } - } - - if (ok && !html.empty() && cf_html_) { - HGLOBAL hHtml = GlobalAlloc(GMEM_MOVEABLE, html.size() + 1); - if (hHtml) { - void* ptr = GlobalLock(hHtml); - if (ptr) { - memcpy(ptr, html.data(), html.size()); - static_cast(ptr)[html.size()] = '\0'; - GlobalUnlock(hHtml); - if (!SetClipboardData(cf_html_, hHtml)) { - GlobalFree(hHtml); - } - } else { - GlobalFree(hHtml); - } - } - } - - CloseClipboard(); - if (ok) last_write_tick_ = GetTickCount64(); - return ok; -} - -bool ListenerPlugin::SetImageToClipboard(const std::string& imagePath) { - if (imagePath.empty()) return false; - - std::wstring wpath = Utf8ToWide(imagePath); - - Gdiplus::Bitmap bitmap(wpath.c_str()); - if (bitmap.GetLastStatus() != Gdiplus::Ok) return false; - - HBITMAP hBitmap = nullptr; - Gdiplus::Color bg(255, 255, 255, 255); - if (bitmap.GetHBITMAP(bg, &hBitmap) != Gdiplus::Ok || !hBitmap) - return false; - - BITMAP bm = {}; - GetObject(hBitmap, sizeof(bm), &bm); - - size_t rowBytes = static_cast(bm.bmWidth) * 4; - size_t imgSize = rowBytes * bm.bmHeight; - - BITMAPV5HEADER bv5 = {}; - bv5.bV5Size = sizeof(BITMAPV5HEADER); - bv5.bV5Width = bm.bmWidth; - bv5.bV5Height = bm.bmHeight; - bv5.bV5Planes = 1; - bv5.bV5BitCount = 32; - bv5.bV5Compression = BI_BITFIELDS; - bv5.bV5SizeImage = static_cast(imgSize); - bv5.bV5RedMask = 0x00FF0000; - bv5.bV5GreenMask = 0x0000FF00; - bv5.bV5BlueMask = 0x000000FF; - bv5.bV5AlphaMask = 0xFF000000; - bv5.bV5CSType = LCS_WINDOWS_COLOR_SPACE; - bv5.bV5Intent = LCS_GM_IMAGES; - - size_t dibSize = sizeof(BITMAPV5HEADER) + imgSize; - HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, dibSize); - if (!hMem) { - DeleteObject(hBitmap); - return false; - } - - void* ptr = GlobalLock(hMem); - if (!ptr) { - GlobalFree(hMem); - DeleteObject(hBitmap); - return false; - } - - std::memcpy(ptr, &bv5, sizeof(BITMAPV5HEADER)); - - BITMAPINFOHEADER bih = {}; - bih.biSize = sizeof(BITMAPINFOHEADER); - bih.biWidth = bm.bmWidth; - bih.biHeight = bm.bmHeight; - bih.biPlanes = 1; - bih.biBitCount = 32; - bih.biCompression = BI_RGB; - bih.biSizeImage = static_cast(imgSize); - - HDC hDC = GetDC(nullptr); - auto* bi = reinterpret_cast(&bih); - int scanLines = GetDIBits( - hDC, hBitmap, 0, bm.bmHeight, - static_cast(ptr) + sizeof(BITMAPV5HEADER), - bi, DIB_RGB_COLORS); - ReleaseDC(nullptr, hDC); - - if (scanLines > 0) { - uint8_t* pixels = static_cast(ptr) + sizeof(BITMAPV5HEADER); - for (size_t i = 3; i < imgSize; i += 4) { - pixels[i] = 0xFF; - } - } - - GlobalUnlock(hMem); - DeleteObject(hBitmap); - - if (scanLines == 0) { - GlobalFree(hMem); - return false; - } - - // CF_DIBV5 covers inline-paste targets (image editors, rich-text composers). - // The virtual-file pair below covers web uploaders; see the SetClipboardData - // calls. We deliberately do NOT use CF_HDROP: browsers ignore it on paste and - // it confused the listener's own read path (CF_HDROP first) on self-writes. - uint64_t pngSize = 0; - HGLOBAL hContents = BuildFileContentsGlobal(wpath, &pngSize); - HGLOBAL hDesc = hContents - ? BuildFileGroupDescriptorGlobal(BaseNameW(wpath), pngSize) - : nullptr; - - HWND hwnd = registrar_->GetView() - ? registrar_->GetView()->GetNativeWindow() - : nullptr; - if (!OpenClipboardWithRetry(hwnd)) { - GlobalFree(hMem); - if (hContents) GlobalFree(hContents); - if (hDesc) GlobalFree(hDesc); - return false; - } - - EmptyClipboard(); - bool ok = SetClipboardData(CF_DIBV5, hMem) != nullptr; - if (!ok) GlobalFree(hMem); - - // Offer the PNG as a named virtual file so Chromium hands web uploaders our - // unique .png instead of inventing the fixed "imagen.png" that those - // sites reject as a duplicate. Descriptor and contents go together or not at - // all. CFSTR_FILECONTENTS via the flat clipboard is read at lindex 0 by the - // IDataObject that OleGetClipboard synthesizes for the consumer. - if (hContents && hDesc) { - if (SetClipboardData(CfFileDescriptorW(), hDesc) == nullptr) { - GlobalFree(hDesc); - GlobalFree(hContents); - } else if (SetClipboardData(CfFileContents(), hContents) == nullptr) { - GlobalFree(hContents); - } - } else { - if (hContents) GlobalFree(hContents); - if (hDesc) GlobalFree(hDesc); - } - - CloseClipboard(); - if (ok) last_write_tick_ = GetTickCount64(); - return ok; -} - -bool ListenerPlugin::SetFilesToClipboard( - const std::vector& paths) { - if (paths.empty()) return false; - - std::vector wpaths; - wpaths.reserve(paths.size()); - for (const auto& p : paths) { - auto wp = Utf8ToWide(p); - if (wp.empty()) continue; - if (GetFileAttributesW(wp.c_str()) == INVALID_FILE_ATTRIBUTES) continue; - wpaths.push_back(std::move(wp)); - } - - if (wpaths.empty()) return false; - - HGLOBAL hMem = BuildDropFilesGlobal(wpaths); - if (!hMem) return false; - - HWND hwnd = registrar_->GetView() - ? registrar_->GetView()->GetNativeWindow() - : nullptr; - if (!OpenClipboardWithRetry(hwnd)) { - GlobalFree(hMem); - return false; - } - - EmptyClipboard(); - bool ok = SetClipboardData(CF_HDROP, hMem) != nullptr; - if (!ok) GlobalFree(hMem); - - CloseClipboard(); - if (ok) last_write_tick_ = GetTickCount64(); - return ok; -} - -namespace { - -// Minimal drag source: drop on left-button release, cancel on Escape. -class FileDropSource : public IDropSource { - public: - FileDropSource() : ref_(1) {} - - HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override { - if (riid == IID_IUnknown || riid == IID_IDropSource) { - *ppv = static_cast(this); - AddRef(); - return S_OK; - } - *ppv = nullptr; - return E_NOINTERFACE; - } - - ULONG STDMETHODCALLTYPE AddRef() override { - return InterlockedIncrement(&ref_); - } - - ULONG STDMETHODCALLTYPE Release() override { - LONG c = InterlockedDecrement(&ref_); - if (c == 0) delete this; - return c; - } - - HRESULT STDMETHODCALLTYPE QueryContinueDrag(BOOL fEscapePressed, - DWORD grfKeyState) override { - if (fEscapePressed) return DRAGDROP_S_CANCEL; - if (!(grfKeyState & MK_LBUTTON)) return DRAGDROP_S_DROP; - return S_OK; - } - - HRESULT STDMETHODCALLTYPE GiveFeedback(DWORD /*dwEffect*/) override { - return DRAGDROP_S_USEDEFAULTCURSORS; - } - - private: - LONG ref_; -}; - -// Offers [paths] as CF_HDROP files through a modal OLE drag. The blocking -// DoDragDrop loop runs on the platform thread, so the host window cannot process -// its focus-loss auto-hide until the drag ends. Returns true on a real drop. -bool DoFilesDragDrop(const std::vector& paths) { - std::vector valid; - valid.reserve(paths.size()); - for (const auto& p : paths) { - if (!p.empty() && GetFileAttributesW(p.c_str()) != INVALID_FILE_ATTRIBUTES) - valid.push_back(p); - } - if (valid.empty()) return false; - - HRESULT ole_hr = OleInitialize(nullptr); - bool ole_inited = SUCCEEDED(ole_hr); - - bool dropped = false; - IDataObject* data_object = nullptr; - HRESULT hr = SHCreateDataObject(nullptr, 0, nullptr, nullptr, - IID_PPV_ARGS(&data_object)); - if (SUCCEEDED(hr) && data_object) { - HGLOBAL drop = BuildDropFilesGlobal(valid); - if (drop) { - FORMATETC fmt = {}; - fmt.cfFormat = CF_HDROP; - fmt.dwAspect = DVASPECT_CONTENT; - fmt.lindex = -1; - fmt.tymed = TYMED_HGLOBAL; - - STGMEDIUM stg = {}; - stg.tymed = TYMED_HGLOBAL; - stg.hGlobal = drop; - - // fRelease=TRUE hands ownership of the medium to the data object. - if (SUCCEEDED(data_object->SetData(&fmt, &stg, TRUE))) { - auto* drop_source = new FileDropSource(); - DWORD effect = 0; - HRESULT dd = - DoDragDrop(data_object, drop_source, DROPEFFECT_COPY, &effect); - dropped = (dd == DRAGDROP_S_DROP) && (effect != DROPEFFECT_NONE); - drop_source->Release(); - } else { - GlobalFree(drop); - } - } - data_object->Release(); - } - - if (ole_inited) OleUninitialize(); - return dropped; -} - -} // namespace - -bool ListenerPlugin::StartFileDrag(const std::vector& paths) { - std::vector wpaths; - wpaths.reserve(paths.size()); - for (const auto& p : paths) { - if (p.empty()) continue; - auto wp = Utf8ToWide(p); - if (!wp.empty()) wpaths.push_back(std::move(wp)); - } - return DoFilesDragDrop(wpaths); -} - -// --- Native shell thumbnail extraction (PR #6b) ---------------------------- - -namespace { - -// CLSID for the PNG image encoder built into GDI+. -// {557CF406-1A04-11D3-9A73-0000F81EF32E} -constexpr CLSID kPngEncoderClsid = { - 0x557CF406, - 0x1A04, - 0x11D3, - {0x9A, 0x73, 0x00, 0x00, 0xF8, 0x1E, 0xF3, 0x2E}}; - -// Encodes [bitmap] as PNG into a fresh byte buffer. Returns empty on failure. -std::vector EncodePng(Gdiplus::Bitmap& bitmap) { - IStream* stream = nullptr; - if (CreateStreamOnHGlobal(nullptr, TRUE, &stream) != S_OK || !stream) { - return {}; - } - - std::vector out; - if (bitmap.Save(stream, &kPngEncoderClsid, nullptr) == Gdiplus::Ok) { - HGLOBAL h_global = nullptr; - if (GetHGlobalFromStream(stream, &h_global) == S_OK && h_global) { - const SIZE_T size = GlobalSize(h_global); - void* ptr = GlobalLock(h_global); - if (ptr && size > 0) { - out.assign(static_cast(ptr), - static_cast(ptr) + size); - } - if (ptr) GlobalUnlock(h_global); - } - } - stream->Release(); - return out; -} - -} // namespace - -std::vector ListenerPlugin::GetNativeThumbnail( - const std::wstring& path, int size_px) { - if (path.empty() || size_px <= 0) return {}; - - // Defensive: most Flutter platform-thread invocations are already in an - // STA, but `CoInitializeEx` is idempotent if the apartment matches and - // simply returns S_FALSE. RPC_E_CHANGED_MODE means another COM apartment - // is active — in that case we proceed without our own init. - HRESULT init_hr = - CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); - bool needs_uninit = SUCCEEDED(init_hr); - - std::vector result; - IShellItemImageFactory* factory = nullptr; - HRESULT hr = SHCreateItemFromParsingName(path.c_str(), nullptr, - IID_PPV_ARGS(&factory)); - if (SUCCEEDED(hr) && factory) { - SIZE size = {size_px, size_px}; - HBITMAP hbmp = nullptr; - - // First attempt: cache-only, fast path. This avoids extractor invocation - // (no COM-out-of-process work, no I/O on the source file). - hr = factory->GetImage( - size, SIIGBF_THUMBNAILONLY | SIIGBF_INCACHEONLY, &hbmp); - - // The shell may report E_PENDING when the cache entry is being built. - // Retry once without INCACHEONLY to let it materialize. We keep - // THUMBNAILONLY so we never get back a generic icon for unknown types. - if (hr == E_PENDING) { - hr = factory->GetImage(size, SIIGBF_THUMBNAILONLY, &hbmp); - } - - if (SUCCEEDED(hr) && hbmp) { - BITMAP bm = {}; - if (GetObject(hbmp, sizeof(bm), &bm) != 0) { - // Discard tiny bitmaps: when the OS has no real thumbnail it can - // still return a 32x32 file-type icon despite THUMBNAILONLY in - // some shell versions. Anything <= 64 px on either side at a 256 - // px request is treated as a generic icon. - const bool too_small = bm.bmWidth <= 64 || bm.bmHeight <= 64; - if (!too_small) { - Gdiplus::Bitmap gdi_bitmap(hbmp, nullptr); - if (gdi_bitmap.GetLastStatus() == Gdiplus::Ok) { - result = EncodePng(gdi_bitmap); - } - } - } - DeleteObject(hbmp); - } - factory->Release(); - } - - if (needs_uninit) CoUninitialize(); - return result; -} - -} // namespace listener - diff --git a/listener/windows/listener_plugin.h b/listener/windows/listener_plugin.h deleted file mode 100644 index 004b4ad5..00000000 --- a/listener/windows/listener_plugin.h +++ /dev/null @@ -1,112 +0,0 @@ -#ifndef FLUTTER_PLUGIN_LISTENER_PLUGIN_H_ -#define FLUTTER_PLUGIN_LISTENER_PLUGIN_H_ - -#include -#include -#include -#include -#include - -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include - -#include -#include - -#include -#include -#include -#include - -namespace listener { - -class ListenerPlugin : public flutter::Plugin { - public: - static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); - - explicit ListenerPlugin(flutter::PluginRegistrarWindows* registrar); - ~ListenerPlugin() override; - - ListenerPlugin(const ListenerPlugin&) = delete; - ListenerPlugin& operator=(const ListenerPlugin&) = delete; - - static std::string WideToUtf8(const std::wstring& wide); - - void StartListening( - std::unique_ptr> sink); - void StopListening(); - - void HandleMethodCall( - const flutter::MethodCall& call, - std::unique_ptr> result); - - private: - flutter::PluginRegistrarWindows* registrar_; - int window_proc_id_ = -1; - - std::mutex sink_mutex_; - std::unique_ptr> sink_; - - std::string last_content_hash_; - ULONGLONG last_change_tick_ = 0; - static constexpr ULONGLONG kDebounceMs = 500; - static constexpr UINT_PTR kClipboardTimerId = 1; - static constexpr UINT kClipboardTimerDelayMs = 50; - static constexpr ULONGLONG kSelfWriteIgnoreMs = 700; - static constexpr int kOpenClipboardRetries = 3; - static constexpr DWORD kOpenClipboardBackoffMs[] = {5, 10, 20}; - - ULONGLONG last_write_tick_ = 0; - - bool clipboard_format_registered_ = false; - - UINT cf_rtf_ = 0; - UINT cf_html_ = 0; - UINT cf_exclude_history_ = 0; - UINT cf_can_include_ = 0; - ULONG_PTR gdip_token_ = 0; - - std::optional HandleWindowMessage(HWND hwnd, UINT message, - WPARAM wparam, LPARAM lparam); - void OnClipboardChanged(); - bool ShouldExclude() const; - bool IsDuplicate(const std::string& hash); - std::string ComputeClipboardHash() const; - std::string GetClipboardSource() const; - - static bool OpenClipboardWithRetry(HWND hwnd); - - static std::wstring ExtractText(); - static std::vector ExtractBytes(UINT format); - static std::vector ExtractFilePaths(); - static bool IsUrl(const std::wstring& text); - static int DetectFileType(const std::wstring& path); - static std::wstring Utf8ToWide(const std::string& utf8); - static std::string ComputeSimpleHash(const std::string& data); - - bool SetTextToClipboard(const std::string& text, - const std::vector& rtf, - const std::vector& html); - bool SetImageToClipboard(const std::string& imagePath); - bool SetFilesToClipboard(const std::vector& paths); - - // Starts a modal OLE drag-drop offering [paths] as CF_HDROP files, so a drop - // target (e.g. a browser upload zone) receives them with their real, unique - // names instead of the fixed "image.png" Chromium synthesizes for pasted - // bitmaps. Blocks the platform thread until the drag ends. - bool StartFileDrag(const std::vector& paths); - - // Native shell thumbnail extraction (PR #6b). Returns the encoded PNG - // bytes, or an empty vector when no usable thumbnail is available. - // Runs synchronously on the platform thread; the cache-hit fast path - // is typically < 50 ms. - static std::vector GetNativeThumbnail(const std::wstring& path, - int size_px); -}; - -} // namespace listener - -#endif // FLUTTER_PLUGIN_LISTENER_PLUGIN_H_ - diff --git a/listener/windows/listener_plugin_c_api.cpp b/listener/windows/listener_plugin_c_api.cpp deleted file mode 100644 index af248c91..00000000 --- a/listener/windows/listener_plugin_c_api.cpp +++ /dev/null @@ -1,12 +0,0 @@ -#include "include/listener/listener_plugin_c_api.h" - -#include - -#include "listener_plugin.h" - -void ListenerPluginCApiRegisterWithRegistrar( - FlutterDesktopPluginRegistrarRef registrar) { - listener::ListenerPlugin::RegisterWithRegistrar( - flutter::PluginRegistrarManager::GetInstance() - ->GetRegistrar(registrar)); -} diff --git a/listener/windows/test/listener_plugin_test.cpp b/listener/windows/test/listener_plugin_test.cpp deleted file mode 100644 index 874dd6d8..00000000 --- a/listener/windows/test/listener_plugin_test.cpp +++ /dev/null @@ -1,27 +0,0 @@ -#include -#include - -#include - -#include "listener_plugin.h" - -namespace listener { -namespace test { - -TEST(ListenerPlugin, ClassCompiles) { - // ListenerPlugin requires a real PluginRegistrarWindows* to construct, - // so we only verify the class definition links correctly. - // Integration tests cover actual clipboard monitoring behavior. - SUCCEED(); -} - -TEST(ListenerPlugin, WideToUtf8RoundTrip) { - // Verify the WideToUtf8 utility via the public header. - // The actual conversion is tested implicitly through clipboard events. - std::wstring input = L"Hello CopyPaste"; - std::string utf8(input.begin(), input.end()); - EXPECT_EQ(utf8, "Hello CopyPaste"); -} - -} // namespace test -} // namespace listener diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index efc9a96b..00000000 --- a/pubspec.lock +++ /dev/null @@ -1,1135 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" - url: "https://pub.dev" - source: hosted - version: "93.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b - url: "https://pub.dev" - source: hosted - version: "10.0.1" - ansi_styles: - dependency: transitive - description: - name: ansi_styles - sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a" - url: "https://pub.dev" - source: hosted - version: "0.3.2+1" - archive: - dependency: transitive - description: - name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff - url: "https://pub.dev" - source: hosted - version: "4.0.9" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 - url: "https://pub.dev" - source: hosted - version: "4.0.6" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 - url: "https://pub.dev" - source: hosted - version: "4.1.1" - build_runner: - dependency: transitive - description: - name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" - url: "https://pub.dev" - source: hosted - version: "2.15.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" - url: "https://pub.dev" - source: hosted - version: "8.12.6" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_config: - dependency: transitive - description: - name: cli_config - sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec - url: "https://pub.dev" - source: hosted - version: "0.2.0" - cli_launcher: - dependency: transitive - description: - name: cli_launcher - sha256: "35cf15a3ffaeb9c11849eaa0afba761bb76dceb42d050532bfd3e1299c9748cd" - url: "https://pub.dev" - source: hosted - version: "0.3.3+1" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - console: - dependency: transitive - description: - name: console - sha256: e04e7824384c5b39389acdd6dc7d33f3efe6b232f6f16d7626f194f6a01ad69a - url: "https://pub.dev" - source: hosted - version: "4.1.0" - conventional_commit: - dependency: transitive - description: - name: conventional_commit - sha256: c40b1b449ce2a63fa2ce852f35e3890b1e182f5951819934c0e4a66254bc0dc3 - url: "https://pub.dev" - source: hosted - version: "0.6.1+1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" - url: "https://pub.dev" - source: hosted - version: "1.15.0" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" - url: "https://pub.dev" - source: hosted - version: "0.3.5+2" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - cryptography: - dependency: transitive - description: - name: cryptography - sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" - url: "https://pub.dev" - source: hosted - version: "2.9.0" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" - url: "https://pub.dev" - source: hosted - version: "3.1.7" - dbus: - dependency: transitive - description: - name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 - url: "https://pub.dev" - source: hosted - version: "0.7.12" - drift: - dependency: transitive - description: - name: drift - sha256: "055c249d1f91be5a47fe447f88afc24c4ca6f4cd6c5ed66767b4797d48acc2e5" - url: "https://pub.dev" - source: hosted - version: "2.32.1" - drift_dev: - dependency: transitive - description: - name: drift_dev - sha256: "88a9de3af8571518148a6d8a513b57779fd1e60a026d3ab8a481a878fba01d91" - url: "https://pub.dev" - source: hosted - version: "2.32.1" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - file_picker: - dependency: transitive - description: - name: file_picker - sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 - url: "https://pub.dev" - source: hosted - version: "11.0.2" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_acrylic: - dependency: transitive - description: - name: flutter_acrylic - sha256: b3996dbde5abf5823cc9ead4cf2e5267c3181f15585fe47ce4dc4472e7ec827a - url: "https://pub.dev" - source: hosted - version: "1.1.4" - flutter_lints: - dependency: transitive - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_localizations: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" - url: "https://pub.dev" - source: hosted - version: "2.0.34" - flutter_test: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - get_it: - dependency: transitive - description: - name: get_it - sha256: "568d62f0e68666fb5d95519743b3c24a34c7f19d834b0658c46e26d778461f66" - url: "https://pub.dev" - source: hosted - version: "9.2.1" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - hooks: - dependency: transitive - description: - name: hooks - sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" - url: "https://pub.dev" - source: hosted - version: "1.0.3" - hotkey_manager: - dependency: transitive - description: - name: hotkey_manager - sha256: "06f0655b76c8dd322fb7101dc615afbdbf39c3d3414df9e059c33892104479cd" - url: "https://pub.dev" - source: hosted - version: "0.2.3" - hotkey_manager_linux: - dependency: transitive - description: - name: hotkey_manager_linux - sha256: "83676bda8210a3377bc6f1977f193bc1dbdd4c46f1bdd02875f44b6eff9a8473" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - hotkey_manager_macos: - dependency: transitive - description: - name: hotkey_manager_macos - sha256: "03b5967e64357b9ac05188ea4a5df6fe4ed4205762cb80aaccf8916ee1713c96" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - hotkey_manager_platform_interface: - dependency: transitive - description: - name: hotkey_manager_platform_interface - sha256: "98ffca25b8cc9081552902747b2942e3bc37855389a4218c9d50ca316b653b13" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - hotkey_manager_windows: - dependency: transitive - description: - name: hotkey_manager_windows - sha256: "0d03ced9fe563ed0b68f0a0e1b22c9ffe26eb8053cb960e401f68a4f070e0117" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce - url: "https://pub.dev" - source: hosted - version: "4.8.0" - intl: - dependency: transitive - description: - name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.dev" - source: hosted - version: "0.20.2" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - jni: - dependency: transitive - description: - name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.dev" - source: hosted - version: "1.0.0" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 - url: "https://pub.dev" - source: hosted - version: "4.11.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - macos_window_utils: - dependency: transitive - description: - name: macos_window_utils - sha256: cb918e1ff0b31fdaa5cd8631eded7c24bd72e1025cf1f95c819e483f0057c652 - url: "https://pub.dev" - source: hosted - version: "1.9.1" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - melos: - dependency: "direct dev" - description: - name: melos - sha256: "2f7381d209ab7554203c51481a6a57c7896593b7f06190a06a467aa5bfd693c5" - url: "https://pub.dev" - source: hosted - version: "7.5.1" - menu_base: - dependency: transitive - description: - name: menu_base - sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" - url: "https://pub.dev" - source: hosted - version: "0.1.1" - meta: - dependency: transitive - description: - name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" - source: hosted - version: "1.18.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - msix: - dependency: transitive - description: - name: msix - sha256: b6b08e7a7b5d1845f2b1d31216d5b1fb558e98251efefe54eb79ed00d27bc2ac - url: "https://pub.dev" - source: hosted - version: "3.16.13" - mustache_template: - dependency: transitive - description: - name: mustache_template - sha256: "544ff0b837836f5ad6b351e7a676ff7c2cad4fa412c465908aeb9358caddb6fc" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" - url: "https://pub.dev" - source: hosted - version: "0.17.6" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: transitive - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" - posix: - dependency: transitive - description: - name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" - url: "https://pub.dev" - source: hosted - version: "6.5.0" - process: - dependency: transitive - description: - name: process - sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 - url: "https://pub.dev" - source: hosted - version: "5.0.5" - prompts: - dependency: transitive - description: - name: prompts - sha256: "3773b845e85a849f01e793c4fc18a45d52d7783b4cb6c0569fad19f9d0a774a1" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pub_updater: - dependency: transitive - description: - name: pub_updater - sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d" - url: "https://pub.dev" - source: hosted - version: "0.5.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - recase: - dependency: transitive - description: - name: recase - sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 - url: "https://pub.dev" - source: hosted - version: "4.1.0" - record_use: - dependency: transitive - description: - name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - screen_retriever: - dependency: transitive - description: - name: screen_retriever - sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_linux: - dependency: transitive - description: - name: screen_retriever_linux - sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_macos: - dependency: transitive - description: - name: screen_retriever_macos - sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_platform_interface: - dependency: transitive - description: - name: screen_retriever_platform_interface - sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_windows: - dependency: transitive - description: - name: screen_retriever_windows - sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 - url: "https://pub.dev" - source: hosted - version: "1.1.3" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - shortid: - dependency: transitive - description: - name: shortid - sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb - url: "https://pub.dev" - source: hosted - version: "0.1.2" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 - url: "https://pub.dev" - source: hosted - version: "4.2.3" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b - url: "https://pub.dev" - source: hosted - version: "2.1.2" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" - url: "https://pub.dev" - source: hosted - version: "0.10.13" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5" - url: "https://pub.dev" - source: hosted - version: "3.3.1" - sqlparser: - dependency: transitive - description: - name: sqlparser - sha256: ab2b467425f1d4f3acfa5fd11a08226f7d6c26ff102c06be1807e1dff34e050b - url: "https://pub.dev" - source: hosted - version: "0.44.3" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test: - dependency: transitive - description: - name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" - url: "https://pub.dev" - source: hosted - version: "1.31.0" - test_api: - dependency: transitive - description: - name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - test_core: - dependency: transitive - description: - name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" - url: "https://pub.dev" - source: hosted - version: "0.6.17" - tray_manager: - dependency: transitive - description: - name: tray_manager - sha256: c5fd83b0ae4d80be6eaedfad87aaefab8787b333b8ebd064b0e442a81006035b - url: "https://pub.dev" - source: hosted - version: "0.5.2" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - uni_platform: - dependency: transitive - description: - name: uni_platform - sha256: e02213a7ee5352212412ca026afd41d269eb00d982faa552f419ffc2debfad84 - url: "https://pub.dev" - source: hosted - version: "0.1.3" - uuid: - dependency: transitive - description: - name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" - url: "https://pub.dev" - source: hosted - version: "4.5.3" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" - source: hosted - version: "15.2.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" - window_manager: - dependency: transitive - description: - name: window_manager - sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" - url: "https://pub.dev" - source: hosted - version: "0.5.1" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" - yaml_edit: - dependency: transitive - description: - name: yaml_edit - sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" - url: "https://pub.dev" - source: hosted - version: "2.2.4" -sdks: - dart: ">=3.11.1 <4.0.0" - flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml deleted file mode 100644 index dd68b5c0..00000000 --- a/pubspec.yaml +++ /dev/null @@ -1,58 +0,0 @@ -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/resources/copypaste_v2_en_screenshot1_panel.png b/resources/copypaste_v2_en_screenshot1_panel.png deleted file mode 100644 index 7713b592..00000000 Binary files a/resources/copypaste_v2_en_screenshot1_panel.png and /dev/null differ diff --git a/resources/copypaste_v2_en_screenshot2_categories.png b/resources/copypaste_v2_en_screenshot2_categories.png deleted file mode 100644 index a6ed99f0..00000000 Binary files a/resources/copypaste_v2_en_screenshot2_categories.png and /dev/null differ diff --git a/resources/copypaste_v2_en_screenshot3_settings.png b/resources/copypaste_v2_en_screenshot3_settings.png deleted file mode 100644 index 65afd926..00000000 Binary files a/resources/copypaste_v2_en_screenshot3_settings.png and /dev/null differ diff --git a/resources/copypaste_v2_en_screenshot4_multiplatform.png b/resources/copypaste_v2_en_screenshot4_multiplatform.png deleted file mode 100644 index 91562388..00000000 Binary files a/resources/copypaste_v2_en_screenshot4_multiplatform.png and /dev/null differ diff --git a/resources/copypaste_v2_es_1_panel.png b/resources/copypaste_v2_es_1_panel.png deleted file mode 100644 index 6e72f2b0..00000000 Binary files a/resources/copypaste_v2_es_1_panel.png and /dev/null differ diff --git a/resources/copypaste_v2_es_2_categorias.png b/resources/copypaste_v2_es_2_categorias.png deleted file mode 100644 index fc60541d..00000000 Binary files a/resources/copypaste_v2_es_2_categorias.png and /dev/null differ diff --git a/resources/copypaste_v2_es_3_settings.png b/resources/copypaste_v2_es_3_settings.png deleted file mode 100644 index 8a251089..00000000 Binary files a/resources/copypaste_v2_es_3_settings.png and /dev/null differ diff --git a/resources/copypaste_v2_es_4_multiplatform.png b/resources/copypaste_v2_es_4_multiplatform.png deleted file mode 100644 index 8a05a22b..00000000 Binary files a/resources/copypaste_v2_es_4_multiplatform.png and /dev/null differ diff --git a/resources/copypaste_v2_screenshot1_panel-black.png b/resources/copypaste_v2_screenshot1_panel-black.png deleted file mode 100644 index 0634312c..00000000 Binary files a/resources/copypaste_v2_screenshot1_panel-black.png and /dev/null differ diff --git a/resources/copypaste_v2_screenshot1_panel.png b/resources/copypaste_v2_screenshot1_panel.png deleted file mode 100644 index 9f694791..00000000 Binary files a/resources/copypaste_v2_screenshot1_panel.png and /dev/null differ diff --git a/resources/copypaste_v2_screenshot2_categorias.png b/resources/copypaste_v2_screenshot2_categorias.png deleted file mode 100644 index ca5c1c0a..00000000 Binary files a/resources/copypaste_v2_screenshot2_categorias.png and /dev/null differ diff --git a/resources/copypaste_v2_screenshot3_settings.png b/resources/copypaste_v2_screenshot3_settings.png deleted file mode 100644 index 463d2ae6..00000000 Binary files a/resources/copypaste_v2_screenshot3_settings.png and /dev/null differ diff --git a/resources/copypaste_v2_screenshot4_multiplatform.png b/resources/copypaste_v2_screenshot4_multiplatform.png deleted file mode 100644 index d277d432..00000000 Binary files a/resources/copypaste_v2_screenshot4_multiplatform.png and /dev/null differ diff --git a/app/assets/icons/icon_app_256.png b/resources/icon_app_256.png similarity index 100% rename from app/assets/icons/icon_app_256.png rename to resources/icon_app_256.png diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..73cb934d --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..20dffbc1 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +style_edition = "2024" +max_width = 100