diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index c7a09c35..00000000 --- a/.gitattributes +++ /dev/null @@ -1,4 +0,0 @@ -packaging/obs/** text eol=lf -packaging/obs/debian/rules text eol=lf -*.spec text eol=lf -*.dsc text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ff667582..b7e29974 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -23,6 +23,9 @@ body: - "Windows 11 (23H2 or newer)" - "Windows 11 (Older version)" - "Windows 10 (1809+)" + - "macOS 15 (Sequoia) or newer" + - "macOS 13–14 (Ventura / Sonoma)" + - "Other / unsupported (Linux ≤ v2.11.0 — reports not accepted)" validations: required: true - type: textarea diff --git a/.github/actions/setup-flutter-workspace/action.yml b/.github/actions/setup-flutter-workspace/action.yml index 1ca6cfcc..dfe15634 100644 --- a/.github/actions/setup-flutter-workspace/action.yml +++ b/.github/actions/setup-flutter-workspace/action.yml @@ -6,6 +6,14 @@ inputs: description: Flutter channel passed to subosito/flutter-action. required: false default: stable + flutter-version: + description: >- + Pinned Flutter release. `channel: stable` alone floats, so a new + stable silently changes dart format output and reddens CI on + untouched files. Bump deliberately: upgrade locally, run + `dart format .`, commit the reformat and this value together. + required: false + default: 3.44.1 version: description: When set, writes this version into app/pubspec.yaml after bootstrap. required: false @@ -17,6 +25,7 @@ runs: - uses: subosito/flutter-action@v2 with: channel: ${{ inputs.channel }} + flutter-version: ${{ inputs.flutter-version }} cache: true - name: Cache pub dependencies diff --git a/.github/actions/setup-linux-build-deps/action.yml b/.github/actions/setup-linux-build-deps/action.yml deleted file mode 100644 index 4af092b5..00000000 --- a/.github/actions/setup-linux-build-deps/action.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Setup Linux build dependencies -description: APT packages required to build and package CopyPaste on Linux, plus a toolchain preflight. - -runs: - using: composite - steps: - - name: Install Linux build dependencies - shell: bash - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y \ - clang \ - cmake \ - desktop-file-utils \ - libayatana-appindicator3-dev \ - libfuse2 \ - libgtk-3-dev \ - libkeybinder-3.0-dev \ - liblzma-dev \ - libx11-dev \ - libxtst-dev \ - lld-14 \ - ninja-build \ - patchelf \ - pkg-config \ - rpm - - - name: Verify Linux toolchain preflight - shell: bash - run: | - set -euo pipefail - test -x /usr/lib/llvm-14/bin/ld.lld || test -x /usr/bin/ld.lld-14 || test -x /usr/bin/ld.lld - pkg-config --modversion gtk+-3.0 - pkg-config --modversion keybinder-3.0 - pkg-config --modversion ayatana-appindicator3-0.1 - pkg-config --modversion x11 - pkg-config --modversion xtst diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ba5dd84..c972e623 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,7 +121,7 @@ jobs: - name: Validate release workflow references run: | errors=0 - for wf in release-win.yml release-mac.yml release-linux.yml; do + 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)) @@ -129,7 +129,7 @@ jobs: echo "OK .github/workflows/$wf" fi done - for act in setup-flutter-workspace setup-linux-build-deps; do + 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)) @@ -142,7 +142,7 @@ jobs: - name: Validate project structure run: | errors=0 - for dir in app/lib app/windows app/macos app/linux app/assets; do + 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)) @@ -181,41 +181,55 @@ jobs: build: needs: quality runs-on: windows-latest - name: Build Verification (Windows) + name: Test & Build (Windows) steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-flutter-workspace - - name: Build Windows release - run: cd app; flutter build windows --release - - build-linux: - needs: quality - runs-on: ubuntu-22.04 - name: Build Verification (Linux) - - steps: - - uses: actions/checkout@v7 - - - uses: ./.github/actions/setup-linux-build-deps + # Las ramas Windows sólo se ejecutan aquí: en ubuntu quedan saltadas. + # Invocado vía dart por la misma razón que en setup-flutter-workspace. + - name: Run tests + shell: bash + run: dart pub global run melos:melos run test:coverage - - uses: ./.github/actions/setup-flutter-workspace + - 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 Linux release - run: cd app && flutter build linux --release + - name: Build Windows release + run: cd app; flutter build windows --release build-macos: needs: quality runs-on: macos-latest - name: Build Verification (macOS) + name: Test & Build (macOS) steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-flutter-workspace + # Las ramas macOS sólo se ejecutan aquí: en ubuntu quedan saltadas. + - 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 diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml deleted file mode 100644 index 6e482479..00000000 --- a/.github/workflows/release-linux.yml +++ /dev/null @@ -1,293 +0,0 @@ -name: Release (Linux) - -on: - workflow_call: - inputs: - version: - required: true - type: string - workflow_dispatch: - inputs: - version: - description: "Version to use (e.g. 2.1.0). Defaults to 2.0.0-dev" - required: false - default: "2.0.0-dev" - -permissions: - contents: read - -jobs: - build-linux: - runs-on: ubuntu-22.04 - timeout-minutes: 45 - name: Build Linux (AppImage, deb, rpm) - - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.ref_name }} - - - name: Resolve version - id: get_version - env: - VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "RPM_VERSION=${VERSION%%-*}" >> "$GITHUB_OUTPUT" - echo "Version: $VERSION (rpm: ${VERSION%%-*})" - - - uses: ./.github/actions/setup-linux-build-deps - - - uses: ./.github/actions/setup-flutter-workspace - with: - version: ${{ steps.get_version.outputs.VERSION }} - - - name: Install Fastforge - run: dart pub global activate fastforge - - - name: Install appimagetool - run: | - wget -qO /usr/local/bin/appimagetool \ - "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" - chmod +x /usr/local/bin/appimagetool - - - name: Package AppImage - env: - APPIMAGE_EXTRACT_AND_RUN: "1" - run: | - cd app - fastforge package \ - --platform linux \ - --targets appimage \ - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" - - - name: Rename AppImage - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - APPIMAGE=$(find app/dist app/build -type f -name "*.AppImage" 2>/dev/null | head -n 1) - if [[ -z "$APPIMAGE" ]]; then - echo "::error::No AppImage generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_x86_64.AppImage" - mv "$APPIMAGE" "$DEST" - chmod +x "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Repack AppImage with AppImageUpdate metadata - env: - APPIMAGE_EXTRACT_AND_RUN: "1" - run: | - set -euo pipefail - VERSION="${{ steps.get_version.outputs.VERSION }}" - APPIMAGE="$GITHUB_WORKSPACE/app/dist/CopyPaste_${VERSION}_x86_64.AppImage" - BASENAME="$(basename "$APPIMAGE")" - WORK="$(mktemp -d)" - (cd "$WORK" && "$APPIMAGE" --appimage-extract >/dev/null) - UPDATE_INFO="gh-releases-zsync|rgdevment|CopyPaste|latest|CopyPaste_*_x86_64.AppImage.zsync" - ( - cd "$WORK" - ARCH=x86_64 appimagetool \ - --updateinformation "$UPDATE_INFO" \ - squashfs-root \ - "$BASENAME" - ) - mv "$WORK/$BASENAME" "$APPIMAGE" - mv "$WORK/${BASENAME}.zsync" "$GITHUB_WORKSPACE/app/dist/${BASENAME}.zsync" - chmod +x "$APPIMAGE" - echo "Embedded update info: $UPDATE_INFO" - ls -la "$GITHUB_WORKSPACE/app/dist/${BASENAME}.zsync" - - - name: Package deb - run: | - cd app - fastforge package \ - --platform linux \ - --targets deb \ - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ - --skip-clean - - - name: Rename deb - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - DEB=$(find app/dist app/build -type f -name "*.deb" 2>/dev/null | head -n 1) - if [[ -z "$DEB" ]]; then - echo "::error::No deb package generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_amd64.deb" - mv "$DEB" "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Package rpm - run: | - set -euo pipefail - RPM_VERSION="${{ steps.get_version.outputs.RPM_VERSION }}" - VERSION="${{ steps.get_version.outputs.VERSION }}" - cd app - sed -i "s/^version:.*/version: $RPM_VERSION/" pubspec.yaml - fastforge package \ - --platform linux \ - --targets rpm \ - --build-dart-define "APP_VERSION=${VERSION}" \ - --skip-clean - sed -i "s/^version:.*/version: $VERSION/" pubspec.yaml - - - name: Rename rpm - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - RPM=$(find app/dist app/build -type f -name "*.rpm" 2>/dev/null | head -n 1) - if [[ -z "$RPM" ]]; then - echo "::error::No rpm package generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_x86_64.rpm" - mv "$RPM" "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Validate bundled .desktop file - run: | - set -euo pipefail - VERSION="${{ steps.get_version.outputs.VERSION }}" - APPIMAGE="app/dist/CopyPaste_${VERSION}_x86_64.AppImage" - WORKDIR="$(mktemp -d)" - (cd "$WORKDIR" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract '*.desktop' >/dev/null) - DESKTOP=$(find "$WORKDIR/squashfs-root" -maxdepth 2 -name '*.desktop' | head -n 1) - if [[ -z "$DESKTOP" ]]; then - echo "::error::No .desktop file inside AppImage" - exit 1 - fi - desktop-file-validate "$DESKTOP" - echo "desktop-file-validate passed" - - - name: Build portable tarball for OBS - run: | - set -euo pipefail - VERSION="${{ steps.get_version.outputs.VERSION }}" - BUNDLE="app/build/linux/x64/release/bundle" - if [[ ! -d "$BUNDLE" ]]; then - echo "::error::Flutter bundle not found at $BUNDLE" - exit 1 - fi - STAGE="$(mktemp -d)/CopyPaste-${VERSION}-linux-x64" - mkdir -p "$STAGE/bundle" "$STAGE/packaging" - cp -a "$BUNDLE"/. "$STAGE/bundle/" - cp LICENSE "$STAGE/LICENSE" - cp app/assets/icons/icon_app_256.png "$STAGE/packaging/icon_app_256.png" - APPIMAGE="$GITHUB_WORKSPACE/app/dist/CopyPaste_${VERSION}_x86_64.AppImage" - EXTRACT="$(mktemp -d)" - (cd "$EXTRACT" && "$APPIMAGE" --appimage-extract '*.desktop' >/dev/null) - DESKTOP=$(find "$EXTRACT/squashfs-root" -maxdepth 2 -name '*.desktop' | head -n 1) - cp "$DESKTOP" "$STAGE/packaging/com.rgdevment.copypaste.desktop" - tar -czf "app/dist/CopyPaste-${VERSION}-linux-x64.tar.gz" \ - -C "$(dirname "$STAGE")" "$(basename "$STAGE")" - ls -la "app/dist/CopyPaste-${VERSION}-linux-x64.tar.gz" - - - name: Compute SHA-256 checksums - run: | - cd app/dist - sha256sum *.AppImage *.AppImage.zsync *.deb *.rpm *.tar.gz > SHA256SUMS 2>/dev/null || \ - sha256sum *.AppImage *.deb *.rpm *.tar.gz > SHA256SUMS - cat SHA256SUMS - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: release-linux - path: | - app/dist/*.AppImage - app/dist/*.AppImage.zsync - app/dist/*.deb - app/dist/*.rpm - app/dist/*.tar.gz - app/dist/SHA256SUMS - retention-days: 5 - - publish-obs: - runs-on: ubuntu-22.04 - needs: build-linux - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') - timeout-minutes: 15 - name: Publish to OpenSUSE Build Service - - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.ref_name }} - - - name: Download Linux artifacts - uses: actions/download-artifact@v7 - with: - name: release-linux - path: linux-artifacts - - - name: Resolve version - id: get_version - run: | - VERSION="${{ inputs.version }}" - DATE_RFC="$(date -R)" - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "DATE_RFC=$DATE_RFC" >> "$GITHUB_OUTPUT" - - - name: Install osc - run: | - sudo apt-get update - sudo apt-get install -y osc - - - name: Configure osc credentials - env: - OBS_USERNAME: ${{ secrets.OBS_USERNAME }} - OBS_PASSWORD: ${{ secrets.OBS_PASSWORD }} - run: | - if [[ -z "$OBS_USERNAME" || -z "$OBS_PASSWORD" ]]; then - echo "::warning::OBS credentials missing — skipping OBS publish" - echo "SKIP=true" >> "$GITHUB_ENV" - exit 0 - fi - mkdir -p ~/.config/osc - cat > ~/.config/osc/oscrc </dev/null || true - STAGE="$(mktemp -d)" - cp -a "$GITHUB_WORKSPACE/packaging/obs/." "$STAGE/" - find "$STAGE" -type f \( -name '*.spec' -o -name '*.dsc' -o -name 'rules' -o -name 'control' -o -name 'changelog' -o -name 'compat' -o -name 'copyright' -o -name 'format' \) \ - -exec sed -i 's/\r$//' {} + - find "$STAGE" -type f \( -name '*.spec' -o -name '*.dsc' -o -name 'changelog' \) \ - -exec sed -i "s/@VERSION@/$VERSION/g; s/Thu, 23 Apr 2026 00:00:00 +0000/$DATE_RFC/g" {} + - TARBALL="CopyPaste-${VERSION}-linux-x64.tar.gz" - if [[ ! -f "$GITHUB_WORKSPACE/linux-artifacts/$TARBALL" ]]; then - echo "::error::Tarball not found in artifacts: $TARBALL" - ls -la "$GITHUB_WORKSPACE/linux-artifacts/" || true - exit 1 - fi - cp "$GITHUB_WORKSPACE/linux-artifacts/$TARBALL" "$STAGE/$TARBALL" - cp "$STAGE/copypaste.spec" "$OBS_DIR/copypaste.spec" - cp "$STAGE/copypaste.dsc" "$OBS_DIR/copypaste.dsc" - cp "$STAGE/copypaste-rpmlintrc" "$OBS_DIR/copypaste-rpmlintrc" - cp "$STAGE/$TARBALL" "$OBS_DIR/$TARBALL" - tar --force-local -C "$STAGE" -cJf "$OBS_DIR/debian.tar.xz" debian - cd "$OBS_DIR" - osc addremove - osc commit -m "Release v$VERSION (automated from GitHub Actions)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd222a54..ea08ed57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,16 +125,9 @@ jobs: version: ${{ needs.extract-version.outputs.version }} secrets: inherit - build-linux: - needs: extract-version - uses: ./.github/workflows/release-linux.yml - with: - version: ${{ needs.extract-version.outputs.version }} - secrets: inherit - github-release: runs-on: ubuntu-latest - needs: [extract-version, build-windows, build-macos, build-linux] + needs: [extract-version, build-windows, build-macos] if: needs.extract-version.outputs.is_tag == 'true' timeout-minutes: 10 name: Create GitHub Release @@ -174,12 +167,6 @@ jobs: patterns=( 'artifacts/release-windows/**/*_Setup.exe' 'artifacts/release-macos/*.dmg' - 'artifacts/release-linux/*.AppImage' - 'artifacts/release-linux/*.AppImage.zsync' - 'artifacts/release-linux/*.deb' - 'artifacts/release-linux/*.rpm' - 'artifacts/release-linux/*.tar.gz' - 'artifacts/release-linux/SHA256SUMS' ) if [[ "$IS_PRERELEASE" != "true" ]]; then patterns+=( 'artifacts/release-windows/**/*_store.msix*' ) @@ -202,10 +189,6 @@ jobs: artifacts/release-windows/**/*_Setup.exe artifacts/release-windows/**/*_store.msix* artifacts/release-macos/*.dmg - artifacts/release-linux/*.AppImage - artifacts/release-linux/*.deb - artifacts/release-linux/*.rpm - artifacts/release-linux/*.tar.gz - name: Create GitHub Release uses: softprops/action-gh-release@v2 @@ -218,12 +201,6 @@ jobs: artifacts/release-windows/**/*_Setup.exe artifacts/release-windows/**/*_store.msix* artifacts/release-macos/*.dmg - artifacts/release-linux/*.AppImage - artifacts/release-linux/*.AppImage.zsync - artifacts/release-linux/*.deb - artifacts/release-linux/*.rpm - artifacts/release-linux/*.tar.gz - artifacts/release-linux/SHA256SUMS env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -292,6 +269,14 @@ jobs: esac echo "Severity: $SEVERITY (override=${SEVERITY_OVERRIDE:-}, previous=${SEVERITY_CURRENT})" + # El default sube minimumSupported a la versión publicada. Con + # severity critical eso bloquea a todo Linux instalado (<= 2.11.0), + # que ya no tiene ruta de actualización: exige la decisión explícita. + 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:-})" @@ -420,37 +405,23 @@ jobs: DMG_NAME="CopyPaste_${VERSION}_universal.dmg" DMG_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${DMG_NAME}" - DEB_NAME="CopyPaste_${VERSION}_amd64.deb" - DEB_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${DEB_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 "Downloading deb to compute SHA256..." - curl -fSL --retry 5 --retry-delay 10 --retry-all-errors \ - -o "/tmp/${DEB_NAME}" "${DEB_URL}" - DEB_SHA256=$(sha256sum "/tmp/${DEB_NAME}" | awk '{print $1}') - rm -f "/tmp/${DEB_NAME}" - echo "Version: ${VERSION}" echo "DMG SHA256: ${DMG_SHA256}" - echo "DEB SHA256: ${DEB_SHA256}" if [[ "$VERSION" == *-* ]]; then CASK_FILE="Casks/copypaste-beta.rb" CASK_NAME="copypaste-beta" CASK_DESC="Clipboard history manager for macOS (beta)" - FORMULA_FILE="Formula/copypaste-beta-linux.rb" - FORMULA_CLASS="CopypasteBetaLinux" else CASK_FILE="Casks/copypaste.rb" CASK_NAME="copypaste" CASK_DESC="Clipboard history manager for macOS" - FORMULA_FILE="Formula/copypaste-linux.rb" - FORMULA_CLASS="CopypasteLinux" fi git clone "https://x-access-token:${GH_TOKEN}@github.com/rgdevment/homebrew-tap.git" /tmp/homebrew-tap @@ -459,7 +430,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - mkdir -p Casks Formula + mkdir -p Casks cat > "${CASK_FILE}" <<- CASK_EOF cask "${CASK_NAME}" do @@ -481,41 +452,12 @@ jobs: end CASK_EOF - cat > "${FORMULA_FILE}" <<- FORMULA_EOF - class ${FORMULA_CLASS} < Formula - desc "Clipboard history manager" - homepage "https://github.com/${{ github.repository }}" - license "GPL-3.0-only" - version "${VERSION}" - - on_linux do - url "${DEB_URL}" - sha256 "${DEB_SHA256}" - end - - def install - system "ar", "x", cached_download - system "tar", "xf", Dir["data.tar.*"].first - libexec.install Dir["opt/copypaste/*"] - bin.write_exec_script libexec/"copypaste" - end - - def caveats - "Requires an X11 session. On Wayland, global hotkey and auto-paste are unavailable." - end - - test do - assert_predicate bin/"copypaste", :exist? - end - end - FORMULA_EOF - - git add "${CASK_FILE}" "${FORMULA_FILE}" + 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} and Linux formula to ${VERSION}" + 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 @@ -525,7 +467,7 @@ jobs: git rebase origin/main fi if git push origin HEAD:main; then - echo "Homebrew Tap updated: cask ${CASK_NAME} and formula ${FORMULA_FILE} → ${VERSION}" + echo "Homebrew Tap updated: cask ${CASK_NAME} → ${VERSION}" exit 0 fi echo "Push rejected (attempt ${attempt})" diff --git a/.gitignore b/.gitignore index dfe5fd54..8ff75b7f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,10 +37,6 @@ coverage/ # ── Build outputs ── dist/ -# ── OBS (osc local checkouts) ── -.osc/ -/home:rgdevment/ - # ── Environment ── .env .venv/ diff --git a/COMMERCIAL.md b/COMMERCIAL.md index ecf84717..ddd33770 100644 --- a/COMMERCIAL.md +++ b/COMMERCIAL.md @@ -27,13 +27,13 @@ matter how many people use it or for how long. If you are a company wondering whether rolling this out to your staff needs a licence: it does not. Internal use is not distribution. -### Packaging it for a Linux distribution +### Packaging it for a distribution or package manager **You do not need a commercial licence, and you never will.** Building -CopyPaste for Debian, Fedora, Arch, openSUSE, Flathub, the AUR, or anywhere -else is exactly the redistribution the GPL is designed to permit. Ship the -`.deb`, the `.rpm`, the AppImage or your own build, keep the licence notices -and make the source available as the GPL requires, and you are done. +CopyPaste for any distribution channel — a package manager, a software +repository, or your own build pipeline — is exactly the redistribution the GPL +is designed to permit. Ship the package, keep the licence notices and make the +source available as the GPL requires, and you are done. Packagers are welcome here. If something about the build makes your life harder, open an issue — that is a bug worth fixing. @@ -71,8 +71,8 @@ being the well-known case. That conflict binds **licensees**, not the copyright holder: a third party cannot publish CopyPaste there, and the project itself can, under separate terms it grants to itself. -Linux repositories and the Microsoft Store are unaffected: both defer to the -software's own licence. +Third-party package repositories and the Microsoft Store are unaffected: both +defer to the software's own licence. ## Why the project is set up this way diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3b2173a..056de0fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,9 +20,9 @@ We believe in: **There is no premium version and no feature is ever held back** — what you install is the whole application. CopyPaste is free software, created by and for the community. -It is also dual licensed: anyone who wants to redistribute it inside a product of their own needs [separate terms](COMMERCIAL.md). Using it, deploying it across a company, or packaging it for a Linux distribution never does. +It is also dual licensed: anyone who wants to redistribute it inside a product of their own needs [separate terms](COMMERCIAL.md). Using it, deploying it across a company, or packaging it for a distribution never does. -> **Maintainers:** the full release process (tagging, manifest, store and OBS publication) lives in [RELEASING.md](RELEASING.md). +> **Maintainers:** the full release process (tagging, manifest and store publication) lives in [RELEASING.md](RELEASING.md). --- @@ -59,7 +59,7 @@ In many ways! Code is just one of them: ### Translate -Do you speak another language? Help us bring CopyPaste to more people. Check the [localization guide](README.md#-localization-help-us-go-global) in the README. +Do you speak another language? Help us bring CopyPaste to more people. Check the [localization guide](README.md#localization-help-translate-copypaste) in the README. ### Improve Documentation @@ -85,7 +85,7 @@ We keep the code simple and consistent: **UI/UX:** -- Native look and feel on each platform (Windows, macOS, Linux) +- Native look and feel on each platform (Windows, macOS) - Functional minimalism - Smooth and fluid transitions - Respect for system Light/Dark themes @@ -139,9 +139,9 @@ request features, discuss design, package it for your distribution, and fork the project under the GPL-3.0. Only merging code into this repository requires the agreement. -**Packaging CopyPaste for a Linux distribution needs no agreement and no -commercial licence** — that is ordinary GPL redistribution, and packagers are -welcome. See [COMMERCIAL.md](COMMERCIAL.md). +**Packaging CopyPaste for a distribution or package manager needs no agreement +and no commercial licence** — that is ordinary GPL redistribution, and +packagers are welcome. See [COMMERCIAL.md](COMMERCIAL.md). --- diff --git a/PRIVACY.md b/PRIVACY.md index c9cb2610..05b439ba 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -93,7 +93,6 @@ Application logs are stored locally for troubleshooting: - **Windows:** `%LOCALAPPDATA%\CopyPaste\logs\` - **macOS:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/logs/` -- **Linux:** `~/.local/share/com.rgdevment.copypaste/CopyPaste/logs/` - **Content:** Application events, errors, and diagnostic information only - **No personal data:** Logs do **not** contain clipboard content — your copied text, images, or file paths are never written to log files @@ -104,7 +103,7 @@ If the app fails to start or crashes during initialization, a single `crash.log` - Lives at `/crash.log` on every platform (e.g. `%LOCALAPPDATA%\CopyPaste\crash.log` on Windows) - Is **capped at 512 KB** — older content is overwritten automatically - Contains: timestamp (UTC), OS name and version, Dart runtime version, the failing operation, and the stack trace -- Has **automatic redaction applied at write time**: your Windows/macOS/Linux user name, full home folder path, and any email addresses found in stack traces are replaced with ``, ``, and `` placeholders before being written to disk +- Has **automatic redaction applied at write time**: your Windows/macOS user name, full home folder path, and any email addresses found in stack traces are replaced with ``, ``, and `` placeholders before being written to disk - **Never contains clipboard content** — clipboard data does not flow through error paths - **Is never sent anywhere automatically** — same rule as the regular logs @@ -165,16 +164,6 @@ All data is stored locally under your user profile: | **Logs** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/logs/` | | **Crash log** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/crash.log` | -**Linux:** - -| Data | Location | -| :--- | :--- | -| **Database** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/clipboard.db` | -| **Images** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/images/` | -| **Configuration** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/config/` | -| **Logs** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/logs/` | -| **Crash log** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/crash.log` | - These folders are protected by your operating system's user account permissions. Other users on the same computer cannot access them under normal conditions. --- @@ -217,7 +206,7 @@ CopyPaste makes **one type of network request** for update checking: - **No clipboard content, no usage data, no personal information** is ever sent - The manifest is **cryptographically signed** with an Ed25519 key. If the signature does not verify, the manifest is discarded and no update indicator is shown - **All platforms:** If an update is found, a non-invasive indicator appears in the app's footer bar — no popups or dialogs interrupt your workflow. You can click the indicator to see details -- **Standalone builds (Windows / macOS / Linux):** Clicking the indicator opens the GitHub release page (or shows the Homebrew / apt / dnf upgrade command). Nothing is downloaded or installed automatically +- **Standalone builds (Windows / macOS):** Clicking the indicator opens the GitHub release page (or shows the Homebrew / Scoop upgrade command). Nothing is downloaded or installed automatically - **Microsoft Store version:** Clicking the indicator opens a dialog explaining that Microsoft Store delivers updates on its own schedule. The app is never blocked on Store builds, since update delivery is outside our control - **Blocked versions:** If the manifest flags the installed version as having a critical issue (for example, a severe security bug or data-corruption fix), standalone builds show a full-screen prompt with direct install/download instructions. This mechanism is disabled on Microsoft Store builds @@ -248,9 +237,8 @@ Each platform has a standard marker that the copying application sets, and CopyP | :--- | :--- | | Windows | `ExcludeClipboardContentFromMonitorProcessing`, or `CanIncludeInClipboardHistory` set to 0 | | macOS | `org.nspasteboard.ConcealedType` and `org.nspasteboard.TransientType` | -| Linux | The `x-kde-passwordManagerHint` clipboard target | -On Linux the check asks only which targets are offered, never for their contents, so the secret's bytes are never requested in the first place. +The check asks only whether the marker is present, never for the secret's contents, so its bytes are never requested in the first place. ### What This Does Not Cover @@ -326,11 +314,6 @@ To completely remove all CopyPaste data when uninstalling: 1. Move CopyPaste to Trash from Applications 2. Delete the data folder: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/` -**Linux:** - -1. Uninstall CopyPaste (via your package manager or remove the binary) -2. Delete the data folder: `~/.local/share/com.rgdevment.copypaste/CopyPaste/` - After these steps, no CopyPaste data remains on your system. --- diff --git a/README.md b/README.md index 75c3177c..d2c5949d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@
- CopyPaste — Free Open Source Clipboard Manager for Windows, macOS and Linux + 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, macOS and Linux.
No ads. No telemetry. No accounts. Just a fast, private clipboard utility built for productivity.

+

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.

@@ -17,7 +17,7 @@ Latest Release - Platform: Windows, macOS, Linux + Platform: Windows, macOS License GPL-3.0 @@ -33,14 +33,10 @@ Install CopyPaste clipboard manager via Homebrew on macOS -   - - CopyPaste clipboard manager for Linux — apt, dnf or Homebrew -

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

@@ -61,13 +57,13 @@ This isn't a company product. I'm a developer who needed a better **copy paste** - **100% local** — your clipboard history never leaves your computer. No cloud, no servers, no accounts. - **Truly free** — no premium tiers, no feature gates, no "free trial" tricks. GPL v3, forever. Only redistributing it inside a product of your own needs [separate terms](COMMERCIAL.md). -- **Cross-platform** — same native copy-paste experience on Windows, macOS, and Linux. +- **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:** standalone builds for **X11 sessions** (Ubuntu / Fedora / RHEL-compatible). Wayland is not supported yet — global hotkey and auto-paste rely on X11 APIs. +> **Linux support has been discontinued.** See [Linux support (discontinued)](#linux-support-discontinued). --- @@ -120,7 +116,7 @@ Most **clipboard managers** out there are either bloated, ugly, Windows-only, or - 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 (and eventually Linux) +- Worked on both Windows and macOS - Didn't require an account, subscription, or internet connection - Actually respected my privacy — not just claimed to @@ -134,7 +130,7 @@ Every line of code is public. You can read it, fork it, or learn from it. This i **CopyPaste is:** -- A **local-first clipboard manager** and **clipboard history** app for Windows, macOS, and Linux +- 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 @@ -190,12 +186,6 @@ CopyPaste stores all data locally under your user profile: - **Images:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/images` - **Config:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/config` -**Linux:** - -- **Database:** `~/.local/share/com.rgdevment.copypaste/CopyPaste/clipboard.db` -- **Images:** `~/.local/share/com.rgdevment.copypaste/CopyPaste/images` -- **Config:** `~/.local/share/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. @@ -213,14 +203,14 @@ If you care about privacy and control, this clipboard manager is made for you. R - **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, macOS, and Linux. +- **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 and Linux support is on 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 @@ -232,14 +222,14 @@ If you care about privacy and control, this clipboard manager is made for you. R - **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 and Linux open straight to the main panel. +- **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). Linux uses a Dart fallback for images; video/audio show a generic icon. +- **Native Thumbnails:** Image, video and audio previews are generated through the OS shell (QuickLook on macOS, `IShellItemImageFactory` on Windows). --- @@ -250,9 +240,9 @@ dedicated shortcuts for its global actions and history panel. | Scope | Shortcut | Action | | :---- | :------- | :----- | -| Active application | Ctrl+V (Windows/Linux) / Cmd+V (macOS) | Paste the current system clipboard normally. CopyPaste does not intercept it. | -| CopyPaste global | Ctrl+Alt+C (Windows) / Control+Shift+V (macOS) / Super+V (Linux) | Open/close CopyPaste (customizable). | -| CopyPaste global, optional | Ctrl+Alt+V (Windows; configurable elsewhere) | 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. | @@ -336,7 +326,7 @@ Double-click always collapses the card before pasting, so your last click state ### Keyboard-Only Workflow -1. **Press Ctrl+Alt+C** on Windows, **Control+Shift+V** on macOS, or **Super+V** on Linux (customizable in Settings) → Window opens with focus on search box +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 @@ -364,11 +354,10 @@ If "Return to Content mode on open" is enabled, the other clear options are auto | OS | Recommended | Alternatives | | :---------- | :-------------------------------- | :------------------------------------------------- | -| **Windows** | Microsoft Store | Standalone `.exe` | +| **Windows** | Microsoft Store | Scoop · standalone `.exe` | | **macOS** | Homebrew | Standalone `.dmg` | -| **Linux** | `apt` / `dnf` (OBS repo) | Homebrew · self-updating AppImage · `.deb`/`.rpm` | -After installing, open CopyPaste with **Ctrl+Alt+C** on Windows, **Control+Shift+V** on macOS, or **Super+V** on Linux. All are customizable in Settings → Shortcuts. On Linux/X11, if the configured shortcut is unavailable, CopyPaste tries a temporary fallback for that session and shows a warning. +After installing, open CopyPaste with **Ctrl+Alt+C** on Windows or **Control+Shift+V** on macOS. Both are customizable in Settings → Shortcuts. ### Windows @@ -376,6 +365,13 @@ After installing, open CopyPaste with **Ctrl+Alt+C** on Windows, **Control+Shift > [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. --- @@ -392,104 +388,27 @@ brew tap rgdevment/tap && brew install --cask copypaste --- -### Linux - -> **Linux requires an X11 session** (Xorg or XWayland). On a pure Wayland session, the global hotkey and auto-paste are unavailable and a warning is shown at startup. CopyPaste is distributed as **standalone, unsandboxed builds** (OBS apt/dnf, Homebrew, AppImage). There is **no Snap, Flatpak or Flathub package**, because the sandbox would prevent the global hotkey, full clipboard polling, and opening of arbitrary file paths from the history. - -#### 1. Native packages via the openSUSE Build Service (recommended) - -Native `.deb` and `.rpm` packages are built and hosted on the [openSUSE Build Service](https://build.opensuse.org/package/show/home:rgdevment/copypaste) (project `home:rgdevment`). Add the repo once, then get updates through your system package manager just like any other system package. - -

-Debian 12 / 13 - -```sh -DIST=Debian_13 # or: Debian_12 -echo "deb http://download.opensuse.org/repositories/home:/rgdevment/${DIST}/ /" \ - | sudo tee /etc/apt/sources.list.d/home_rgdevment.list -curl -fsSL "https://download.opensuse.org/repositories/home:rgdevment/${DIST}/Release.key" \ - | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/home_rgdevment.gpg > /dev/null -sudo apt update -sudo apt install copypaste -``` - -
- -
-Ubuntu 22.04 / 24.04 - -```sh -DIST=xUbuntu_24.04 # or: xUbuntu_22.04 -echo "deb http://download.opensuse.org/repositories/home:/rgdevment/${DIST}/ /" \ - | sudo tee /etc/apt/sources.list.d/home_rgdevment.list -curl -fsSL "https://download.opensuse.org/repositories/home:rgdevment/${DIST}/Release.key" \ - | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/home_rgdevment.gpg > /dev/null -sudo apt update -sudo apt install copypaste -``` - -
- -
-Fedora 40 / 41 - -```sh -DIST=Fedora_41 # or: Fedora_40 -sudo dnf config-manager --add-repo \ - "https://download.opensuse.org/repositories/home:rgdevment/${DIST}/home:rgdevment.repo" -sudo dnf install copypaste -``` - -
- -
-openSUSE Tumbleweed - -```sh -sudo zypper addrepo \ - https://download.opensuse.org/repositories/home:/rgdevment/openSUSE_Tumbleweed/home:rgdevment.repo -sudo zypper refresh -sudo zypper install copypaste -``` - -
- -> Repository signing is handled by the OBS project key; `apt`/`dnf`/`zypper` verify every package automatically. Installation requires `sudo` because system paths are written. If you cannot use `sudo`, use Homebrew or the AppImage below. - -#### 2. Homebrew - -If you already use Homebrew, or you cannot use `sudo`, this is the fastest path: - -```sh -brew tap rgdevment/tap && brew install copypaste-linux -``` - -Updates land via `brew upgrade copypaste-linux`. - -#### 3. Self-updating AppImage - -A single portable file — no install, runs from your home directory. Once launched, the AppImage **updates itself** through [AppImageUpdate](https://github.com/AppImage/AppImageUpdate): each release embeds a `.zsync` URL pointing to the latest GitHub Release, so the binary delta-updates in place. - -```sh -wget https://github.com/rgdevment/CopyPaste/releases/latest/download/CopyPaste__x86_64.AppImage -chmod +x CopyPaste__x86_64.AppImage -./CopyPaste__x86_64.AppImage -``` - -To refresh without redownloading the whole file, install AppImageUpdate from your distro and run: - -```sh -appimageupdate ./CopyPaste__x86_64.AppImage -``` - -#### 4. Standalone `.deb` / `.rpm` (manual) - -If you don't want a repo and don't want the AppImage, grab the standalone packages directly from [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest): - -- `CopyPaste__amd64.deb` for Debian / Ubuntu and derivatives -- `CopyPaste__x86_64.rpm` for Fedora / RHEL and derivatives - -These are the same artifacts the OBS repo ships, but installed with `dpkg -i` / `rpm -i` they don't get system-managed updates — you'd need to redownload manually for each release. For day-to-day use, prefer the OBS repo above. +### 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 @@ -497,7 +416,6 @@ These are the same artifacts the OBS repo ships, but installed with `dpkg -i` / | :---------- | :------------------------------------------- | :-------------------------------- | | **Windows** | Windows 10 (1809+), Windows 11 | x64 | | **macOS** | Ventura (13.0+) | Universal (Apple Silicon + Intel) | -| **Linux** | Ubuntu 22.04+ · Fedora 40+ · openSUSE Tumbleweed · RHEL-compatible | x86_64 | ### Standalone Downloads @@ -507,9 +425,6 @@ Direct packages live on [GitHub Releases](https://github.com/rgdevment/CopyPaste | :---------- | :------------------------- | :-------------------------------------------------------------------------- | | **Windows** | `*_Setup.exe` | Self-signed installer — see security note below | | **macOS** | `*.dmg` | Universal binary (Apple Silicon + Intel) | -| **Linux** | `*.AppImage` + `.zsync` | Self-updating via AppImageUpdate | -| **Linux** | `*.deb` | Debian/Ubuntu — manual updates | -| **Linux** | `*.rpm` | Fedora/RHEL — manual updates |
Windows standalone: security warnings @@ -527,7 +442,7 @@ Since CopyPaste is an independent open source project, the installer uses a self ## 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 Linux distribution. Only redistributing it inside a product of your own needs [separate terms](COMMERCIAL.md). +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. @@ -541,14 +456,11 @@ No. CopyPaste works fully offline. The standalone version makes a lightweight ch **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. -**Do I need sudo to install on Linux?** -For apt/dnf, yes — they install to system paths. If you cannot use sudo, use Homebrew (if available) or the .AppImage. - **Where is my clipboard history stored?** -Windows: `%LOCALAPPDATA%\CopyPaste\` — macOS: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/` — Linux: `~/.local/share/com.rgdevment.copypaste/CopyPaste/`. Each folder contains the database, images, config, and logs. +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, macOS (Ventura+), and Linux on **X11 sessions** (Ubuntu 22.04+ · Fedora 40+ via OBS apt/dnf · openSUSE Tumbleweed · any distro via Homebrew or the self-updating .AppImage). Wayland-only sessions are not supported yet — see the [Getting Started](#getting-started) section for details. +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. @@ -750,11 +662,11 @@ If you're curious about what's under the hood of this open source clipboard mana | Technology | Why | | :---------------------------------------------------- | :------------------------------------------------------------------------------------ | -| **Flutter** | Cross-platform UI toolkit — native on Windows, macOS, and Linux. | +| **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) / C Plugin (Linux)** | Low-level clipboard listener to capture every content type before the OS discards it. | +| **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. | @@ -792,7 +704,7 @@ No ads. No telemetry. No accounts. Everything local. ## License and Spirit -**CopyPaste** — A modern, open source clipboard manager and copy-paste tool for Windows, macOS, and Linux. +**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. @@ -804,8 +716,8 @@ 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 Linux distribution is ordinary GPL redistribution and needs -no permission from anyone. +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. diff --git a/RELEASING.md b/RELEASING.md index 31a5523a..162601ae 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,11 +6,14 @@ to do by hand. ## TL;DR — cutting a normal release -1. Bump the version in `app/pubspec.yaml` on `main` and merge. -2. Create and push an **annotated, signed** tag. The tag message body +1. Create and push an **annotated, signed** tag. The tag message body carries the release metadata through `Key: value` trailers. -3. Wait for GitHub Actions to finish — artifacts, manifest, stores and OBS - are all updated automatically. +2. Wait for GitHub Actions to finish — artifacts, manifest and stores are + all updated automatically. + +`app/pubspec.yaml` stays pinned at `0.0.0-dev`: the version travels from the +tag to the binaries through the `APP_VERSION` dart-define, so there is nothing +to bump by hand. **You do not need to edit `release-manifest.json` for a normal release.** The tag message is the single source of truth per release; the pipeline @@ -39,11 +42,11 @@ fans out to: | ---------------------------- | ----------------------------------------------------------------------- | | `build-windows` | Signed `*_Setup.exe` + MSIX store bundle. | | `build-macos` | Universal `*.dmg`. | -| `build-linux` | `*.AppImage` + `.zsync`, `*.deb`, `*.rpm`, `SHA256SUMS`, portable tarball.| | `github-release` | Publishes all artifacts to a GitHub Release on the tag. | | `publish-release-manifest` | Patches, signs (Ed25519) and uploads `release-manifest.json(.sig)`. | | `publish-to-store` | Submits MSIX to the Microsoft Store (stable tags only, no `-rc`). | -| `publish-obs` | Commits rendered `_service`, `.spec` and `debian.tar.xz` to OBS. | +| `update-homebrew-cask` | Rewrites the macOS cask in `rgdevment/homebrew-tap`. | +| `update-scoop-bucket` | Rewrites the manifest in `rgdevment/scoop-bucket`. | ## Release manifest — what the pipeline overrides vs. what you own @@ -115,6 +118,24 @@ Both default to "do not change the manifest" when the trailer is absent, so for a normal release you typically set only `Severity:` and `Min-Supported:` (or nothing at all, and let the defaults ride). +#### Discontinued Linux clients and the blocking floor + +`Min-Supported:` defaults to the version being tagged, which is harmless at +`recommended` — only `critical` actually blocks. But Linux installs are frozen +at v2.11.0 and have nowhere to upgrade to, so a `critical` tag that lets the +default ride would strand every one of them on the block screen. + +The pipeline therefore **fails the release** if `Severity: critical` arrives +without an explicit `Min-Supported:`. Choose deliberately: + +- To revoke specific broken builds, use `Blocked:` — an explicit list never + catches a Linux version by accident. +- To raise the floor while keeping Linux usable, set `Min-Supported: 2.11.0` + or lower. +- Above 2.11.0 you are consciously blocking Linux. Those clients still get a + working action button: `channels.github_linux` in `release-manifest.json` + points at the v2.11.0 release, which is the last version they can install. + ## Examples ### Normal recommended release @@ -122,7 +143,7 @@ so for a normal release you typically set only `Severity:` and ```text v2.4.0 -Adds OBS repos and AppImage auto-update. Full notes: … +Adds drag-and-drop and faster search. Full notes: … Severity: recommended Min-Supported: 2.3.0 @@ -130,16 +151,16 @@ Min-Supported: 2.3.0 ### Release that only improves one platform -When the new version mostly affects Linux (or any single platform), you -still tag `recommended` — the badge encourages the update without being -alarming, and users who don't care about the platform-specific changes -can ignore it. There is no `optional` severity. +When the new version mostly affects a single platform, you still tag +`recommended` — the badge encourages the update without being alarming, and +users who don't care about the platform-specific changes can ignore it. +There is no `optional` severity. ```text v2.4.0 -Linux-only: new OBS apt/dnf repos and self-updating AppImage. -Windows and macOS unchanged. +Windows-only: MSIX startup task and taskbar integration fixes. +macOS unchanged. Severity: recommended Min-Supported: 2.3.0 @@ -150,7 +171,7 @@ If you truly don't want to surface the update at all, use `patch`: ```text v2.4.1 -Linux packaging polish. No user-facing changes on Windows/macOS. +Installer packaging polish. No user-facing changes. Severity: patch ``` @@ -192,13 +213,10 @@ checks for a dash in the version). ## Post-release checklist -- [ ] GitHub Release has all six Linux artifacts, both Windows installers - (setup + MSIX), `.dmg`, plus `release-manifest.json(.sig)`. +- [ ] GitHub Release has both Windows installers (setup + MSIX), `.dmg`, + plus `release-manifest.json(.sig)`. - [ ] Microsoft Store submission is in "certification" within 15 min of the tag (stable only). -- [ ] OBS build results are green at - `https://build.opensuse.org/package/show/home:rgdevment/copypaste`. - First build of a new tag may take 10–20 min per target. - [ ] Homebrew tap (`rgdevment/homebrew-tap`) updated — currently manual; see the tap repo for instructions. - [ ] App started on your machine shows the right "Update available" @@ -208,9 +226,10 @@ checks for a dash in the version). - **Homebrew tap** — requires a push to a separate repo. Can be automated later with `brew bump-formula-pr`. -- **OBS first-time project setup** — the project, package and enabled - targets were created by hand once; see [packaging/obs/README.md](packaging/obs/README.md). - After bootstrap, every tag flows automatically. +- **Linux formulae in the tap** — `Formula/copypaste-linux.rb` and + `copypaste-beta-linux.rb` are no longer written by the pipeline. Mark them + `deprecate!` (frozen at v2.11.0) rather than deleting them, so anyone who + already installed them keeps a reinstall path. - **Microsoft Store first-time submission per SKU** — the Store requires a human to accept the submission the first time. Subsequent tags go through automatically. @@ -233,8 +252,7 @@ If a release turns out bad **after** the tag is out: | ----------------------- | ------------------------ | ---------------------------------------- | | `RELEASE_PRIVATE_KEY` | Actions secret | Signs `release-manifest.json`. | | `STORE_APP_ID` | Actions variable | Microsoft Store product ID. | -| `OBS_USERNAME` | Actions secret | OBS account for `osc`. | -| `OBS_PASSWORD` | Actions secret | OBS password / token for `osc`. | +| `GIST_TOKEN` | Actions secret | Pushes to the Homebrew tap and Scoop bucket. | | `GITHUB_TOKEN` | Built-in | Releases, uploads, etc. | Rotating any of these does not require code changes. diff --git a/SECURITY.md b/SECURITY.md index fb7fcb44..159f2b79 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,9 +24,7 @@ I'm not protecting a brand or business. I'm protecting _you_ and everyone using - **Configurable Retention** — Automatically delete old clipboard items based on your retention settings. - **Open Source** — Every line of code is public. You can inspect, audit, and verify what we're doing. - **Signed Release Manifest** — The update notifier fetches a small JSON file signed with an Ed25519 key. The signature is verified locally before the file is trusted, so a compromised mirror cannot inject a fake "latest version" or a malicious install URL. If the signature fails, the manifest is discarded. -- **Minimum Supported Version Enforcement** — When a release contains a critical fix (e.g. a data-corruption or security issue), the signed manifest can mark older versions as blocked. Standalone builds (Windows / macOS / Linux) then show a full-screen prompt with direct install instructions. **Microsoft Store builds are never blocked** — updates on that platform are delivered on Microsoft's review schedule, which is outside our control, so blocking would leave users without a path forward. -- **Signed Linux Repositories** — Native `.deb` and `.rpm` packages are built and signed by the [openSUSE Build Service](https://build.opensuse.org/project/show/home:rgdevment) project key. `apt`, `dnf` and `zypper` verify every package against the OBS GPG key before installation, the same trust chain used by upstream openSUSE and Fedora repositories. -- **Delta-Updated AppImage** — The Linux AppImage embeds an [AppImageUpdate](https://github.com/AppImage/AppImageUpdate) `.zsync` URL pointing back to GitHub Releases. Updates are fetched as binary deltas over HTTPS and verified against the published `SHA256SUMS` file shipped with each release. +- **Minimum Supported Version Enforcement** — When a release contains a critical fix (e.g. a data-corruption or security issue), the signed manifest can mark older versions as blocked. Standalone builds (Windows / macOS) then show a full-screen prompt with direct install instructions. **Microsoft Store builds are never blocked** — updates on that platform are delivered on Microsoft's review schedule, which is outside our control, so blocking would leave users without a path forward. ### Development Practices @@ -99,7 +97,7 @@ This is the fastest way to reach us. We check email daily and will respond withi - **Impact** — What could an attacker do? Who is affected? - **Steps to Reproduce** — How can we reproduce the issue? - **CopyPaste Version** — Which version is affected? -- **OS and version** — e.g., Windows 11 23H2, macOS Sequoia 15.1, Ubuntu 24.04 +- **OS and version** — e.g., Windows 11 23H2, macOS Sequoia 15.1 - **Proof of Concept** (optional) — Code or screenshots demonstrating the issue - **Suggested Fix** (optional) — If you have ideas on how to fix it @@ -202,7 +200,7 @@ We're grateful to the security researchers who help make **CopyPaste** safer: **CopyPaste does not currently use cryptographic functions for data storage.** - Clipboard history is stored in **plaintext** in a local SQLite database -- Database files are protected by **OS-level file system permissions** (Windows, macOS, and Linux) +- Database files are protected by **OS-level file system permissions** (Windows and macOS) - No encryption is applied to stored clipboard data **Why?** diff --git a/app/.metadata b/app/.metadata index a972271a..33cef2bf 100644 --- a/app/.metadata +++ b/app/.metadata @@ -15,9 +15,6 @@ migration: - platform: root create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - - platform: linux - create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - platform: macos create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 diff --git a/app/distribute_options.yaml b/app/distribute_options.yaml index f4e1b80b..df57bc9a 100644 --- a/app/distribute_options.yaml +++ b/app/distribute_options.yaml @@ -20,24 +20,3 @@ releases: build_args: dart-define: STORE_BUILD: "true" - - - name: linux-appimage - jobs: - - name: build-appimage - package: - platform: linux - target: appimage - - - name: linux-deb - jobs: - - name: build-deb - package: - platform: linux - target: deb - - - name: linux-rpm - jobs: - - name: build-rpm - package: - platform: linux - target: rpm diff --git a/app/lib/helpers/url_helper.dart b/app/lib/helpers/url_helper.dart index 0e4acff8..9e6970f8 100644 --- a/app/lib/helpers/url_helper.dart +++ b/app/lib/helpers/url_helper.dart @@ -14,15 +14,12 @@ class UrlHelper { await Process.start('cmd', ['/c', 'start', '', url], runInShell: true); } else if (platform == 'macos') { await Process.start('open', [url]); - } else if (platform == 'linux') { - await Process.start('xdg-open', [url]); } } static String _currentPlatform() { if (Platform.isWindows) return 'windows'; if (Platform.isMacOS) return 'macos'; - if (Platform.isLinux) return 'linux'; return 'other'; } } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index a3a6cf89..26002a9f 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -437,7 +437,7 @@ "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, macOS, and Linux.\nLocal-first \u2014 your history, always at hand. No accounts, no telemetry, no subscriptions.", + "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", @@ -506,8 +506,6 @@ "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" } } }, - "updateAvailableLinux": "Version {version} is available.\n\nDownload the latest release from GitHub.", - "@updateAvailableLinux": { "description": "Update dialog message for Linux", "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" } } }, @@ -559,67 +557,6 @@ "blockedFallbackHint": "Visit https://github.com/rgdevment/CopyPaste/releases to download the latest installer.", "@blockedFallbackHint": { "description": "Hint shown when no channel-specific action is available" }, - "waylandUnsupportedTitle": "Wayland is not supported", - "@waylandUnsupportedTitle": { "description": "Title for the Wayland-unsupported gate screen" }, - - "waylandUnsupportedBadge": "Open source · X11 only", - "@waylandUnsupportedBadge": { "description": "Badge chip on the Wayland-unsupported gate screen" }, - - "waylandUnsupportedBody": "Linux support is still a work in progress. This project is maintained by a single person and we need more testers to move forward.\n\nCopyPaste works fully on X11 — to use it, log in with an X11 session. Sorry for the inconvenience.", - "@waylandUnsupportedBody": { "description": "Body text on the Wayland-unsupported gate screen" }, - - "waylandUnsupportedGitHub": "View on GitHub", - "@waylandUnsupportedGitHub": { "description": "Button to open the repo from the Wayland gate" }, - - "waylandUnsupportedClose": "Close", - "@waylandUnsupportedClose": { "description": "Button to exit the app from the Wayland gate" }, - - "linuxHotkeyFallbackWarning": "The shortcut {requested} is unavailable on this X11 desktop. CopyPaste is temporarily using {fallback}. You can change it in Settings.", - "@linuxHotkeyFallbackWarning": { - "description": "Shown when the preferred Linux hotkey is unavailable and a temporary fallback is active", - "placeholders": { - "requested": { "type": "String" }, - "fallback": { "type": "String" } - } - }, - - "linuxHotkeyConflictWarning": "The shortcut {requested} is unavailable on this X11 desktop, and the temporary fallback {fallback} also failed. Open Settings to choose another shortcut.", - "@linuxHotkeyConflictWarning": { - "description": "Shown when both the requested Linux hotkey and the temporary fallback fail", - "placeholders": { - "requested": { "type": "String" }, - "fallback": { "type": "String" } - } - }, - - "linuxHotkeyGrabFailedWarning": "The shortcut {hotkey} is being used by another application. Change it in Settings → Shortcuts.", - "@linuxHotkeyGrabFailedWarning": { - "description": "Shown when XGrabKey fails because another app already owns the shortcut", - "placeholders": { - "hotkey": { "type": "String" } - } - }, - - "linuxPasteFocusTimeoutWarning": "The clipboard has your content. Paste manually with Ctrl+V.", - "@linuxPasteFocusTimeoutWarning": { - "description": "Shown when the X11 paste flow could not regain focus on the previous window in time" - }, - - "linuxAppindicatorBannerTitle": "System tray icon unavailable", - "@linuxAppindicatorBannerTitle": { "description": "Title of the AppIndicator missing banner" }, - - "linuxAppindicatorBannerBody": "Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.", - "@linuxAppindicatorBannerBody": { "description": "Body of the AppIndicator missing banner" }, - - "linuxXtestBannerTitle": "Automatic paste-back disabled", - "@linuxXtestBannerTitle": { "description": "Title of the missing XTest banner" }, - - "linuxXtestBannerBody": "The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.", - "@linuxXtestBannerBody": { "description": "Body of the missing XTest banner" }, - - "linuxBannerDismiss": "Dismiss", - "@linuxBannerDismiss": { "description": "Action to dismiss a Linux capability banner" }, - "wakeupHint": "CopyPaste runs in the background — press {hotkey} or click the tray icon to open it anytime.", "@wakeupHint": { "description": "In-app snackbar shown inside the window when it is raised by a second launch attempt", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 47f1ec85..ed5fa789 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -231,7 +231,7 @@ "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, macOS y Linux.\nTodo local \u2014 tu historial, siempre a mano. Sin cuentas, sin telemetr\u00eda, sin suscripciones.", + "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", @@ -259,7 +259,6 @@ "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.", - "updateAvailableLinux": "La versi\u00f3n {version} est\u00e1 disponible.\n\nDescarga 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", @@ -279,21 +278,6 @@ "blockedQuit": "Salir de CopyPaste", "blockedFallbackHint": "Visita https://github.com/rgdevment/CopyPaste/releases para descargar el instalador m\u00e1s reciente.", - "waylandUnsupportedTitle": "Wayland no est\u00e1 soportado", - "waylandUnsupportedBadge": "Open source \u00b7 Solo X11", - "waylandUnsupportedBody": "El soporte en Linux est\u00e1 en progreso. Este proyecto lo mantiene una sola persona y necesitamos m\u00e1s testers para avanzar.\n\nCopyPaste funciona completamente en X11 \u2014 para usarlo, inicia sesi\u00f3n con X11. Lamentamos las molestias.", - "waylandUnsupportedGitHub": "Ver en GitHub", - "waylandUnsupportedClose": "Cerrar", - "linuxHotkeyFallbackWarning": "El atajo {requested} no est\u00e1 disponible en este escritorio X11. CopyPaste est\u00e1 usando temporalmente {fallback}. Puedes cambiarlo en Configuraci\u00f3n.", - "linuxHotkeyConflictWarning": "El atajo {requested} no est\u00e1 disponible en este escritorio X11 y el fallback temporal {fallback} tambi\u00e9n fall\u00f3. Abre Configuraci\u00f3n para elegir otro atajo.", - "linuxHotkeyGrabFailedWarning": "El atajo {hotkey} est\u00e1 siendo usado por otra aplicaci\u00f3n. C\u00e1mbialo en Configuraci\u00f3n \u2192 Atajos.", - "linuxPasteFocusTimeoutWarning": "El portapapeles tiene tu contenido. P\u00e9galo manualmente con Ctrl+V.", - "linuxAppindicatorBannerTitle": "\u00cdcono de bandeja no disponible", - "linuxAppindicatorBannerBody": "Tu escritorio no expone un host de AppIndicator, por lo que el \u00edcono de CopyPaste no aparecer\u00e1 en la bandeja. Instala una extensi\u00f3n de bandeja para tu distribuci\u00f3n y reinicia CopyPaste.", - "linuxXtestBannerTitle": "Pegado autom\u00e1tico deshabilitado", - "linuxXtestBannerBody": "La extensi\u00f3n XTest de X11 no est\u00e1 disponible, por lo que CopyPaste no puede inyectar Ctrl+V autom\u00e1ticamente. Los elementos siguen copi\u00e1ndose al portapapeles \u2014 p\u00e9galos manualmente con Ctrl+V.", - "linuxBannerDismiss": "Descartar", - "wakeupHint": "CopyPaste se ejecuta en segundo plano \u2014 presiona {hotkey} o haz clic en el \u00edcono de la bandeja para abrirlo cuando quieras.", "taskbarOpenHint": "Tip: presiona {hotkey} para abrir y pegar autom\u00e1ticamente, sin perder el foco.", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 88bd87b6..611ddda4 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -1217,7 +1217,7 @@ abstract class AppLocalizations { /// About section description /// /// In en, this message translates to: - /// **'A modern clipboard manager built to feel native on Windows, macOS, and Linux.\nLocal-first — your history, always at hand. No accounts, no telemetry, no subscriptions.'** + /// **'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 @@ -1364,12 +1364,6 @@ abstract class AppLocalizations { /// **'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 Linux - /// - /// In en, this message translates to: - /// **'Version {version} is available.\n\nDownload the latest release from GitHub.'** - String updateAvailableLinux(String version); - /// Update dialog message for MS Store builds /// /// In en, this message translates to: @@ -1466,90 +1460,6 @@ abstract class AppLocalizations { /// **'Visit https://github.com/rgdevment/CopyPaste/releases to download the latest installer.'** String get blockedFallbackHint; - /// Title for the Wayland-unsupported gate screen - /// - /// In en, this message translates to: - /// **'Wayland is not supported'** - String get waylandUnsupportedTitle; - - /// Badge chip on the Wayland-unsupported gate screen - /// - /// In en, this message translates to: - /// **'Open source · X11 only'** - String get waylandUnsupportedBadge; - - /// Body text on the Wayland-unsupported gate screen - /// - /// In en, this message translates to: - /// **'Linux support is still a work in progress. This project is maintained by a single person and we need more testers to move forward.\n\nCopyPaste works fully on X11 — to use it, log in with an X11 session. Sorry for the inconvenience.'** - String get waylandUnsupportedBody; - - /// Button to open the repo from the Wayland gate - /// - /// In en, this message translates to: - /// **'View on GitHub'** - String get waylandUnsupportedGitHub; - - /// Button to exit the app from the Wayland gate - /// - /// In en, this message translates to: - /// **'Close'** - String get waylandUnsupportedClose; - - /// Shown when the preferred Linux hotkey is unavailable and a temporary fallback is active - /// - /// In en, this message translates to: - /// **'The shortcut {requested} is unavailable on this X11 desktop. CopyPaste is temporarily using {fallback}. You can change it in Settings.'** - String linuxHotkeyFallbackWarning(String requested, String fallback); - - /// Shown when both the requested Linux hotkey and the temporary fallback fail - /// - /// In en, this message translates to: - /// **'The shortcut {requested} is unavailable on this X11 desktop, and the temporary fallback {fallback} also failed. Open Settings to choose another shortcut.'** - String linuxHotkeyConflictWarning(String requested, String fallback); - - /// Shown when XGrabKey fails because another app already owns the shortcut - /// - /// In en, this message translates to: - /// **'The shortcut {hotkey} is being used by another application. Change it in Settings → Shortcuts.'** - String linuxHotkeyGrabFailedWarning(String hotkey); - - /// Shown when the X11 paste flow could not regain focus on the previous window in time - /// - /// In en, this message translates to: - /// **'The clipboard has your content. Paste manually with Ctrl+V.'** - String get linuxPasteFocusTimeoutWarning; - - /// Title of the AppIndicator missing banner - /// - /// In en, this message translates to: - /// **'System tray icon unavailable'** - String get linuxAppindicatorBannerTitle; - - /// Body of the AppIndicator missing banner - /// - /// In en, this message translates to: - /// **'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'** - String get linuxAppindicatorBannerBody; - - /// Title of the missing XTest banner - /// - /// In en, this message translates to: - /// **'Automatic paste-back disabled'** - String get linuxXtestBannerTitle; - - /// Body of the missing XTest banner - /// - /// In en, this message translates to: - /// **'The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.'** - String get linuxXtestBannerBody; - - /// Action to dismiss a Linux capability banner - /// - /// In en, this message translates to: - /// **'Dismiss'** - String get linuxBannerDismiss; - /// In-app snackbar shown inside the window when it is raised by a second launch attempt /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 582c7d4d..1bfbfcf2 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -614,7 +614,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aboutDescription => - 'A modern clipboard manager built to feel native on Windows, macOS, and Linux.\nLocal-first — your history, always at hand. No accounts, no telemetry, no subscriptions.'; + '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'; @@ -699,11 +699,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Version $version is available.\n\nUpdate via Homebrew:\nbrew upgrade copypaste\n\nOr download the latest release from GitHub.'; } - @override - String updateAvailableLinux(String version) { - return 'Version $version is available.\n\nDownload 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.'; @@ -766,58 +761,6 @@ class AppLocalizationsEn extends AppLocalizations { String get blockedFallbackHint => 'Visit https://github.com/rgdevment/CopyPaste/releases to download the latest installer.'; - @override - String get waylandUnsupportedTitle => 'Wayland is not supported'; - - @override - String get waylandUnsupportedBadge => 'Open source · X11 only'; - - @override - String get waylandUnsupportedBody => - 'Linux support is still a work in progress. This project is maintained by a single person and we need more testers to move forward.\n\nCopyPaste works fully on X11 — to use it, log in with an X11 session. Sorry for the inconvenience.'; - - @override - String get waylandUnsupportedGitHub => 'View on GitHub'; - - @override - String get waylandUnsupportedClose => 'Close'; - - @override - String linuxHotkeyFallbackWarning(String requested, String fallback) { - return 'The shortcut $requested is unavailable on this X11 desktop. CopyPaste is temporarily using $fallback. You can change it in Settings.'; - } - - @override - String linuxHotkeyConflictWarning(String requested, String fallback) { - return 'The shortcut $requested is unavailable on this X11 desktop, and the temporary fallback $fallback also failed. Open Settings to choose another shortcut.'; - } - - @override - String linuxHotkeyGrabFailedWarning(String hotkey) { - return 'The shortcut $hotkey is being used by another application. Change it in Settings → Shortcuts.'; - } - - @override - String get linuxPasteFocusTimeoutWarning => - 'The clipboard has your content. Paste manually with Ctrl+V.'; - - @override - String get linuxAppindicatorBannerTitle => 'System tray icon unavailable'; - - @override - String get linuxAppindicatorBannerBody => - 'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'; - - @override - String get linuxXtestBannerTitle => 'Automatic paste-back disabled'; - - @override - String get linuxXtestBannerBody => - 'The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.'; - - @override - String get linuxBannerDismiss => 'Dismiss'; - @override String wakeupHint(String hotkey) { return 'CopyPaste runs in the background — press $hotkey or click the tray icon to open it anytime.'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 5aff977f..7f962d09 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -620,7 +620,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get aboutDescription => - 'Un gestor de portapapeles moderno, nativo en Windows, macOS y Linux.\nTodo local — tu historial, siempre a mano. Sin cuentas, sin telemetría, sin suscripciones.'; + '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'; @@ -705,11 +705,6 @@ class AppLocalizationsEs extends AppLocalizations { 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 updateAvailableLinux(String version) { - return 'La versión $version está disponible.\n\nDescarga 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.'; @@ -772,58 +767,6 @@ class AppLocalizationsEs extends AppLocalizations { String get blockedFallbackHint => 'Visita https://github.com/rgdevment/CopyPaste/releases para descargar el instalador más reciente.'; - @override - String get waylandUnsupportedTitle => 'Wayland no está soportado'; - - @override - String get waylandUnsupportedBadge => 'Open source · Solo X11'; - - @override - String get waylandUnsupportedBody => - 'El soporte en Linux está en progreso. Este proyecto lo mantiene una sola persona y necesitamos más testers para avanzar.\n\nCopyPaste funciona completamente en X11 — para usarlo, inicia sesión con X11. Lamentamos las molestias.'; - - @override - String get waylandUnsupportedGitHub => 'Ver en GitHub'; - - @override - String get waylandUnsupportedClose => 'Cerrar'; - - @override - String linuxHotkeyFallbackWarning(String requested, String fallback) { - return 'El atajo $requested no está disponible en este escritorio X11. CopyPaste está usando temporalmente $fallback. Puedes cambiarlo en Configuración.'; - } - - @override - String linuxHotkeyConflictWarning(String requested, String fallback) { - return 'El atajo $requested no está disponible en este escritorio X11 y el fallback temporal $fallback también falló. Abre Configuración para elegir otro atajo.'; - } - - @override - String linuxHotkeyGrabFailedWarning(String hotkey) { - return 'El atajo $hotkey está siendo usado por otra aplicación. Cámbialo en Configuración → Atajos.'; - } - - @override - String get linuxPasteFocusTimeoutWarning => - 'El portapapeles tiene tu contenido. Pégalo manualmente con Ctrl+V.'; - - @override - String get linuxAppindicatorBannerTitle => 'Ícono de bandeja no disponible'; - - @override - String get linuxAppindicatorBannerBody => - 'Tu escritorio no expone un host de AppIndicator, por lo que el ícono de CopyPaste no aparecerá en la bandeja. Instala una extensión de bandeja para tu distribución y reinicia CopyPaste.'; - - @override - String get linuxXtestBannerTitle => 'Pegado automático deshabilitado'; - - @override - String get linuxXtestBannerBody => - 'La extensión XTest de X11 no está disponible, por lo que CopyPaste no puede inyectar Ctrl+V automáticamente. Los elementos siguen copiándose al portapapeles — pégalos manualmente con Ctrl+V.'; - - @override - String get linuxBannerDismiss => 'Descartar'; - @override String wakeupHint(String hotkey) { return 'CopyPaste se ejecuta en segundo plano — presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo cuando quieras.'; diff --git a/app/lib/main.dart b/app/lib/main.dart index 9e0ba04d..53f77981 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -14,15 +14,12 @@ import 'package:window_manager/window_manager.dart'; import 'services/auto_update_service.dart'; import 'services/install_channel.dart'; -import 'services/linux_capabilities.dart'; import 'services/release_manifest_service.dart'; import 'shell/app_window.dart'; import 'shell/focus_manager.dart'; +import 'shell/hotkey_binding.dart'; import 'shell/hotkey_handler.dart'; -import 'shell/linux_hotkey_registration.dart'; -import 'shell/linux_session.dart'; -import 'shell/linux_shell.dart'; import 'shell/single_instance.dart'; import 'shell/startup_helper.dart'; import 'shell/tray_icon.dart'; @@ -31,7 +28,6 @@ import 'shell/win_package_context.dart'; import 'shell/desktop_notifier.dart'; import 'screens/main_screen.dart'; import 'screens/settings_screen.dart'; -import 'screens/wayland_unsupported_screen.dart'; import 'theme/compact_theme.dart'; import 'theme/theme_provider.dart'; import 'l10n/app_localizations.dart'; @@ -39,9 +35,6 @@ import 'screens/permission_gate_screen.dart'; import 'screens/desktop_onboarding_screen.dart'; import 'screens/blocked_version_screen.dart'; -// Re-exported so existing tests can import isWaylandSession from main.dart. -export 'shell/linux_session.dart' show isWaylandSession; - bool _isMicaDark(String themeMode) => switch (themeMode) { 'dark' => true, 'auto' || @@ -118,8 +111,6 @@ Future _run() async { ? WindowsNativeThumbnailProvider() : Platform.isMacOS ? MacOSNativeThumbnailProvider() - : Platform.isLinux - ? LinuxNativeThumbnailProvider() : null; final clipboardService = ClipboardService( repo, @@ -166,15 +157,6 @@ Future _run() async { AppLogger.warn('main: Window.setEffect failed (non-fatal): $e'); } - if (Platform.isLinux) { - try { - final caps = await LinuxCapabilitiesService.detect(); - AppLogger.info('main: linux capabilities $caps'); - } catch (e) { - AppLogger.warn('main: LinuxCapabilities.detect failed (non-fatal): $e'); - } - } - runApp( CopyPasteApp( storage: storage, @@ -228,12 +210,9 @@ class _CopyPasteAppState extends State Future? _pendingConfigSave; bool _showPermissionGate = false; bool _showOnboarding = false; - bool _showWaylandUnsupported = false; - bool _linuxPrefersDark = false; String? _availableUpdateVersion; ManifestState? _manifestState; bool _programmaticRestore = false; - Timer? _blurHideTimer; bool _hotkeyToggleInProgress = false; bool _directPlainPasteInProgress = false; bool _itemPasteInProgress = false; @@ -324,19 +303,6 @@ class _CopyPasteAppState extends State Future _initShellBody() async { windowManager.addListener(this); final isFirstRun = widget.storage.isFirstRun; - final wayland = Platform.isLinux && isWaylandSession(); - - if (wayland) { - await _appWindow.init(startVisible: true); - await _appWindow.enterGateMode(); - if (mounted) setState(() => _showWaylandUnsupported = true); - return; - } - - if (Platform.isLinux) { - final isDark = await linuxPrefersDarkMode(); - if (mounted) setState(() => _linuxPrefersDark = isDark); - } _startListening(); bool macosGranted = true; @@ -345,20 +311,14 @@ class _CopyPasteAppState extends State } final isUpdate = _config.lastRunVersion != AppConfig.appVersion; - final windowsNeedsOnboarding = - Platform.isWindows && !_config.hasSeenOnboarding; - final linuxNeedsOnboarding = - Platform.isLinux && !_config.hasCompletedOnboarding; final desktopNeedsOnboarding = - windowsNeedsOnboarding || linuxNeedsOnboarding; + Platform.isWindows && !_config.hasSeenOnboarding; final showOnStart = isFirstRun && - (Platform.isLinux || - (Platform.isMacOS && macosGranted) || - Platform.isWindows) || + ((Platform.isMacOS && macosGranted) || Platform.isWindows) || desktopNeedsOnboarding; await _appWindow.init(startVisible: showOnStart); - if (showOnStart && (Platform.isWindows || linuxNeedsOnboarding)) { + if (showOnStart && Platform.isWindows) { try { await _appWindow.enterGateMode(); } catch (e) { @@ -468,67 +428,19 @@ class _CopyPasteAppState extends State } Future _registerHotkeyWithFeedback() async { - if (!Platform.isLinux) { - 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() ?? '', - ), - ); - } - return; - } - - // Wayland is blocked before this point in _initShell — only X11 reaches here. final result = await _hotkeyHandler.registerWithFallback(); _reportPlainPasteHotkeyFailure(); - if (result.status == HotkeyRegistrationStatus.fallbackRegistered) { - AppLogger.info( - 'Primary Linux hotkey failed, using temporary fallback: ' - '${result.requestedBinding.label()} -> ' - '${result.effectiveBinding?.label()}', - ); - if (result.failureReason == HotkeyFailureReason.grabFailed) { - _showShellNotice( - (l) => - l.linuxHotkeyGrabFailedWarning(result.requestedBinding.label()), - ); - } else { - _showShellNotice( - (l) => l.linuxHotkeyFallbackWarning( - result.requestedBinding.label(), - result.effectiveBinding?.label() ?? - kLinuxTemporaryFallbackHotkey.label(), - ), - ); - } - return; - } - if (result.status == HotkeyRegistrationStatus.failed) { - AppLogger.error( - 'Linux hotkey registration failed for ${result.requestedBinding.label()}', + _showShellNotice( + (l) => l.hotkeyRegistrationFailed(result.requestedBinding.label()), + ); + } else if (result.status == HotkeyRegistrationStatus.fallbackRegistered) { + _showShellNotice( + (l) => l.hotkeyFallbackActive( + result.requestedBinding.label(), + result.effectiveBinding?.label() ?? '', + ), ); - if (result.failureReason == HotkeyFailureReason.grabFailed) { - _showShellNotice( - (l) => - l.linuxHotkeyGrabFailedWarning(result.requestedBinding.label()), - ); - } else { - _showShellNotice( - (l) => l.linuxHotkeyConflictWarning( - result.requestedBinding.label(), - kLinuxTemporaryFallbackHotkey.label(), - ), - ); - } } } @@ -543,9 +455,6 @@ class _CopyPasteAppState extends State ThemeMode get _effectiveThemeMode { final mode = _config.themeMode; - if (Platform.isLinux && (mode == 'auto' || mode == 'system')) { - return _linuxPrefersDark ? ThemeMode.dark : ThemeMode.light; - } return switch (mode) { 'dark' => ThemeMode.dark, 'auto' || 'system' => ThemeMode.system, @@ -586,7 +495,7 @@ class _CopyPasteAppState extends State } void _startListening() { - if (!Platform.isWindows && !Platform.isMacOS && !Platform.isLinux) return; + if (!Platform.isWindows && !Platform.isMacOS) return; AppLogger.info('_startListening: subscribing to clipboard event stream'); _listenerSubscription = widget.listener.onEvent.listen( _onClipboardEvent, @@ -897,14 +806,6 @@ class _CopyPasteAppState extends State return next; } - Future _updateLinuxConfig(AppConfig Function(AppConfig) update) async { - final next = update(_config); - if (identical(next, _config)) return; - _config = next; - if (mounted) setState(() {}); - await _config.save(widget.storage.configFilePath); - } - Future _toggleWindow() async { _programmaticRestore = true; try { @@ -1014,13 +915,6 @@ class _CopyPasteAppState extends State AppLogger.warn( 'Paste was not sent: error=${response.errorCode ?? 'unknown'}', ); - if (response.isFocusTimeout && Platform.isLinux) { - _showShellNotice( - (l) => l.linuxPasteFocusTimeoutWarning, - revealWhenHidden: true, - ); - return; - } if (response.errorCode == 'targetElevated') { _showShellNotice((l) => l.pasteTargetElevated, revealWhenHidden: true); return; @@ -1103,13 +997,6 @@ class _CopyPasteAppState extends State } catch (e) { AppLogger.error('cleanup tray: $e'); } - if (Platform.isLinux) { - try { - await LinuxShell.dispose(); - } catch (e) { - AppLogger.error('cleanup linux shell: $e'); - } - } try { await widget.clipboardService.dispose(); } catch (e) { @@ -1265,28 +1152,12 @@ class _CopyPasteAppState extends State await _appWindow.exitSettingsMode(); } - @override - void onWindowFocus() { - _blurHideTimer?.cancel(); - _blurHideTimer = null; - } - @override void onWindowBlur() { if (!_appWindow.isReady || !_appWindow.isVisible) return; if (_appWindow.isGateMode) return; if (!_config.hideOnDeactivate) return; - if (Platform.isLinux) { - _blurHideTimer?.cancel(); - _blurHideTimer = Timer(const Duration(milliseconds: 500), () async { - _blurHideTimer = null; - final focus = await LinuxShell.getInputFocus(); - if (focus != null && focus.ownsFocus) return; - unawaited(_appWindow.hideIfNotPinned()); - }); - } else { - unawaited(_appWindow.hideIfNotPinned()); - } + unawaited(_appWindow.hideIfNotPinned()); } @override @@ -1350,7 +1221,6 @@ class _CopyPasteAppState extends State _persistConfig( (_) => fromOnboarding.copyWith( hasSeenOnboarding: true, - hasCompletedOnboarding: true, lastRunVersion: AppConfig.appVersion, ), ), @@ -1369,7 +1239,6 @@ class _CopyPasteAppState extends State _persistConfig( (_) => fromOnboarding.copyWith( hasSeenOnboarding: true, - hasCompletedOnboarding: true, lastRunVersion: AppConfig.appVersion, ), ), @@ -1471,12 +1340,6 @@ class _CopyPasteAppState extends State ); } - if (_showWaylandUnsupported) { - return WaylandUnsupportedScreen( - onClose: () => unawaited(_exitApp()), - ); - } - if (_showOnboarding) { final binding = HotkeyBinding( virtualKey: _config.hotkeyVirtualKey, @@ -1551,13 +1414,6 @@ class _CopyPasteAppState extends State current: AppConfig.appVersion, state: _manifestState, ), - appConfig: Platform.isLinux ? _config : null, - linuxCapabilities: Platform.isLinux - ? LinuxCapabilitiesService.current - : null, - onLinuxConfigUpdate: Platform.isLinux - ? _updateLinuxConfig - : null, ); }, ), diff --git a/app/lib/screens/blocked_version_screen.dart b/app/lib/screens/blocked_version_screen.dart index 33b35a2f..fda9efc0 100644 --- a/app/lib/screens/blocked_version_screen.dart +++ b/app/lib/screens/blocked_version_screen.dart @@ -158,7 +158,6 @@ class BlockedVersionScreen extends StatelessWidget { static String? _packageManagerName(InstallChannel channel) => switch (channel) { InstallChannel.homebrew => 'brew', - InstallChannel.snap => 'snap', InstallChannel.scoop => 'scoop', _ => null, }; diff --git a/app/lib/screens/linux_capabilities_banner.dart b/app/lib/screens/linux_capabilities_banner.dart deleted file mode 100644 index d1a577b0..00000000 --- a/app/lib/screens/linux_capabilities_banner.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; - -import '../l10n/app_localizations.dart'; -import '../services/linux_capabilities.dart'; -import '../theme/theme_provider.dart'; - -typedef LinuxBannerDismissCallback = - Future Function(AppConfig Function(AppConfig) update); - -class LinuxCapabilitiesBanner extends StatelessWidget { - const LinuxCapabilitiesBanner({ - super.key, - required this.config, - required this.capabilities, - required this.onDismiss, - }); - - final AppConfig config; - final LinuxCapabilities capabilities; - final LinuxBannerDismissCallback onDismiss; - - _BannerKind? _resolveActiveBanner() { - if (!capabilities.isUsable) return null; - if (!capabilities.hasAppIndicator && - !config.linuxAppindicatorWarningDismissed) { - return _BannerKind.appIndicator; - } - if (!capabilities.hasXTest && !config.linuxXtestWarningDismissed) { - return _BannerKind.xtest; - } - return null; - } - - @override - Widget build(BuildContext context) { - final kind = _resolveActiveBanner(); - if (kind == null) return const SizedBox.shrink(); - - final l = AppLocalizations.of(context); - final colors = CopyPasteTheme.colorsOf(context); - final (title, body) = switch (kind) { - _BannerKind.appIndicator => ( - l.linuxAppindicatorBannerTitle, - l.linuxAppindicatorBannerBody, - ), - _BannerKind.xtest => (l.linuxXtestBannerTitle, l.linuxXtestBannerBody), - }; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - color: colors.primary.withValues(alpha: 0.10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.warning_amber_rounded, size: 16, color: colors.primary), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: colors.onSurface, - ), - ), - const SizedBox(height: 2), - Text( - body, - style: TextStyle(fontSize: 11, color: colors.onSurfaceMuted), - ), - ], - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: () => _dismiss(kind), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Tooltip( - message: l.linuxBannerDismiss, - child: Icon( - Icons.close_rounded, - size: 16, - color: colors.onSurfaceMuted, - ), - ), - ), - ), - ], - ), - ); - } - - Future _dismiss(_BannerKind kind) { - return onDismiss((c) { - switch (kind) { - case _BannerKind.appIndicator: - return c.copyWith(linuxAppindicatorWarningDismissed: true); - case _BannerKind.xtest: - return c.copyWith(linuxXtestWarningDismissed: true); - } - }); - } -} - -enum _BannerKind { appIndicator, xtest } diff --git a/app/lib/screens/main_screen.dart b/app/lib/screens/main_screen.dart index fbead9b1..ba8e3059 100644 --- a/app/lib/screens/main_screen.dart +++ b/app/lib/screens/main_screen.dart @@ -8,7 +8,6 @@ import 'package:flutter/services.dart'; import '../helpers/url_helper.dart'; import '../l10n/app_localizations.dart'; import '../services/auto_update_service.dart'; -import '../services/linux_capabilities.dart'; import '../services/release_manifest_service.dart'; import '../theme/app_theme_data.dart'; import '../theme/theme_provider.dart'; @@ -18,7 +17,6 @@ import '../widgets/filter_bar.dart'; import '../widgets/filter_tab_bar.dart'; import '../widgets/label_color_dialog.dart'; import '../widgets/title_bar.dart'; -import 'linux_capabilities_banner.dart'; enum ClipboardTab { recent, pinned } @@ -41,9 +39,6 @@ class MainScreen extends StatefulWidget { this.onDismissHint, this.updateVersion, this.updateSeverity, - this.appConfig, - this.linuxCapabilities, - this.onLinuxConfigUpdate, super.key, }); @@ -64,10 +59,6 @@ class MainScreen extends StatefulWidget { final VoidCallback? onDismissHint; final String? updateVersion; final ManifestSeverity? updateSeverity; - final AppConfig? appConfig; - final LinuxCapabilities? linuxCapabilities; - final Future Function(AppConfig Function(AppConfig))? - onLinuxConfigUpdate; @override State createState() => MainScreenState(); @@ -603,14 +594,6 @@ class MainScreenState extends State { }, ), if (widget.showHint) _buildHintBanner(colors), - if (widget.appConfig != null && - widget.linuxCapabilities != null && - widget.onLinuxConfigUpdate != null) - LinuxCapabilitiesBanner( - config: widget.appConfig!, - capabilities: widget.linuxCapabilities!, - onDismiss: widget.onLinuxConfigUpdate!, - ), Expanded( child: _isEmpty ? const EmptyState() @@ -844,8 +827,6 @@ class MainScreenState extends State { ? l.updateAvailableStore(version) : Platform.isMacOS ? l.updateAvailableMac(version) - : Platform.isLinux - ? l.updateAvailableLinux(version) : l.updateAvailableWindows(version), ), ), diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index 514de6fe..f257b9e7 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -380,7 +380,7 @@ class _SettingsScreenState extends State { return parts.join(); } if (useCtrl) parts.add('Ctrl'); - if (useMeta) parts.add(Platform.isLinux ? 'Super' : 'Win'); + if (useMeta) parts.add('Win'); if (useAlt) parts.add('Alt'); if (useShift) parts.add('Shift'); parts.add(keyName); @@ -1216,11 +1216,7 @@ class _SettingsScreenState extends State { }, ), _ModifierChip( - label: Platform.isMacOS - ? '⌘ Command' - : Platform.isLinux - ? 'Super' - : 'Win', + label: Platform.isMacOS ? '⌘ Command' : 'Win', selected: _hotkeyWin, colors: colors, onTap: () { @@ -1305,11 +1301,7 @@ class _SettingsScreenState extends State { }, ), _ModifierChip( - label: Platform.isMacOS - ? '⌘ Command' - : Platform.isLinux - ? 'Super' - : 'Win', + label: Platform.isMacOS ? '⌘ Command' : 'Win', selected: _plainPasteHotkeyWin, colors: colors, onTap: () { diff --git a/app/lib/screens/wayland_unsupported_screen.dart b/app/lib/screens/wayland_unsupported_screen.dart deleted file mode 100644 index 60be9780..00000000 --- a/app/lib/screens/wayland_unsupported_screen.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../helpers/url_helper.dart'; -import '../l10n/app_localizations.dart'; - -class WaylandUnsupportedScreen extends StatelessWidget { - const WaylandUnsupportedScreen({required this.onClose, super.key}); - - final VoidCallback onClose; - - static const _repoUrl = 'https://github.com/rgdevment/CopyPaste'; - - @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: 320, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of( - context, - ).copyWith(scrollbars: false), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 36), - 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.waylandUnsupportedTitle, - style: tt.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - _Badge(label: l.waylandUnsupportedBadge, colorScheme: cs), - const SizedBox(height: 20), - Divider(color: cs.outlineVariant, height: 1), - const SizedBox(height: 20), - Text( - l.waylandUnsupportedBody, - style: tt.bodyMedium?.copyWith( - color: cs.onSurfaceVariant, - height: 1.6, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 28), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () => UrlHelper.open(_repoUrl), - icon: const Icon(Icons.open_in_new_rounded, size: 15), - label: Text(l.waylandUnsupportedGitHub), - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: onClose, - child: Text(l.waylandUnsupportedClose), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _Badge extends StatelessWidget { - const _Badge({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: Text( - label, - style: TextStyle( - fontSize: 12, - color: colorScheme.primary, - fontWeight: FontWeight.w500, - ), - ), - ); - } -} diff --git a/app/lib/services/install_channel.dart b/app/lib/services/install_channel.dart index 2ba4ab1d..37a09892 100644 --- a/app/lib/services/install_channel.dart +++ b/app/lib/services/install_channel.dart @@ -13,13 +13,10 @@ enum InstallChannel { scoop, githubMacos, homebrew, - githubLinux, - appImage, - snap, unknown, } -enum HostPlatform { macos, linux, windows, other } +enum HostPlatform { macos, windows, other } class InstallChannelDetector { static HostPlatform? platformOverride; @@ -47,12 +44,6 @@ class InstallChannelDetector { return InstallChannel.githubMacos; } - if (host == HostPlatform.linux) { - if (path.contains('.AppImage')) return InstallChannel.appImage; - if (path.startsWith('/snap/')) return InstallChannel.snap; - return InstallChannel.githubLinux; - } - if (host == HostPlatform.windows) { if (_isScoopPath(path)) return InstallChannel.scoop; return InstallChannel.githubWindows; @@ -63,7 +54,6 @@ class InstallChannelDetector { static HostPlatform _currentPlatform() { if (Platform.isMacOS) return HostPlatform.macos; - if (Platform.isLinux) return HostPlatform.linux; if (Platform.isWindows) return HostPlatform.windows; return HostPlatform.other; } @@ -80,14 +70,10 @@ class InstallChannelDetector { return 'github_macos'; case InstallChannel.homebrew: return 'homebrew'; - case InstallChannel.githubLinux: - return 'github_linux'; - case InstallChannel.appImage: - return 'github_linux'; - case InstallChannel.snap: - return 'snap'; + // Deliberately absent from the manifest: an unidentified host has no + // install channel, so the blocked-version screen falls back to the hint. case InstallChannel.unknown: - return 'github_linux'; + return 'unknown'; } } diff --git a/app/lib/services/linux_capabilities.dart b/app/lib/services/linux_capabilities.dart deleted file mode 100644 index 149973ca..00000000 --- a/app/lib/services/linux_capabilities.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -import '../shell/linux_session.dart'; - -@immutable -class LinuxCapabilities { - const LinuxCapabilities({ - required this.session, - required this.isX11, - required this.hasXTest, - required this.hasAppIndicator, - required this.hasEwmh, - required this.detectedDesktopEnv, - required this.detectedWmName, - required this.detectionTimedOut, - }); - - final LinuxSessionInfo session; - final bool isX11; - final bool hasXTest; - final bool hasAppIndicator; - final bool hasEwmh; - final String detectedDesktopEnv; - final String detectedWmName; - final bool detectionTimedOut; - - bool get isLinux => Platform.isLinux; - bool get isWayland => session.isWayland; - bool get isUsable => isLinux && isX11; - - static const LinuxCapabilities unsupported = LinuxCapabilities( - session: LinuxSessionInfo.unsupported, - isX11: false, - hasXTest: false, - hasAppIndicator: false, - hasEwmh: false, - detectedDesktopEnv: '', - detectedWmName: '', - detectionTimedOut: false, - ); - - LinuxCapabilities copyWith({ - bool? isX11, - bool? hasXTest, - bool? hasAppIndicator, - bool? hasEwmh, - String? detectedDesktopEnv, - String? detectedWmName, - bool? detectionTimedOut, - }) { - return LinuxCapabilities( - session: session, - isX11: isX11 ?? this.isX11, - hasXTest: hasXTest ?? this.hasXTest, - hasAppIndicator: hasAppIndicator ?? this.hasAppIndicator, - hasEwmh: hasEwmh ?? this.hasEwmh, - detectedDesktopEnv: detectedDesktopEnv ?? this.detectedDesktopEnv, - detectedWmName: detectedWmName ?? this.detectedWmName, - detectionTimedOut: detectionTimedOut ?? this.detectionTimedOut, - ); - } - - @override - String toString() => - 'LinuxCapabilities(isX11=$isX11, hasXTest=$hasXTest, ' - 'hasAppIndicator=$hasAppIndicator, ' - 'hasEwmh=$hasEwmh, desktopEnv=$detectedDesktopEnv, wm=$detectedWmName, ' - 'timedOut=$detectionTimedOut, session=$session)'; -} - -abstract class LinuxCapabilitiesChannel { - Future?> invokeShell(String method); - Future?> invokeListener(String method); -} - -class _DefaultLinuxCapabilitiesChannel implements LinuxCapabilitiesChannel { - const _DefaultLinuxCapabilitiesChannel(); - - static const MethodChannel _shell = MethodChannel('copypaste/linux_shell'); - static const MethodChannel _listener = MethodChannel( - 'copypaste/clipboard_writer', - ); - - @override - Future?> invokeShell(String method) async { - final result = await _shell.invokeMethod(method); - return result is Map ? Map.from(result) : null; - } - - @override - Future?> invokeListener(String method) async { - final result = await _listener.invokeMethod(method); - return result is Map ? Map.from(result) : null; - } -} - -class LinuxCapabilitiesService { - LinuxCapabilitiesService._(); // coverage:ignore-line - - static LinuxCapabilities _cache = LinuxCapabilities.unsupported; - static bool _initialized = false; - - static LinuxCapabilities get current => _cache; - static bool get isInitialized => _initialized; - - @visibleForTesting - static void resetForTesting([LinuxCapabilities? value]) { - _cache = value ?? LinuxCapabilities.unsupported; - _initialized = value != null; - } - - static Future detect({ - LinuxCapabilitiesChannel channel = const _DefaultLinuxCapabilitiesChannel(), - Duration timeout = const Duration(milliseconds: 800), - @visibleForTesting LinuxSessionInfo? sessionOverride, - }) async { - if (!Platform.isLinux) { - _cache = LinuxCapabilities.unsupported; - _initialized = true; - return _cache; - } - - final session = sessionOverride ?? detectLinuxSession(); - final base = LinuxCapabilities.unsupported.copyWithSession(session); - - if (!session.isX11) { - _cache = base; - _initialized = true; - return _cache; - } - - bool timedOut = false; - Map? shellCaps; - Map? listenerCaps; - - try { - final results = - await Future.wait([ - channel.invokeShell('getCapabilities').catchError((_) => null), - channel.invokeListener('getCapabilities').catchError((_) => null), - ]).timeout( - timeout, - onTimeout: () { - timedOut = true; - return [null, null]; - }, - ); - shellCaps = results[0]; - listenerCaps = results[1]; - } catch (e) { - AppLogger.warn('LinuxCapabilities.detect failed: $e'); - } - - final result = LinuxCapabilities( - session: session, - isX11: _readBool(shellCaps, 'isX11', fallback: true), - hasXTest: _readBool(listenerCaps, 'hasXTest'), - hasAppIndicator: _readBool(shellCaps, 'hasAppIndicator'), - hasEwmh: _readBool(shellCaps, 'hasEwmh'), - detectedDesktopEnv: _readString(shellCaps, 'desktopEnv'), - detectedWmName: _readString(shellCaps, 'wmName'), - detectionTimedOut: timedOut, - ); - - _cache = result; - _initialized = true; - return result; - } - - static bool _readBool( - Map? map, - String key, { - bool fallback = false, - }) { - if (map == null) return fallback; - final value = map[key]; - return value is bool ? value : fallback; - } - - static String _readString(Map? map, String key) { - if (map == null) return ''; - final value = map[key]; - return value is String ? value : ''; - } -} - -extension _LinuxCapabilitiesSession on LinuxCapabilities { - LinuxCapabilities copyWithSession(LinuxSessionInfo session) { - return LinuxCapabilities( - session: session, - isX11: isX11, - hasXTest: hasXTest, - hasAppIndicator: hasAppIndicator, - hasEwmh: hasEwmh, - detectedDesktopEnv: detectedDesktopEnv, - detectedWmName: detectedWmName, - detectionTimedOut: detectionTimedOut, - ); - } -} diff --git a/app/lib/services/linux_guard.dart b/app/lib/services/linux_guard.dart deleted file mode 100644 index 939e3c8f..00000000 --- a/app/lib/services/linux_guard.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:io'; - -import 'linux_capabilities.dart'; - -class LinuxGuard { - const LinuxGuard._(); // coverage:ignore-line - - static LinuxCapabilities get _caps => LinuxCapabilitiesService.current; - - static bool get isLinux => Platform.isLinux; - static bool get isUsable => isLinux && _caps.isX11; - static bool get isWayland => isLinux && _caps.isWayland; - - static bool get canRegisterHotkey => isUsable && _caps.hasEwmh; - static bool get canPasteBack => isUsable && _caps.hasXTest; - static bool get canShowTray => isUsable && _caps.hasAppIndicator; - static bool get canAutostart => isUsable; - static bool get usesNativeWindowEffects => false; -} diff --git a/app/lib/shell/app_window.dart b/app/lib/shell/app_window.dart index 932695aa..629a873f 100644 --- a/app/lib/shell/app_window.dart +++ b/app/lib/shell/app_window.dart @@ -9,8 +9,6 @@ import 'package:flutter_acrylic/flutter_acrylic.dart'; import 'package:listener/listener.dart'; import 'package:window_manager/window_manager.dart'; -import 'linux_shell.dart'; - typedef _SystemParametersInfoWNative = Int32 Function( Uint32 uiAction, @@ -263,8 +261,6 @@ class AppWindow { Future _positionNearCursor() async { if (Platform.isWindows) { await _positionNearCursorWindows(); - } else if (Platform.isLinux) { - await _positionNearCursorLinux(); } else if (Platform.isMacOS) { await _positionNearCursorNative(); } else { @@ -272,26 +268,6 @@ class AppWindow { } } - Future _positionNearCursorLinux() async { - try { - final info = await LinuxShell.getCursorMonitor(); - if (info == null) { - await _positionNearCursorNative(); - return; - } - final workArea = ( - info.x, - info.y, - info.x + info.width, - info.y + info.height, - ); - await _applyPosition(info.cursorX, info.cursorY, workArea); - } catch (e) { - AppLogger.warn('_positionNearCursorLinux: fallback to native: $e'); - await _positionNearCursorNative(); - } - } - Future _positionNearCursorWindows() async { try { final cursor = _getCursorPosWin32(); @@ -626,43 +602,31 @@ class AppWindow { Future show() async { AppLogger.info('AppWindow.show: starting'); - if (Platform.isLinux) { + final restored = await _tryRestoreSavedPosition(); + AppLogger.info('AppWindow.show: restored=$restored'); + if (!restored) { + await _positionNearCursor(); + } + if (Platform.isWindows) { await windowManager.setSkipTaskbar(false); - await windowManager.show(); - final restored = await _tryRestoreSavedPosition(); - if (!restored) { - await _positionNearCursor(); - } - await LinuxShell.focusWindow(); - } else { - 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 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', ); - } else { - AppLogger.info('AppWindow.show: window shown and focused'); - } - if (Platform.isWindows) { - await applyEffect(); } + 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); @@ -696,20 +660,10 @@ class AppWindow { if (!_visible) return; _visible = false; await _captureCurrentPosition(); - Future? unmappedFuture; - if (Platform.isLinux) { - unmappedFuture = LinuxShell.awaitEvent( - 'unmapped', - timeout: const Duration(milliseconds: 300), - ); - } await windowManager.hide(); if (!Platform.isMacOS) { await windowManager.setSkipTaskbar(true); } - if (unmappedFuture != null) { - await unmappedFuture; - } onVisibilityChanged?.call(false); } @@ -731,21 +685,11 @@ class AppWindow { await _captureCurrentPosition(); _settingsMode = true; await windowManager.setResizable(true); - Future? configureFuture; - if (Platform.isLinux) { - configureFuture = LinuxShell.awaitEvent( - 'configureNotify', - timeout: const Duration(milliseconds: 250), - ); - } await windowManager.setMinimumSize( const Size(_settingsWidth, _settingsHeight), ); await windowManager.setMaximumSize(const Size(1200, 900)); await windowManager.setSize(const Size(_settingsWidth, _settingsHeight)); - if (configureFuture != null) { - await configureFuture; - } await windowManager.center(); if (!await windowManager.isVisible()) { await windowManager.show(); @@ -756,20 +700,9 @@ class AppWindow { Future exitSettingsMode() async { _settingsMode = false; - Future? configureFuture; - if (Platform.isLinux) { - await windowManager.setResizable(true); - configureFuture = LinuxShell.awaitEvent( - 'configureNotify', - timeout: const Duration(milliseconds: 250), - ); - } await windowManager.setMinimumSize(Size(_popupWidth, 400)); await windowManager.setMaximumSize(Size(_popupWidth, 900)); await windowManager.setSize(Size(_popupWidth, _popupHeight)); - if (configureFuture != null) { - await configureFuture; - } await windowManager.setResizable(false); final restored = await _tryRestoreSavedPosition(); if (!restored) { diff --git a/app/lib/shell/desktop_notifier.dart b/app/lib/shell/desktop_notifier.dart index 9d328b3b..ba83fbc8 100644 --- a/app/lib/shell/desktop_notifier.dart +++ b/app/lib/shell/desktop_notifier.dart @@ -1,17 +1,11 @@ -import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart'; - import 'windows_balloon.dart'; /// Cross-platform desktop notification helper for tray balloons. /// /// Routes to the most idiomatic native channel per OS: /// - Windows → `WindowsBalloon` (Shell_NotifyIconW via FFI). -/// - Linux → `notify-send` (libnotify CLI shipped on every desktop; -/// talks D-Bus to `org.freedesktop.Notifications`, which all -/// modern DEs implement: GNOME Shell, KDE Plasma, Xfce…). /// - macOS → no-op (Mac uses dock badges + window UI; balloons would /// collide with the system Notification Center conventions). /// @@ -19,12 +13,6 @@ import 'windows_balloon.dart'; class DesktopNotifier { DesktopNotifier._(); - /// Injectable process runner. Override in tests to avoid spawning real - /// system processes. - @visibleForTesting - static Future Function(String, List)? - processRunnerOverride; - /// Shows a transient notification with [title] and [body]. /// Returns true when the platform layer accepted the request. static Future show({ @@ -34,39 +22,6 @@ class DesktopNotifier { if (Platform.isWindows) { return WindowsBalloon.show(title: title, body: body); } - if (Platform.isLinux) { - return _showLinux(title: title, body: body); - } return false; } - - /// Spawns `notify-send` to push a notification through D-Bus - /// (`org.freedesktop.Notifications`). Silent on systems without it. - /// - /// Flags: - /// --app-name=CopyPaste → grouping / branding in the shell. - /// --icon=copypaste → DE looks up the icon by name in the theme; - /// falls back gracefully if not installed. - /// --expire-time=7000 → matches Windows balloon dismiss window. - static Future _showLinux({ - required String title, - required String body, - }) async { - final runner = processRunnerOverride ?? Process.run; - try { - final result = await runner('notify-send', [ - '--app-name=CopyPaste', - '--icon=copypaste', - '--expire-time=7000', - title, - body, - ]); - return result.exitCode == 0; - } on ProcessException { - // notify-send not installed (rare; ships with libnotify-bin). - return false; - } catch (_) { - return false; - } - } } diff --git a/app/lib/shell/focus_manager.dart b/app/lib/shell/focus_manager.dart index 3d05e6ac..e16fcea8 100644 --- a/app/lib/shell/focus_manager.dart +++ b/app/lib/shell/focus_manager.dart @@ -141,7 +141,7 @@ class WindowFocusManager { Future capturePreviousWindow() async { if (Platform.isWindows) { return _capturePreviousWindows(); - } else if (Platform.isMacOS || Platform.isLinux) { + } else if (Platform.isMacOS) { _previousBundleId = await ClipboardWriter.captureFrontmostApp(); AppLogger.info( 'Focus session capture: platform=${Platform.operatingSystem}, ' @@ -162,7 +162,7 @@ class WindowFocusManager { AppLogger.warn('Paste cancelled: no previous Windows destination'); return const PasteResponse(success: false, errorCode: 'noPreviousWindow'); } - if ((Platform.isMacOS || Platform.isLinux) && _previousBundleId == null) { + if (Platform.isMacOS && _previousBundleId == null) { AppLogger.warn('Paste cancelled: no previous application destination'); return const PasteResponse(success: false, errorCode: 'noPreviousWindow'); } @@ -171,7 +171,7 @@ class WindowFocusManager { try { await Future.delayed(Duration(milliseconds: delayBeforeFocusMs)); - if (Platform.isMacOS || Platform.isLinux) { + if (Platform.isMacOS) { final response = await ClipboardWriter.activateAndPaste( bundleId: _previousBundleId!, delayMs: delayBeforePasteMs, diff --git a/app/lib/shell/hotkey_binding.dart b/app/lib/shell/hotkey_binding.dart new file mode 100644 index 00000000..0ced92f0 --- /dev/null +++ b/app/lib/shell/hotkey_binding.dart @@ -0,0 +1,71 @@ +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 index a6a6aac4..cc99f48c 100644 --- a/app/lib/shell/hotkey_handler.dart +++ b/app/lib/shell/hotkey_handler.dart @@ -1,13 +1,11 @@ // coverage:ignore-file -import 'dart:async'; import 'dart:io'; import 'package:core/core.dart'; import 'package:flutter/services.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; -import 'linux_hotkey_registration.dart'; -import 'linux_shell.dart'; +import 'hotkey_binding.dart'; import 'windows_hotkey_channel.dart'; class HotkeyHandler { @@ -24,7 +22,6 @@ class HotkeyHandler { HotKey? _hotkey; HotKey? _plainPasteHotkey; WindowsHotkeyChannel? _windowsHotkeys; - StreamSubscription? _linuxEventsSubscription; bool? _plainPasteRegistrationSucceeded; bool? get plainPasteRegistrationSucceeded => _plainPasteRegistrationSucceeded; @@ -85,39 +82,10 @@ class HotkeyHandler { : null; if (_hotkey != null || _plainPasteHotkey != null || - _windowsHotkeys != null || - _linuxEventsSubscription != null) { + _windowsHotkeys != null) { await unregister(); } - if (Platform.isLinux) { - _linuxEventsSubscription ??= LinuxShell.events.listen((event) { - if (event == 'hotkey') _onHotkey?.call(); - if (event == 'plainPasteHotkey') _onPlainPasteHotkey?.call(); - }); - final result = await registerLinuxHotkeyWithFallback( - api: const LinuxShellHotkeyBindingApi(), - requestedBinding: _requestedBinding, - ); - if (config.plainPasteHotkeyEnabled) { - final response = await LinuxShell.registerHotkey( - id: 'plainPaste', - virtualKey: _plainPasteBinding.virtualKey, - useCtrl: _plainPasteBinding.useCtrl, - useWin: _plainPasteBinding.useWin, - useAlt: _plainPasteBinding.useAlt, - useShift: _plainPasteBinding.useShift, - ); - if (!response.success) { - AppLogger.error( - 'Plain paste hotkey registration failed: ${response.errorCode}', - ); - } - _plainPasteRegistrationSucceeded = response.success; - } - return result; - } - if (Platform.isWindows) { return _registerWindowsHotkeys(); } @@ -312,25 +280,6 @@ class HotkeyHandler { } return; } - if (Platform.isLinux) { - try { - await _linuxEventsSubscription?.cancel(); - } catch (e) { - AppLogger.error('Linux hotkey event cancellation failed: $e'); - } finally { - _linuxEventsSubscription = null; - } - try { - await LinuxShell.unregisterHotkey(); - } catch (e) { - AppLogger.error('Linux hotkey unregistration failed: $e'); - } - _hotkey = null; - _plainPasteHotkey = null; - _plainPasteRegistrationSucceeded = null; - return; - } - final registered = []; if (_hotkey != null) registered.add(_hotkey!); if (_plainPasteHotkey != null) registered.add(_plainPasteHotkey!); diff --git a/app/lib/shell/linux_hotkey_registration.dart b/app/lib/shell/linux_hotkey_registration.dart deleted file mode 100644 index af873a71..00000000 --- a/app/lib/shell/linux_hotkey_registration.dart +++ /dev/null @@ -1,215 +0,0 @@ -import 'dart:io' show Platform; - -import 'package:flutter/foundation.dart'; - -import 'linux_shell.dart'; - -enum HotkeyRegistrationStatus { registered, fallbackRegistered, failed } - -enum HotkeyFailureReason { - unsupportedKey, - noModifier, - grabFailed, - noX11, - channelError, - unknown, -} - -HotkeyFailureReason _reasonFromCode(String? code) { - switch (code) { - case 'unsupportedKey': - return HotkeyFailureReason.unsupportedKey; - case 'noModifier': - return HotkeyFailureReason.noModifier; - case 'grabFailed': - return HotkeyFailureReason.grabFailed; - case 'noX11': - return HotkeyFailureReason.noX11; - case 'channelError': - return HotkeyFailureReason.channelError; - default: - return HotkeyFailureReason.unknown; - } -} - -final Set _supportedLinuxVirtualKeys = { - for (var k = 0x41; k <= 0x5A; k++) k, - for (var k = 0x30; k <= 0x39; k++) k, - for (var k = 0x70; k <= 0x87; k++) k, - 0x08, - 0x09, - 0x0D, - 0x1B, - 0x20, - 0x21, - 0x22, - 0x23, - 0x24, - 0x25, - 0x26, - 0x27, - 0x28, - 0x2D, - 0x2E, - 0xBA, - 0xBB, - 0xBC, - 0xBD, - 0xBE, - 0xBF, - 0xC0, - 0xDB, - 0xDC, - 0xDD, - 0xDE, -}; - -bool isLinuxSupportedVirtualKey(int virtualKey) => - _supportedLinuxVirtualKeys.contains(virtualKey); - -@immutable -class HotkeyBinding { - const HotkeyBinding({ - required this.virtualKey, - required this.keyName, - required this.useCtrl, - required this.useWin, - required this.useAlt, - required this.useShift, - }); - - final int virtualKey; - final String keyName; - final bool useCtrl; - final bool useWin; - final bool useAlt; - final bool useShift; - - String label({bool isMac = false}) { - final parts = []; - 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(Platform.isLinux ? 'Super' : '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); -} - -const HotkeyBinding kLinuxTemporaryFallbackHotkey = HotkeyBinding( - virtualKey: 0x56, - keyName: 'V', - useCtrl: false, - useWin: true, - useAlt: false, - useShift: true, -); - -@immutable -class HotkeyRegistrationResult { - const HotkeyRegistrationResult({ - required this.status, - required this.requestedBinding, - this.effectiveBinding, - this.failureReason, - }); - - final HotkeyRegistrationStatus status; - final HotkeyBinding requestedBinding; - final HotkeyBinding? effectiveBinding; - final HotkeyFailureReason? failureReason; - - bool get isRegistered => - status == HotkeyRegistrationStatus.registered || - status == HotkeyRegistrationStatus.fallbackRegistered; -} - -abstract class LinuxHotkeyBindingApi { - Future registerHotkey(HotkeyBinding binding); -} - -class LinuxShellHotkeyBindingApi implements LinuxHotkeyBindingApi { - const LinuxShellHotkeyBindingApi(); - - @override - Future registerHotkey(HotkeyBinding binding) { - return LinuxShell.registerHotkey( - virtualKey: binding.virtualKey, - useCtrl: binding.useCtrl, - useWin: binding.useWin, - useAlt: binding.useAlt, - useShift: binding.useShift, - ); - } -} - -Future registerLinuxHotkeyWithFallback({ - required LinuxHotkeyBindingApi api, - required HotkeyBinding requestedBinding, - HotkeyBinding fallbackBinding = kLinuxTemporaryFallbackHotkey, -}) async { - if (!isLinuxSupportedVirtualKey(requestedBinding.virtualKey)) { - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.failed, - requestedBinding: requestedBinding, - failureReason: HotkeyFailureReason.unsupportedKey, - ); - } - - final primary = await api.registerHotkey(requestedBinding); - if (primary.success) { - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.registered, - requestedBinding: requestedBinding, - effectiveBinding: requestedBinding, - ); - } - - if (requestedBinding == fallbackBinding) { - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.failed, - requestedBinding: requestedBinding, - failureReason: _reasonFromCode(primary.errorCode), - ); - } - - final fallback = await api.registerHotkey(fallbackBinding); - if (fallback.success) { - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.fallbackRegistered, - requestedBinding: requestedBinding, - effectiveBinding: fallbackBinding, - failureReason: _reasonFromCode(primary.errorCode), - ); - } - - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.failed, - requestedBinding: requestedBinding, - failureReason: _reasonFromCode(fallback.errorCode ?? primary.errorCode), - ); -} diff --git a/app/lib/shell/linux_session.dart b/app/lib/shell/linux_session.dart deleted file mode 100644 index e95aece5..00000000 --- a/app/lib/shell/linux_session.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:flutter/foundation.dart'; - -@immutable -class LinuxSessionInfo { - const LinuxSessionInfo({ - required this.sessionType, - required this.hasDisplay, - required this.hasWaylandDisplay, - required this.hasWaylandSocket, - required this.desktopEnv, - required this.wmName, - }); - - final String sessionType; - final bool hasDisplay; - final bool hasWaylandDisplay; - final bool hasWaylandSocket; - final String desktopEnv; - final String wmName; - - bool get isWayland { - if (sessionType == 'wayland') return true; - if (sessionType == 'x11' || sessionType == 'mir' || sessionType == 'tty') { - return false; - } - if (hasWaylandDisplay) return true; - if (hasWaylandSocket && !hasDisplay) return true; - if (hasDisplay) return false; - return hasWaylandSocket; - } - - bool get isX11 { - if (sessionType == 'x11') return true; - if (sessionType == 'wayland' || - sessionType == 'mir' || - sessionType == 'tty') { - return false; - } - if (hasDisplay && !hasWaylandDisplay && !hasWaylandSocket) return true; - return false; - } - - bool get isXWayland => - hasDisplay && - (hasWaylandDisplay || hasWaylandSocket) && - sessionType == 'wayland'; - - bool get isUsable => isX11 || isWayland; - - static const LinuxSessionInfo unsupported = LinuxSessionInfo( - sessionType: '', - hasDisplay: false, - hasWaylandDisplay: false, - hasWaylandSocket: false, - desktopEnv: '', - wmName: '', - ); - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is LinuxSessionInfo && - other.sessionType == sessionType && - other.hasDisplay == hasDisplay && - other.hasWaylandDisplay == hasWaylandDisplay && - other.hasWaylandSocket == hasWaylandSocket && - other.desktopEnv == desktopEnv && - other.wmName == wmName; - } - - @override - int get hashCode => Object.hash( - sessionType, - hasDisplay, - hasWaylandDisplay, - hasWaylandSocket, - desktopEnv, - wmName, - ); - - @override - String toString() => - 'LinuxSessionInfo(sessionType=$sessionType, hasDisplay=$hasDisplay, ' - 'hasWaylandDisplay=$hasWaylandDisplay, hasWaylandSocket=$hasWaylandSocket, ' - 'desktopEnv=$desktopEnv, wmName=$wmName)'; -} - -LinuxSessionInfo detectLinuxSession() { - if (!Platform.isLinux) return LinuxSessionInfo.unsupported; - - final env = Platform.environment; - final sessionType = (env['XDG_SESSION_TYPE'] ?? '').trim().toLowerCase(); - final display = (env['DISPLAY'] ?? '').trim(); - final waylandDisplay = (env['WAYLAND_DISPLAY'] ?? '').trim(); - final desktopEnv = - (env['XDG_CURRENT_DESKTOP'] ?? env['DESKTOP_SESSION'] ?? '').trim(); - final wmName = (env['XDG_SESSION_DESKTOP'] ?? '').trim(); - - return LinuxSessionInfo( - sessionType: sessionType, - hasDisplay: display.isNotEmpty, - hasWaylandDisplay: waylandDisplay.isNotEmpty, - hasWaylandSocket: _hasWaylandSocket(env['XDG_RUNTIME_DIR']), - desktopEnv: desktopEnv, - wmName: wmName, - ); -} - -bool isWaylandSession() => detectLinuxSession().isWayland; - -bool _hasWaylandSocket(String? runtimeDir) { - if (runtimeDir == null || runtimeDir.isEmpty) return false; - try { - return Directory(runtimeDir) - .listSync(followLinks: false) - .any((e) => e.uri.pathSegments.last.startsWith('wayland')); - } catch (e) { - AppLogger.warn('linux_session: hasWaylandSocket failed: $e'); - return false; - } -} - -Future linuxPrefersDarkMode() async { - if (!Platform.isLinux) return false; - - try { - final result = await Process.run('gsettings', [ - 'get', - 'org.gnome.desktop.interface', - 'color-scheme', - ]); - if (result.exitCode == 0) { - return (result.stdout as String).contains('dark'); - } - } catch (e) { - AppLogger.warn('linux_session: gsettings color-scheme failed: $e'); - } - - final gtkTheme = (Platform.environment['GTK_THEME'] ?? '').toLowerCase(); - return gtkTheme.contains('dark'); -} diff --git a/app/lib/shell/linux_shell.dart b/app/lib/shell/linux_shell.dart deleted file mode 100644 index 854990f0..00000000 --- a/app/lib/shell/linux_shell.dart +++ /dev/null @@ -1,259 +0,0 @@ -// coverage:ignore-file -import 'dart:async'; - -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; - -class LinuxShell { - LinuxShell._(); - - static const MethodChannel _methodChannel = MethodChannel( - 'copypaste/linux_shell', - ); - static const EventChannel _eventChannel = EventChannel( - 'copypaste/linux_shell/events', - ); - - static StreamController? _eventsController; - static StreamSubscription? _eventChannelSubscription; - - static Stream get events { - if (_eventsController == null) { - _eventsController = StreamController.broadcast(); - _eventChannelSubscription = _eventChannel.receiveBroadcastStream().listen( - (dynamic event) { - if (event is! Map) return; - final map = Map.from(event); - final type = map['type'] as String? ?? ''; - if (type.isNotEmpty) _eventsController?.add(type); - }, - onError: (Object error) => _eventsController?.addError(error), - ); - } - return _eventsController!.stream; - } - - static Future dispose() async { - await _eventChannelSubscription?.cancel(); - _eventChannelSubscription = null; - await _eventsController?.close(); - _eventsController = null; - } - - static Future awaitEvent( - String type, { - Duration timeout = const Duration(milliseconds: 300), - }) async { - final completer = Completer(); - final sub = events.listen((event) { - if (event == type && !completer.isCompleted) completer.complete(true); - }); - final timer = Timer(timeout, () { - if (!completer.isCompleted) completer.complete(false); - }); - try { - return await completer.future; - } finally { - timer.cancel(); - await sub.cancel(); - } - } - - static Future initTray({ - required String iconPath, - required String showHideLabel, - required String exitLabel, - required String tooltip, - }) async { - return _invokeTrayMethod('initTray', { - 'iconPath': iconPath, - 'showHideLabel': showHideLabel, - 'exitLabel': exitLabel, - 'tooltip': tooltip, - }); - } - - static Future updateTray({ - required String iconPath, - required String showHideLabel, - required String exitLabel, - required String tooltip, - }) async { - return _invokeTrayMethod('updateTray', { - 'iconPath': iconPath, - 'showHideLabel': showHideLabel, - 'exitLabel': exitLabel, - 'tooltip': tooltip, - }); - } - - static Future _invokeTrayMethod( - String method, - Map args, - ) async { - try { - final result = await _methodChannel.invokeMethod(method, args); - if (result is Map) { - final map = Map.from(result); - final code = map['errorCode']; - return TrayResponse( - success: map['success'] == true, - errorCode: code is String ? code : null, - ); - } - if (result is bool) { - return TrayResponse(success: result); - } - return const TrayResponse(success: false, errorCode: 'unknown'); - } catch (e) { - AppLogger.error('LinuxShell.$method failed: $e'); - return const TrayResponse(success: false, errorCode: 'channelError'); - } - } - - static Future destroyTray() async { - try { - await _methodChannel.invokeMethod('destroyTray'); - } catch (e) { - AppLogger.error('LinuxShell.destroyTray failed: $e'); - } - } - - static Future registerHotkey({ - String id = 'openClose', - required int virtualKey, - required bool useCtrl, - required bool useWin, - required bool useAlt, - required bool useShift, - }) async { - try { - final result = await _methodChannel - .invokeMethod('registerHotkey', { - 'id': id, - 'virtualKey': virtualKey, - 'useCtrl': useCtrl, - 'useWin': useWin, - 'useAlt': useAlt, - 'useShift': useShift, - }); - if (result is Map) { - final map = Map.from(result); - final success = map['success'] == true; - final code = map['errorCode']; - return HotkeyRegisterResponse( - success: success, - errorCode: code is String ? code : null, - ); - } - if (result is bool) { - return HotkeyRegisterResponse(success: result); - } - return const HotkeyRegisterResponse(success: false, errorCode: 'unknown'); - } catch (e) { - AppLogger.error('LinuxShell.registerHotkey failed: $e'); - return const HotkeyRegisterResponse( - success: false, - errorCode: 'channelError', - ); - } - } - - static Future unregisterHotkey() async { - try { - await _methodChannel.invokeMethod('unregisterHotkey'); - } catch (e) { - AppLogger.error('LinuxShell.unregisterHotkey failed: $e'); - } - } - - static Future focusWindow() async { - try { - await _methodChannel.invokeMethod('focusWindow'); - } catch (e) { - AppLogger.error('LinuxShell.focusWindow failed: $e'); - } - } - - static Future getCursorMonitor() async { - try { - final result = await _methodChannel.invokeMethod( - 'getCursorMonitor', - ); - if (result is! Map) return null; - return CursorMonitorInfo( - cursorX: (result['cursorX'] as num?)?.toDouble() ?? 0, - cursorY: (result['cursorY'] as num?)?.toDouble() ?? 0, - x: (result['x'] as num?)?.toDouble() ?? 0, - y: (result['y'] as num?)?.toDouble() ?? 0, - width: (result['width'] as num?)?.toDouble() ?? 0, - height: (result['height'] as num?)?.toDouble() ?? 0, - scaleFactor: (result['scaleFactor'] as num?)?.toDouble() ?? 1.0, - ); - } catch (e) { - AppLogger.error('LinuxShell.getCursorMonitor failed: $e'); - return null; - } - } - - static Future getInputFocus() async { - try { - final result = await _methodChannel.invokeMethod('getInputFocus'); - if (result is! Map) return null; - return InputFocusInfo( - ownsFocus: result['ownsFocus'] as bool? ?? false, - focusWindow: (result['focusWindow'] as num?)?.toInt() ?? 0, - ownWindow: (result['ownWindow'] as num?)?.toInt() ?? 0, - ); - } catch (e) { - AppLogger.error('LinuxShell.getInputFocus failed: $e'); - return null; - } - } -} - -class CursorMonitorInfo { - const CursorMonitorInfo({ - required this.cursorX, - required this.cursorY, - required this.x, - required this.y, - required this.width, - required this.height, - required this.scaleFactor, - }); - - final double cursorX; - final double cursorY; - final double x; - final double y; - final double width; - final double height; - final double scaleFactor; -} - -class InputFocusInfo { - const InputFocusInfo({ - required this.ownsFocus, - required this.focusWindow, - required this.ownWindow, - }); - - final bool ownsFocus; - final int focusWindow; - final int ownWindow; -} - -class HotkeyRegisterResponse { - const HotkeyRegisterResponse({required this.success, this.errorCode}); - - final bool success; - final String? errorCode; -} - -class TrayResponse { - const TrayResponse({required this.success, this.errorCode}); - - final bool success; - final String? errorCode; -} diff --git a/app/lib/shell/single_instance.dart b/app/lib/shell/single_instance.dart index 2094e90c..306fbb35 100644 --- a/app/lib/shell/single_instance.dart +++ b/app/lib/shell/single_instance.dart @@ -214,14 +214,7 @@ class SingleInstance { } static bool acquire() { - bool acquired; - if (Platform.isWindows) { - acquired = _acquireWindows(); - } else if (Platform.isMacOS || Platform.isLinux) { - acquired = _acquireUnix(); - } else { - return true; - } + final acquired = Platform.isWindows ? _acquireWindows() : _acquireUnix(); if (!acquired) signalWakeup(); return acquired; } diff --git a/app/lib/shell/startup_helper.dart b/app/lib/shell/startup_helper.dart index c00cfb72..78d18da8 100644 --- a/app/lib/shell/startup_helper.dart +++ b/app/lib/shell/startup_helper.dart @@ -6,7 +6,6 @@ import 'package:core/core.dart'; import 'package:ffi/ffi.dart'; import 'package:flutter/foundation.dart'; -import 'linux_session.dart'; import 'msix_startup_task.dart'; import 'win_package_context.dart'; @@ -115,19 +114,6 @@ class StartupHelper { } else { _removeLaunchAgent(); } - } else if (Platform.isLinux) { - // Never install autostart on Wayland — the app would launch and immediately - // show the unsupported screen, which is a poor experience. - if (isWaylandSession()) { - _removeDesktopAutostart(); - AppLogger.info('Wayland session: autostart entry removed/skipped.'); - return; - } - if (runOnStartup) { - _installDesktopAutostart(); - } else { - _removeDesktopAutostart(); - } } } @@ -322,45 +308,4 @@ class StartupHelper { AppLogger.error('Failed to remove LaunchAgent: $e'); } } - - // Honors XDG_CONFIG_HOME (must be an absolute path per the XDG spec); - // falls back to ~/.config when unset or relative. - static String get _xdgConfigDir { - final xdg = Platform.environment['XDG_CONFIG_HOME']; - if (xdg != null && xdg.startsWith('/')) return xdg; - final home = Platform.environment['HOME'] ?? '/tmp'; - return '$home/.config'; - } - - static String get _desktopAutostartPath => - '$_xdgConfigDir/autostart/$_appName.desktop'; - - static void _installDesktopAutostart() { - try { - final exePath = Platform.resolvedExecutable; - final desktop = - '[Desktop Entry]\n' - 'Type=Application\n' - 'Name=$_appName\n' - 'Exec=$exePath\n' - 'X-GNOME-Autostart-enabled=true\n' - 'StartupNotify=false\n' - 'Terminal=false\n' - 'OnlyShowIn=GNOME;KDE;XFCE;Cinnamon;MATE;LXDE;LXQt;Pantheon;Unity;Budgie;Deepin;\n'; - final autostartDir = Directory('$_xdgConfigDir/autostart'); - if (!autostartDir.existsSync()) autostartDir.createSync(recursive: true); - File(_desktopAutostartPath).writeAsStringSync(desktop); - } catch (e) { - AppLogger.error('Failed to install autostart desktop entry: $e'); - } - } - - static void _removeDesktopAutostart() { - try { - final file = File(_desktopAutostartPath); - if (file.existsSync()) file.deleteSync(); - } catch (e) { - AppLogger.error('Failed to remove autostart desktop entry: $e'); - } - } } diff --git a/app/lib/shell/tray_icon.dart b/app/lib/shell/tray_icon.dart index c96491bf..e1011e88 100644 --- a/app/lib/shell/tray_icon.dart +++ b/app/lib/shell/tray_icon.dart @@ -1,46 +1,20 @@ // coverage:ignore-file -import 'dart:async'; import 'dart:io'; import 'package:tray_manager/tray_manager.dart'; -import '../services/linux_guard.dart'; -import 'linux_shell.dart'; - class TrayIcon with TrayListener { TrayIcon({required this.onToggle, required this.onExit}); final void Function() onToggle; final Future Function() onExit; - StreamSubscription? _linuxEventsSubscription; - static String get _iconPath { if (Platform.isMacOS) return 'assets/icons/icon_mac_tray.png'; - if (Platform.isLinux) return 'assets/icons/icon_tray_64.png'; return 'assets/icons/icon_tray.ico'; } Future init() async { - if (Platform.isLinux) { - if (!LinuxGuard.canShowTray) return; - _linuxEventsSubscription ??= LinuxShell.events.listen((event) { - switch (event) { - case 'toggle': - onToggle(); - case 'exit': - onExit(); - } - }); - await LinuxShell.initTray( - iconPath: _iconPath, - showHideLabel: 'Show/Hide', - exitLabel: 'Exit', - tooltip: 'CopyPaste', - ); - return; - } - trayManager.addListener(this); await trayManager.setIcon(_iconPath); await trayManager.setContextMenu( @@ -59,17 +33,6 @@ class TrayIcon with TrayListener { required String exitLabel, required String tooltip, }) async { - if (Platform.isLinux) { - if (!LinuxGuard.canShowTray) return; - await LinuxShell.updateTray( - iconPath: _iconPath, - showHideLabel: showHideLabel, - exitLabel: exitLabel, - tooltip: tooltip, - ); - return; - } - await trayManager.setToolTip(tooltip); await trayManager.setContextMenu( Menu( @@ -101,13 +64,6 @@ class TrayIcon with TrayListener { } Future dispose() async { - if (Platform.isLinux) { - await _linuxEventsSubscription?.cancel(); - _linuxEventsSubscription = null; - await LinuxShell.destroyTray(); - return; - } - trayManager.removeListener(this); await trayManager.destroy(); } diff --git a/app/linux/.gitignore b/app/linux/.gitignore deleted file mode 100644 index d3896c98..00000000 --- a/app/linux/.gitignore +++ /dev/null @@ -1 +0,0 @@ -flutter/ephemeral diff --git a/app/linux/CMakeLists.txt b/app/linux/CMakeLists.txt deleted file mode 100644 index c5e522b4..00000000 --- a/app/linux/CMakeLists.txt +++ /dev/null @@ -1,121 +0,0 @@ -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES C CXX) - -set(BINARY_NAME "copypaste") -set(APPLICATION_ID "com.rgdevment.copypaste") - -cmake_policy(SET CMP0063 NEW) - -# Load bundled libraries from the lib/ directory relative to the binary. -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Define build configuration options. -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() - -# 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_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) - -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -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) - -foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) - install(FILES "${bundled_library}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endforeach(bundled_library) - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") -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. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() diff --git a/app/linux/flutter/CMakeLists.txt b/app/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd0164..00000000 --- a/app/linux/flutter/CMakeLists.txt +++ /dev/null @@ -1,88 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.10) - -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. - -# Serves the same purpose as list(TRANSFORM ... PREPEND ...), -# which isn't available in 3.10. -function(list_prepend LIST_NAME PREFIX) - set(NEW_LIST "") - foreach(element ${${LIST_NAME}}) - list(APPEND NEW_LIST "${PREFIX}${element}") - endforeach(element) - set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) -endfunction() - -# === Flutter Library === -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) -pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) - -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") - -# 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/lib/libapp.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "fl_basic_message_channel.h" - "fl_binary_codec.h" - "fl_binary_messenger.h" - "fl_dart_project.h" - "fl_engine.h" - "fl_json_message_codec.h" - "fl_json_method_codec.h" - "fl_message_codec.h" - "fl_method_call.h" - "fl_method_channel.h" - "fl_method_codec.h" - "fl_method_response.h" - "fl_plugin_registrar.h" - "fl_plugin_registry.h" - "fl_standard_message_codec.h" - "fl_standard_method_codec.h" - "fl_string_codec.h" - "fl_value.h" - "fl_view.h" - "flutter_linux.h" -) -list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") -target_link_libraries(flutter INTERFACE - PkgConfig::GTK - PkgConfig::GLIB - PkgConfig::GIO -) -add_dependencies(flutter 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. -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CMAKE_CURRENT_BINARY_DIR}/_phony_ - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} -) diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index b626f359..00000000 --- a/app/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,35 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include -#include -#include -#include -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) flutter_acrylic_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAcrylicPlugin"); - flutter_acrylic_plugin_register_with_registrar(flutter_acrylic_registrar); - g_autoptr(FlPluginRegistrar) hotkey_manager_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerLinuxPlugin"); - hotkey_manager_linux_plugin_register_with_registrar(hotkey_manager_linux_registrar); - g_autoptr(FlPluginRegistrar) listener_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ListenerPlugin"); - listener_plugin_register_with_registrar(listener_registrar); - g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); - screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); - g_autoptr(FlPluginRegistrar) tray_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); - tray_manager_plugin_register_with_registrar(tray_manager_registrar); - g_autoptr(FlPluginRegistrar) window_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); - window_manager_plugin_register_with_registrar(window_manager_registrar); -} diff --git a/app/linux/flutter/generated_plugin_registrant.h b/app/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47b..00000000 --- a/app/linux/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 fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 3ae67ed1..00000000 --- a/app/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,30 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - flutter_acrylic - hotkey_manager_linux - listener - screen_retriever_linux - 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}/linux 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}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/app/linux/packaging/appimage/make_config.yaml b/app/linux/packaging/appimage/make_config.yaml deleted file mode 100644 index f4dee49b..00000000 --- a/app/linux/packaging/appimage/make_config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -display_name: CopyPaste -icon: assets/icons/icon_app_256.png -generic_name: Clipboard Manager -startup_notify: true -categories: - - Utility -keywords: - - clipboard - - copy - - paste diff --git a/app/linux/packaging/deb/make_config.yaml b/app/linux/packaging/deb/make_config.yaml deleted file mode 100644 index 03a1d85b..00000000 --- a/app/linux/packaging/deb/make_config.yaml +++ /dev/null @@ -1,24 +0,0 @@ -display_name: CopyPaste -package_name: copypaste -maintainer: - name: rgdevment - email: rgdevment@apirest.cl -priority: optional -section: x11 -installed_size: 51200 -essential: false -dependencies: - - libayatana-appindicator3-1 - - libkeybinder-3.0-0 - - libgtk-3-0 | libgtk-3-0t64 - - libx11-6 - - libxtst6 -icon: assets/icons/icon_app_256.png -generic_name: Clipboard Manager -startup_notify: true -categories: - - Utility -keywords: - - clipboard - - copy - - paste diff --git a/app/linux/packaging/rpm/make_config.yaml b/app/linux/packaging/rpm/make_config.yaml deleted file mode 100644 index 30e7403b..00000000 --- a/app/linux/packaging/rpm/make_config.yaml +++ /dev/null @@ -1,24 +0,0 @@ -display_name: CopyPaste -package_name: copypaste -icon: assets/icons/icon_app_256.png -summary: A clipboard history manager -group: Utilities -vendor: rgdevment -packager: rgdevment -packagerEmail: rgdevment@apirest.cl -license: GPLv3 -url: https://github.com/rgdevment/CopyPaste -requires: - - libayatana-appindicator-gtk3 - - keybinder3 - - gtk3 - - libX11 - - libXtst -generic_name: Clipboard Manager -startup_notify: true -categories: - - Utility -keywords: - - clipboard - - copy - - paste diff --git a/app/linux/runner/CMakeLists.txt b/app/linux/runner/CMakeLists.txt deleted file mode 100644 index 758aa942..00000000 --- a/app/linux/runner/CMakeLists.txt +++ /dev/null @@ -1,34 +0,0 @@ -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES C CXX) - -add_executable(${BINARY_NAME} - "copypaste_linux_shell.c" - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -apply_standard_settings(${BINARY_NAME}) -target_compile_options(${BINARY_NAME} PRIVATE -Wno-deprecated-declarations) - -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) - -pkg_check_modules(X11 IMPORTED_TARGET x11 xtst) -if(X11_FOUND) - target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::X11) -else() - message(WARNING "X11/XTest not found — hotkey and tray features will be X11-unavailable") -endif() - -pkg_check_modules(APPINDICATOR IMPORTED_TARGET ayatana-appindicator3-0.1) -if(APPINDICATOR_FOUND) - target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::APPINDICATOR) - target_compile_definitions(${BINARY_NAME} PRIVATE HAVE_APPINDICATOR) -else() - message(WARNING "ayatana-appindicator3 not found — tray icon falls back to GtkStatusIcon (may not appear on GNOME)") -endif() - -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c deleted file mode 100644 index cb7664c1..00000000 --- a/app/linux/runner/copypaste_linux_shell.c +++ /dev/null @@ -1,904 +0,0 @@ -#include "copypaste_linux_shell.h" - -#include -#include -#include -#ifdef HAVE_APPINDICATOR -#include -#endif -#include -#include -#include -#include - -#ifdef GDK_WINDOWING_X11 -#include -#include -#include -#include -#include -#include -#endif - -struct _CopyPasteLinuxShell { - FlMethodChannel* method_channel; - FlEventChannel* event_channel; - gboolean events_listening; - -#ifdef HAVE_APPINDICATOR - AppIndicator* app_indicator; -#endif - GtkWidget* tray_menu; - GtkWidget* toggle_item; - GtkWidget* exit_item; - gchar* resolved_icon_path; - - GtkWindow* gtk_window; - - gboolean hotkey_registered; -#ifdef GDK_WINDOWING_X11 - Display* xdisplay; - Window root_window; - guint hotkey_keycode; - guint hotkey_modifiers; - gboolean plain_hotkey_registered; - guint plain_hotkey_keycode; - guint plain_hotkey_modifiers; - guint32 last_hotkey_time; -#endif -}; - -static const gchar* kShellChannelName = "copypaste/linux_shell"; -static const gchar* kShellEventChannelName = "copypaste/linux_shell/events"; - -static gboolean shell_is_x11(void) { -#ifdef GDK_WINDOWING_X11 - GdkDisplay* display = gdk_display_get_default(); - return display != NULL && GDK_IS_X11_DISPLAY(display); -#else - return FALSE; -#endif -} - -static FlValue* shell_event(const gchar* type) { - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", fl_value_new_string(type)); - return fl_value_ref(event); -} - -static void send_shell_event(CopyPasteLinuxShell* shell, const gchar* type) { - if (!shell->events_listening || shell->event_channel == NULL) { - return; - } - - g_autoptr(FlValue) event = shell_event(type); - g_autoptr(GError) error = NULL; - if (!fl_event_channel_send(shell->event_channel, event, NULL, &error) && - error != NULL) { - g_warning("Failed to send linux shell event: %s", error->message); - } -} - -static gboolean window_unmap_event_cb(GtkWidget* widget, GdkEvent* event, - gpointer user_data) { - (void)widget; - (void)event; - send_shell_event((CopyPasteLinuxShell*)user_data, "unmapped"); - return FALSE; -} - -static gboolean window_map_event_cb(GtkWidget* widget, GdkEvent* event, - gpointer user_data) { - (void)widget; - (void)event; - send_shell_event((CopyPasteLinuxShell*)user_data, "mapped"); - return FALSE; -} - -static gboolean window_configure_event_cb(GtkWidget* widget, GdkEvent* event, - gpointer user_data) { - (void)widget; - (void)event; - send_shell_event((CopyPasteLinuxShell*)user_data, "configureNotify"); - return FALSE; -} - -static gchar* resolve_asset_path(const gchar* asset_path) { - if (asset_path == NULL || *asset_path == '\0') { - return NULL; - } - - if (g_path_is_absolute(asset_path) && g_file_test(asset_path, G_FILE_TEST_EXISTS)) { - return g_strdup(asset_path); - } - - gchar exe_path[PATH_MAX + 1]; - ssize_t length = readlink("/proc/self/exe", exe_path, PATH_MAX); - if (length <= 0) { - return g_file_test(asset_path, G_FILE_TEST_EXISTS) ? g_strdup(asset_path) : NULL; - } - - exe_path[length] = '\0'; - g_autofree gchar* exe_dir = g_path_get_dirname(exe_path); - g_autofree gchar* flutter_asset_path = - g_build_filename(exe_dir, "data", "flutter_assets", asset_path, NULL); - if (g_file_test(flutter_asset_path, G_FILE_TEST_EXISTS)) { - return g_strdup(flutter_asset_path); - } - - g_autofree gchar* sibling_asset_path = g_build_filename(exe_dir, asset_path, NULL); - if (g_file_test(sibling_asset_path, G_FILE_TEST_EXISTS)) { - return g_strdup(sibling_asset_path); - } - - return NULL; -} - -static void destroy_tray_menu(CopyPasteLinuxShell* shell) { - if (shell->tray_menu != NULL) { - gtk_widget_destroy(shell->tray_menu); - shell->tray_menu = NULL; - shell->toggle_item = NULL; - shell->exit_item = NULL; - } -} - -static void tray_toggle_cb(GtkMenuItem* item, gpointer user_data) { - (void)item; - send_shell_event((CopyPasteLinuxShell*)user_data, "toggle"); -} - -static void tray_exit_cb(GtkMenuItem* item, gpointer user_data) { - (void)item; - send_shell_event((CopyPasteLinuxShell*)user_data, "exit"); -} - -static void rebuild_tray_menu(CopyPasteLinuxShell* shell, - const gchar* show_hide_label, - const gchar* exit_label) { - destroy_tray_menu(shell); - - shell->tray_menu = gtk_menu_new(); - shell->toggle_item = gtk_menu_item_new_with_label(show_hide_label); - shell->exit_item = gtk_menu_item_new_with_label(exit_label); - GtkWidget* separator = gtk_separator_menu_item_new(); - - g_signal_connect(shell->toggle_item, "activate", G_CALLBACK(tray_toggle_cb), - shell); - g_signal_connect(shell->exit_item, "activate", G_CALLBACK(tray_exit_cb), shell); - - gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), shell->toggle_item); - gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), separator); - gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), shell->exit_item); - gtk_widget_show_all(shell->tray_menu); -} - -static void parse_tray_args(FlValue* args, - const gchar** out_icon_path, - const gchar** out_tooltip, - const gchar** out_show_hide, - const gchar** out_exit_label) { - FlValue* icon_value = args != NULL ? fl_value_lookup_string(args, "iconPath") : NULL; - FlValue* tooltip_value = args != NULL ? fl_value_lookup_string(args, "tooltip") : NULL; - FlValue* toggle_value = args != NULL ? fl_value_lookup_string(args, "showHideLabel") : NULL; - FlValue* exit_value = args != NULL ? fl_value_lookup_string(args, "exitLabel") : NULL; - - *out_icon_path = - icon_value != NULL && fl_value_get_type(icon_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(icon_value) - : NULL; - *out_tooltip = - tooltip_value != NULL && fl_value_get_type(tooltip_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(tooltip_value) - : "CopyPaste"; - *out_show_hide = - toggle_value != NULL && fl_value_get_type(toggle_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(toggle_value) - : "Show/Hide"; - *out_exit_label = - exit_value != NULL && fl_value_get_type(exit_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(exit_value) - : "Exit"; -} - -static FlValue* make_tray_result(gboolean success, const char* error_code) { - FlValue* map = fl_value_new_map(); - fl_value_set_string_take(map, "success", fl_value_new_bool(success)); - if (error_code != NULL) { - fl_value_set_string_take(map, "errorCode", fl_value_new_string(error_code)); - } - return map; -} - -static FlValue* init_tray(CopyPasteLinuxShell* shell, FlValue* args) { -#ifdef HAVE_APPINDICATOR - const gchar* icon_path; - const gchar* tooltip; - const gchar* show_hide; - const gchar* exit_label; - parse_tray_args(args, &icon_path, &tooltip, &show_hide, &exit_label); - - g_free(shell->resolved_icon_path); - shell->resolved_icon_path = resolve_asset_path(icon_path); - - if (shell->app_indicator == NULL) { - shell->app_indicator = app_indicator_new( - "com.rgdevment.copypaste", "copypaste", - APP_INDICATOR_CATEGORY_APPLICATION_STATUS); - } - - if (shell->app_indicator == NULL) { - return make_tray_result(FALSE, "noAppIndicator"); - } - - if (shell->resolved_icon_path != NULL) { - g_autofree gchar* icon_dir = - g_path_get_dirname(shell->resolved_icon_path); - g_autofree gchar* icon_base = - g_path_get_basename(shell->resolved_icon_path); - gchar* dot = strrchr(icon_base, '.'); - if (dot != NULL) *dot = '\0'; - app_indicator_set_icon_theme_path(shell->app_indicator, icon_dir); - app_indicator_set_icon_full(shell->app_indicator, icon_base, tooltip); - } - - app_indicator_set_title(shell->app_indicator, tooltip); - rebuild_tray_menu(shell, show_hide, exit_label); - app_indicator_set_menu(shell->app_indicator, GTK_MENU(shell->tray_menu)); - app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_ACTIVE); - return make_tray_result(TRUE, NULL); -#else - (void)shell; - (void)args; - return make_tray_result(FALSE, "noAppIndicator"); -#endif -} - -static FlValue* destroy_tray(CopyPasteLinuxShell* shell) { - destroy_tray_menu(shell); - -#ifdef HAVE_APPINDICATOR - if (shell->app_indicator != NULL) { - app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_PASSIVE); - g_clear_object(&shell->app_indicator); - } -#endif - - g_clear_pointer(&shell->resolved_icon_path, g_free); - return make_tray_result(TRUE, NULL); -} - -static FlValue* make_hotkey_result(gboolean success, const char* error_code) { - FlValue* map = fl_value_new_map(); - fl_value_set_string_take(map, "success", fl_value_new_bool(success)); - if (error_code != NULL) { - fl_value_set_string_take(map, "errorCode", fl_value_new_string(error_code)); - } - return map; -} - -#ifdef GDK_WINDOWING_X11 -static guint modifier_combinations[] = {0, LockMask, Mod2Mask, LockMask | Mod2Mask}; -static int (*previous_x11_error_handler)(Display*, XErrorEvent*) = NULL; -static Display* trapped_x11_display = NULL; -static int trapped_x11_error_code = Success; - -static int hotkey_x11_error_handler(Display* display, XErrorEvent* event) { - if (display == trapped_x11_display) { - trapped_x11_error_code = event->error_code; - return 0; - } - - if (previous_x11_error_handler != NULL) { - return previous_x11_error_handler(display, event); - } - - return 0; -} - -static gboolean trap_x11_grab(Display* display, - Window root_window, - KeyCode keycode, - guint modifiers) { - previous_x11_error_handler = XSetErrorHandler(hotkey_x11_error_handler); - trapped_x11_display = display; - trapped_x11_error_code = Success; - - XGrabKey(display, (int)keycode, (int)modifiers, root_window, True, - GrabModeAsync, GrabModeAsync); - XSync(display, False); - - trapped_x11_display = NULL; - XSetErrorHandler(previous_x11_error_handler); - previous_x11_error_handler = NULL; - - return trapped_x11_error_code == Success; -} - -static void ungrab_hotkey_variants(Display* display, - Window root_window, - KeyCode keycode, - guint modifiers) { - for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { - XUngrabKey(display, (int)keycode, - (int)(modifiers | modifier_combinations[i]), root_window); - } - XSync(display, False); -} - -static KeySym virtual_key_to_keysym(gint64 virtual_key) { - if (virtual_key >= 0x41 && virtual_key <= 0x5A) { - return (KeySym)(XK_A + (virtual_key - 0x41)); - } - if (virtual_key >= 0x30 && virtual_key <= 0x39) { - return (KeySym)(XK_0 + (virtual_key - 0x30)); - } - if (virtual_key >= 0x70 && virtual_key <= 0x87) { - return (KeySym)(XK_F1 + (virtual_key - 0x70)); - } - switch (virtual_key) { - case 0x08: return XK_BackSpace; - case 0x09: return XK_Tab; - case 0x0D: return XK_Return; - case 0x1B: return XK_Escape; - case 0x20: return XK_space; - case 0x21: return XK_Page_Up; - case 0x22: return XK_Page_Down; - case 0x23: return XK_End; - case 0x24: return XK_Home; - case 0x25: return XK_Left; - case 0x26: return XK_Up; - case 0x27: return XK_Right; - case 0x28: return XK_Down; - case 0x2D: return XK_Insert; - case 0x2E: return XK_Delete; - case 0xBA: return XK_semicolon; - case 0xBB: return XK_equal; - case 0xBC: return XK_comma; - case 0xBD: return XK_minus; - case 0xBE: return XK_period; - case 0xBF: return XK_slash; - case 0xC0: return XK_grave; - case 0xDB: return XK_bracketleft; - case 0xDC: return XK_backslash; - case 0xDD: return XK_bracketright; - case 0xDE: return XK_apostrophe; - default: return NoSymbol; - } -} - -static guint compute_modifier_mask(FlValue* args) { - guint modifiers = 0; - - FlValue* ctrl = fl_value_lookup_string(args, "useCtrl"); - FlValue* meta = fl_value_lookup_string(args, "useWin"); - FlValue* alt = fl_value_lookup_string(args, "useAlt"); - FlValue* shift = fl_value_lookup_string(args, "useShift"); - - if (ctrl != NULL && fl_value_get_type(ctrl) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(ctrl)) { - modifiers |= ControlMask; - } - if (meta != NULL && fl_value_get_type(meta) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(meta)) { - modifiers |= Mod4Mask; - } - if (alt != NULL && fl_value_get_type(alt) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(alt)) { - modifiers |= Mod1Mask; - } - if (shift != NULL && fl_value_get_type(shift) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(shift)) { - modifiers |= ShiftMask; - } - - return modifiers; -} - -static void unregister_hotkey(CopyPasteLinuxShell* shell) { - if (!shell->hotkey_registered || shell->xdisplay == NULL || shell->hotkey_keycode == 0) { - shell->hotkey_registered = FALSE; - return; - } - - ungrab_hotkey_variants(shell->xdisplay, shell->root_window, - (KeyCode)shell->hotkey_keycode, - shell->hotkey_modifiers); - shell->hotkey_registered = FALSE; - shell->hotkey_keycode = 0; - shell->hotkey_modifiers = 0; -} - -static void unregister_plain_hotkey(CopyPasteLinuxShell* shell) { - if (!shell->plain_hotkey_registered || shell->xdisplay == NULL || - shell->plain_hotkey_keycode == 0) { - shell->plain_hotkey_registered = FALSE; - return; - } - - ungrab_hotkey_variants(shell->xdisplay, shell->root_window, - (KeyCode)shell->plain_hotkey_keycode, - shell->plain_hotkey_modifiers); - shell->plain_hotkey_registered = FALSE; - shell->plain_hotkey_keycode = 0; - shell->plain_hotkey_modifiers = 0; -} - -static FlValue* register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { - if (!shell_is_x11() || shell->xdisplay == NULL) { - return make_hotkey_result(FALSE, "noX11"); - } - - FlValue* id_value = args != NULL ? fl_value_lookup_string(args, "id") : NULL; - const gchar* id = id_value != NULL && - fl_value_get_type(id_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(id_value) - : "openClose"; - gboolean is_plain = g_strcmp0(id, "plainPaste") == 0; - if (is_plain) { - unregister_plain_hotkey(shell); - } else { - unregister_hotkey(shell); - } - - FlValue* key_value = args != NULL ? fl_value_lookup_string(args, "virtualKey") : NULL; - gint64 virtual_key = (key_value != NULL && fl_value_get_type(key_value) == FL_VALUE_TYPE_INT) - ? fl_value_get_int(key_value) : 0; - KeySym keysym = virtual_key_to_keysym(virtual_key); - if (keysym == NoSymbol) { - g_warning("registerHotkey: unsupported virtual key 0x%llx", (unsigned long long)virtual_key); - return make_hotkey_result(FALSE, "unsupportedKey"); - } - - guint modifiers = compute_modifier_mask(args); - if (modifiers == 0) { - g_warning("registerHotkey: no modifier keys specified"); - return make_hotkey_result(FALSE, "noModifier"); - } - - KeyCode keycode = XKeysymToKeycode(shell->xdisplay, keysym); - if (keycode == 0) { - g_warning("registerHotkey: no keycode for keysym %lu", (unsigned long)keysym); - return make_hotkey_result(FALSE, "unsupportedKey"); - } - - if ((is_plain && shell->hotkey_registered && - shell->hotkey_keycode == keycode && - shell->hotkey_modifiers == modifiers) || - (!is_plain && shell->plain_hotkey_registered && - shell->plain_hotkey_keycode == keycode && - shell->plain_hotkey_modifiers == modifiers)) { - return make_hotkey_result(FALSE, "grabFailed"); - } - - for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { - if (!trap_x11_grab(shell->xdisplay, shell->root_window, keycode, - modifiers | modifier_combinations[i])) { - g_warning("registerHotkey: XGrabKey failed (modifier variant 0x%x) — key may be in use", - modifiers | modifier_combinations[i]); - ungrab_hotkey_variants(shell->xdisplay, shell->root_window, keycode, - modifiers); - return make_hotkey_result(FALSE, "grabFailed"); - } - } - - XSync(shell->xdisplay, False); - XWindowAttributes attrs; - if (XGetWindowAttributes(shell->xdisplay, shell->root_window, &attrs) != 0) { - XSelectInput(shell->xdisplay, shell->root_window, - attrs.your_event_mask | KeyPressMask); - } else { - XSelectInput(shell->xdisplay, shell->root_window, KeyPressMask); - } - XSync(shell->xdisplay, False); - - if (is_plain) { - shell->plain_hotkey_registered = TRUE; - shell->plain_hotkey_keycode = keycode; - shell->plain_hotkey_modifiers = modifiers; - } else { - shell->hotkey_registered = TRUE; - shell->hotkey_keycode = keycode; - shell->hotkey_modifiers = modifiers; - } - return make_hotkey_result(TRUE, NULL); -} - -static GdkFilterReturn x11_event_filter(GdkXEvent* xevent, - GdkEvent* event, - gpointer user_data) { - (void)event; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - if (!shell->hotkey_registered && !shell->plain_hotkey_registered) { - return GDK_FILTER_CONTINUE; - } - - XEvent* x_event = (XEvent*)xevent; - if (x_event->type != KeyPress) { - return GDK_FILTER_CONTINUE; - } - - guint relevant_mask = ControlMask | ShiftMask | Mod1Mask | Mod4Mask; - guint state = (guint)x_event->xkey.state & relevant_mask; - if ((guint)x_event->xkey.keycode == shell->hotkey_keycode && - shell->hotkey_registered && - state == shell->hotkey_modifiers) { - shell->last_hotkey_time = x_event->xkey.time; - send_shell_event(shell, "hotkey"); - return GDK_FILTER_REMOVE; - } - - if ((guint)x_event->xkey.keycode == shell->plain_hotkey_keycode && - shell->plain_hotkey_registered && - state == shell->plain_hotkey_modifiers) { - shell->last_hotkey_time = x_event->xkey.time; - send_shell_event(shell, "plainPasteHotkey"); - return GDK_FILTER_REMOVE; - } - - return GDK_FILTER_CONTINUE; -} -#endif - -static FlMethodErrorResponse* shell_listen_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - shell->events_listening = TRUE; - return NULL; -} - -static FlMethodErrorResponse* shell_cancel_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - shell->events_listening = FALSE; - return NULL; -} - -static void respond_method_success(FlMethodCall* method_call, FlValue* result) { - g_autoptr(GError) error = NULL; - g_autoptr(FlValue) owned = result; - if (!fl_method_call_respond_success(method_call, owned, &error) && error != NULL) { - g_warning("Failed to respond to linux shell method call: %s", error->message); - } -} - -static gboolean has_app_indicator_runtime(void) { -#ifdef HAVE_APPINDICATOR - return TRUE; -#else - return FALSE; -#endif -} - -static gboolean ewmh_supports_active_window(void) { -#ifdef GDK_WINDOWING_X11 - GdkDisplay* gdk_display = gdk_display_get_default(); - if (gdk_display == NULL || !GDK_IS_X11_DISPLAY(gdk_display)) { - return FALSE; - } - Display* xdisplay = GDK_DISPLAY_XDISPLAY(gdk_display); - if (xdisplay == NULL) return FALSE; - Atom net_supported = XInternAtom(xdisplay, "_NET_SUPPORTED", True); - Atom net_active_window = XInternAtom(xdisplay, "_NET_ACTIVE_WINDOW", True); - if (net_supported == None || net_active_window == None) return FALSE; - Window root = DefaultRootWindow(xdisplay); - Atom actual_type = None; - int actual_format = 0; - unsigned long nitems = 0; - unsigned long bytes_after = 0; - unsigned char* data = NULL; - int status = XGetWindowProperty(xdisplay, root, net_supported, 0, 1024, False, - XA_ATOM, &actual_type, &actual_format, - &nitems, &bytes_after, &data); - gboolean found = FALSE; - if (status == Success && actual_type == XA_ATOM && actual_format == 32 && data != NULL) { - Atom* atoms = (Atom*)data; - for (unsigned long i = 0; i < nitems; ++i) { - if (atoms[i] == net_active_window) { found = TRUE; break; } - } - } - if (data != NULL) XFree(data); - return found; -#else - return FALSE; -#endif -} - -static gchar* read_wm_name(void) { -#ifdef GDK_WINDOWING_X11 - GdkDisplay* gdk_display = gdk_display_get_default(); - if (gdk_display == NULL || !GDK_IS_X11_DISPLAY(gdk_display)) return NULL; - Display* xdisplay = GDK_DISPLAY_XDISPLAY(gdk_display); - Atom check = XInternAtom(xdisplay, "_NET_SUPPORTING_WM_CHECK", True); - Atom utf8 = XInternAtom(xdisplay, "UTF8_STRING", True); - Atom wm_name = XInternAtom(xdisplay, "_NET_WM_NAME", True); - if (check == None || wm_name == None) return NULL; - Window root = DefaultRootWindow(xdisplay); - Atom actual_type = None; - int actual_format = 0; - unsigned long nitems = 0; - unsigned long bytes_after = 0; - unsigned char* data = NULL; - if (XGetWindowProperty(xdisplay, root, check, 0, 1, False, XA_WINDOW, - &actual_type, &actual_format, &nitems, &bytes_after, - &data) != Success || data == NULL) { - return NULL; - } - Window wm_window = *(Window*)data; - XFree(data); - data = NULL; - if (wm_window == None) return NULL; - Atom string_type = utf8 != None ? utf8 : XA_STRING; - if (XGetWindowProperty(xdisplay, wm_window, wm_name, 0, 256, False, - string_type, &actual_type, &actual_format, &nitems, - &bytes_after, &data) != Success || data == NULL) { - return NULL; - } - gchar* name = g_strndup((const gchar*)data, nitems); - XFree(data); - return name; -#else - return NULL; -#endif -} - -static FlValue* build_capabilities(void) { - FlValue* caps = fl_value_new_map(); - fl_value_set_string_take(caps, "isX11", fl_value_new_bool(shell_is_x11())); - fl_value_set_string_take(caps, "hasAppIndicator", - fl_value_new_bool(has_app_indicator_runtime())); - fl_value_set_string_take(caps, "hasEwmh", - fl_value_new_bool(ewmh_supports_active_window())); - const gchar* desktop_env = g_getenv("XDG_CURRENT_DESKTOP"); - if (desktop_env == NULL) desktop_env = g_getenv("DESKTOP_SESSION"); - fl_value_set_string_take(caps, "desktopEnv", - fl_value_new_string(desktop_env != NULL ? desktop_env : "")); - g_autofree gchar* wm = read_wm_name(); - fl_value_set_string_take(caps, "wmName", - fl_value_new_string(wm != NULL ? wm : "")); - return caps; -} - -static FlValue* build_cursor_monitor(void) { - GdkDisplay* display = gdk_display_get_default(); - if (display == NULL) { - return fl_value_new_null(); - } - GdkSeat* seat = gdk_display_get_default_seat(display); - if (seat == NULL) { - return fl_value_new_null(); - } - GdkDevice* pointer = gdk_seat_get_pointer(seat); - if (pointer == NULL) { - return fl_value_new_null(); - } - gint cursor_x = 0; - gint cursor_y = 0; - GdkScreen* screen = NULL; - gdk_device_get_position(pointer, &screen, &cursor_x, &cursor_y); - - GdkMonitor* monitor = - gdk_display_get_monitor_at_point(display, cursor_x, cursor_y); - if (monitor == NULL) { - monitor = gdk_display_get_primary_monitor(display); - } - if (monitor == NULL) { - return fl_value_new_null(); - } - - GdkRectangle workarea = {0, 0, 0, 0}; - gdk_monitor_get_workarea(monitor, &workarea); - gint scale = gdk_monitor_get_scale_factor(monitor); - if (scale <= 0) { - scale = 1; - } - - FlValue* result = fl_value_new_map(); - fl_value_set_string_take(result, "cursorX", - fl_value_new_float((double)cursor_x)); - fl_value_set_string_take(result, "cursorY", - fl_value_new_float((double)cursor_y)); - fl_value_set_string_take(result, "x", - fl_value_new_float((double)workarea.x)); - fl_value_set_string_take(result, "y", - fl_value_new_float((double)workarea.y)); - fl_value_set_string_take(result, "width", - fl_value_new_float((double)workarea.width)); - fl_value_set_string_take(result, "height", - fl_value_new_float((double)workarea.height)); - fl_value_set_string_take(result, "scaleFactor", - fl_value_new_float((double)scale)); - return result; -} - -static FlValue* build_input_focus(CopyPasteLinuxShell* shell) { - FlValue* result = fl_value_new_map(); - fl_value_set_string_take(result, "ownsFocus", fl_value_new_bool(FALSE)); - fl_value_set_string_take(result, "focusWindow", fl_value_new_int(0)); - fl_value_set_string_take(result, "ownWindow", fl_value_new_int(0)); -#ifdef GDK_WINDOWING_X11 - if (!shell_is_x11() || shell->xdisplay == NULL) { - return result; - } - Window focused = None; - int revert_to = 0; - XGetInputFocus(shell->xdisplay, &focused, &revert_to); - fl_value_set_string_take(result, "focusWindow", - fl_value_new_int((gint64)focused)); - - if (shell->gtk_window == NULL) { - return result; - } - GdkWindow* gdk_window = - gtk_widget_get_window(GTK_WIDGET(shell->gtk_window)); - if (gdk_window == NULL) { - return result; - } - Window own = gdk_x11_window_get_xid(gdk_window); - fl_value_set_string_take(result, "ownWindow", fl_value_new_int((gint64)own)); - - gboolean owns = (focused == own); - if (!owns && focused != None && focused != PointerRoot) { - Window root = None; - Window parent = None; - Window* children = NULL; - unsigned int nchildren = 0; - Window cursor = focused; - for (int depth = 0; depth < 8; depth++) { - if (XQueryTree(shell->xdisplay, cursor, &root, &parent, &children, - &nchildren) == 0) { - break; - } - if (children != NULL) { - XFree(children); - } - if (parent == None || parent == root) { - break; - } - if (parent == own) { - owns = TRUE; - break; - } - cursor = parent; - } - } - fl_value_set_string_take(result, "ownsFocus", fl_value_new_bool(owns)); -#else - (void)shell; -#endif - return result; -} - -static void shell_method_call_cb(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data) { - (void)channel; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - const gchar* method = fl_method_call_get_name(method_call); - FlValue* args = fl_method_call_get_args(method_call); - - if (strcmp(method, "getCapabilities") == 0) { - respond_method_success(method_call, build_capabilities()); - return; - } - - if (strcmp(method, "initTray") == 0 || strcmp(method, "updateTray") == 0) { - respond_method_success(method_call, init_tray(shell, args)); - return; - } - - if (strcmp(method, "destroyTray") == 0) { - respond_method_success(method_call, destroy_tray(shell)); - return; - } - - if (strcmp(method, "registerHotkey") == 0) { -#ifdef GDK_WINDOWING_X11 - respond_method_success(method_call, register_hotkey(shell, args)); -#else - respond_method_success(method_call, make_hotkey_result(FALSE, "noX11")); -#endif - return; - } - - if (strcmp(method, "unregisterHotkey") == 0) { -#ifdef GDK_WINDOWING_X11 - unregister_hotkey(shell); - unregister_plain_hotkey(shell); -#endif - respond_method_success(method_call, fl_value_new_bool(TRUE)); - return; - } - - if (strcmp(method, "focusWindow") == 0) { - if (shell->gtk_window != NULL) { -#ifdef GDK_WINDOWING_X11 - guint32 t = shell->last_hotkey_time != 0 ? shell->last_hotkey_time - : GDK_CURRENT_TIME; - gtk_window_present_with_time(shell->gtk_window, t); -#else - gtk_window_present(shell->gtk_window); -#endif - } - respond_method_success(method_call, fl_value_new_bool(TRUE)); - return; - } - - if (strcmp(method, "getCursorMonitor") == 0) { - respond_method_success(method_call, build_cursor_monitor()); - return; - } - - if (strcmp(method, "getInputFocus") == 0) { - respond_method_success(method_call, build_input_focus(shell)); - return; - } - - g_autoptr(FlMethodResponse) response = - FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); - fl_method_call_respond(method_call, response, NULL); -} - -CopyPasteLinuxShell* copypaste_linux_shell_new(FlBinaryMessenger* messenger, - GtkWindow* window) { - CopyPasteLinuxShell* shell = g_new0(CopyPasteLinuxShell, 1); - shell->gtk_window = window; - - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - shell->method_channel = - fl_method_channel_new(messenger, kShellChannelName, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler(shell->method_channel, - shell_method_call_cb, shell, NULL); - - shell->event_channel = - fl_event_channel_new(messenger, kShellEventChannelName, FL_METHOD_CODEC(codec)); - fl_event_channel_set_stream_handlers(shell->event_channel, shell_listen_cb, - shell_cancel_cb, shell, NULL); - - if (shell->gtk_window != NULL) { - g_signal_connect(shell->gtk_window, "unmap-event", - G_CALLBACK(window_unmap_event_cb), shell); - g_signal_connect(shell->gtk_window, "map-event", - G_CALLBACK(window_map_event_cb), shell); - g_signal_connect(shell->gtk_window, "configure-event", - G_CALLBACK(window_configure_event_cb), shell); - } - -#ifdef GDK_WINDOWING_X11 - if (shell_is_x11()) { - GdkDisplay* display = gdk_display_get_default(); - shell->xdisplay = gdk_x11_display_get_xdisplay(display); - shell->root_window = DefaultRootWindow(shell->xdisplay); - GdkWindow* gdk_root = gdk_get_default_root_window(); - gdk_window_add_filter(gdk_root, x11_event_filter, shell); - } -#endif - - return shell; -} - -void copypaste_linux_shell_dispose(CopyPasteLinuxShell* shell) { - if (shell == NULL) { - return; - } - -#ifdef GDK_WINDOWING_X11 - if (shell_is_x11()) { - unregister_hotkey(shell); - unregister_plain_hotkey(shell); - GdkWindow* gdk_root = gdk_get_default_root_window(); - gdk_window_remove_filter(gdk_root, x11_event_filter, shell); - } -#endif - - destroy_tray(shell); - g_clear_object(&shell->method_channel); - g_clear_object(&shell->event_channel); - g_free(shell); -} diff --git a/app/linux/runner/copypaste_linux_shell.h b/app/linux/runner/copypaste_linux_shell.h deleted file mode 100644 index 56fcc1ea..00000000 --- a/app/linux/runner/copypaste_linux_shell.h +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef FLUTTER_COPYPASTE_LINUX_SHELL_H_ -#define FLUTTER_COPYPASTE_LINUX_SHELL_H_ - -#include -#include - -G_BEGIN_DECLS - -typedef struct _CopyPasteLinuxShell CopyPasteLinuxShell; - -CopyPasteLinuxShell* copypaste_linux_shell_new(FlBinaryMessenger* messenger, - GtkWindow* window); - -void copypaste_linux_shell_dispose(CopyPasteLinuxShell* shell); - -G_END_DECLS - -#endif // FLUTTER_COPYPASTE_LINUX_SHELL_H_ \ No newline at end of file diff --git a/app/linux/runner/main.cc b/app/linux/runner/main.cc deleted file mode 100644 index 6f5e5dad..00000000 --- a/app/linux/runner/main.cc +++ /dev/null @@ -1,7 +0,0 @@ -#include "my_application.h" - -int main(int argc, char** argv) { - g_setenv("GDK_BACKEND", "x11", FALSE); - g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); -} diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc deleted file mode 100644 index b1e16f54..00000000 --- a/app/linux/runner/my_application.cc +++ /dev/null @@ -1,130 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "copypaste_linux_shell.h" -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; - CopyPasteLinuxShell* shell; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "CopyPaste"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "CopyPaste"); - } - - gtk_window_set_default_size(window, 368, 500); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments( - project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - GdkRGBA background_color; - gdk_rgba_parse(&background_color, "#1a1a2e"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - gtk_widget_realize(GTK_WIDGET(window)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - FlBinaryMessenger* messenger = - fl_engine_get_binary_messenger(fl_view_get_engine(view)); - self->shell = copypaste_linux_shell_new(messenger, window); - - gtk_widget_grab_focus(GTK_WIDGET(view)); - - gdk_notify_startup_complete(); -} - -static gboolean my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -static void my_application_startup(GApplication* application) { - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); -} - -static void my_application_shutdown(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - if (self->shell != nullptr) { - copypaste_linux_shell_dispose(self->shell); - self->shell = nullptr; - } - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); -} - -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = - my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) { self->shell = nullptr; } - -MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); - - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); -} diff --git a/app/linux/runner/my_application.h b/app/linux/runner/my_application.h deleted file mode 100644 index db16367a..00000000 --- a/app/linux/runner/my_application.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -G_DECLARE_FINAL_TYPE(MyApplication, - my_application, - MY, - APPLICATION, - GtkApplication) - -/** - * my_application_new: - * - * Creates a new Flutter-based application. - * - * Returns: a new #MyApplication. - */ -MyApplication* my_application_new(); - -#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/app/test/helpers/url_helper_test.dart b/app/test/helpers/url_helper_test.dart index a34b3f15..ed75ba37 100644 --- a/app/test/helpers/url_helper_test.dart +++ b/app/test/helpers/url_helper_test.dart @@ -26,13 +26,6 @@ void main() { } catch (_) {} }); - test('takes linux branch when platformOverride=linux', () async { - UrlHelper.platformOverride = 'linux'; - 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 index 31b66b57..992768a3 100644 --- a/app/test/screens/blocked_version_screen_test.dart +++ b/app/test/screens/blocked_version_screen_test.dart @@ -21,7 +21,6 @@ ReleaseManifest _manifest({ String? githubWindowsUrl, String? homebrewCommand, String? msStoreUrl, - String? snapCommand, String? scoopCommand, }) { return ReleaseManifest( @@ -36,10 +35,7 @@ ReleaseManifest _manifest({ if (homebrewCommand != null) 'homebrew': ChannelInfo(command: homebrewCommand), if (msStoreUrl != null) 'msstore': ChannelInfo(url: msStoreUrl), - if (snapCommand != null) 'snap': ChannelInfo(command: snapCommand), if (scoopCommand != null) 'scoop': ChannelInfo(command: scoopCommand), - if (githubWindowsUrl != null) - 'github_linux': ChannelInfo(url: githubWindowsUrl), if (githubWindowsUrl != null) 'github_macos': ChannelInfo(url: githubWindowsUrl), }, @@ -117,21 +113,6 @@ void main() { expect(find.text(l.updateActionCopyCommand('brew')), findsOneWidget); }); - testWidgets('shows Copy command button for snap channel', (tester) async { - InstallChannelDetector.channelOverride = InstallChannel.snap; - await tester.pumpWidget( - _wrap( - BlockedVersionScreen( - currentVersion: '2.2.6', - manifest: _manifest(snapCommand: 'sudo snap refresh copypaste'), - ), - ), - ); - await tester.pumpAndSettle(); - final l = await AppLocalizations.delegate.load(const Locale('en')); - expect(find.text(l.updateActionCopyCommand('snap')), findsOneWidget); - }); - testWidgets('shows Copy command button for scoop channel', (tester) async { InstallChannelDetector.channelOverride = InstallChannel.scoop; await tester.pumpWidget( diff --git a/app/test/screens/linux_capabilities_banner_test.dart b/app/test/screens/linux_capabilities_banner_test.dart deleted file mode 100644 index fc9edca9..00000000 --- a/app/test/screens/linux_capabilities_banner_test.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/l10n/app_localizations.dart'; -import 'package:copypaste/screens/linux_capabilities_banner.dart'; -import 'package:copypaste/services/linux_capabilities.dart'; -import 'package:copypaste/shell/linux_session.dart'; -import 'package:copypaste/theme/compact_theme.dart'; -import 'package:copypaste/theme/theme_provider.dart'; -import 'package:core/core.dart'; - -LinuxCapabilities _caps({bool hasAppIndicator = true, bool hasXTest = true}) { - return LinuxCapabilities( - session: const LinuxSessionInfo( - sessionType: 'x11', - hasDisplay: true, - hasWaylandDisplay: false, - hasWaylandSocket: false, - desktopEnv: 'gnome', - wmName: 'mutter', - ), - isX11: true, - hasXTest: hasXTest, - hasAppIndicator: hasAppIndicator, - hasEwmh: true, - detectedDesktopEnv: 'gnome', - detectedWmName: 'mutter', - detectionTimedOut: false, - ); -} - -Widget _wrap(Widget child) { - return MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: CopyPasteTheme( - themeData: CompactTheme(), - child: Scaffold(body: child), - ), - ); -} - -void main() { - group('LinuxCapabilitiesBanner', () { - testWidgets('renders nothing when not on Linux', (tester) async { - if (Platform.isLinux) return; - await tester.pumpWidget( - _wrap( - LinuxCapabilitiesBanner( - config: const AppConfig(), - capabilities: _caps(hasAppIndicator: false), - onDismiss: (_) async {}, - ), - ), - ); - expect(find.byType(Icon), findsNothing); - }); - - testWidgets('renders AppIndicator banner when missing and not dismissed', ( - tester, - ) async { - if (!Platform.isLinux) return; - await tester.pumpWidget( - _wrap( - LinuxCapabilitiesBanner( - config: const AppConfig(), - capabilities: _caps(hasAppIndicator: false), - onDismiss: (_) async {}, - ), - ), - ); - expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); - expect(find.byIcon(Icons.close_rounded), findsOneWidget); - }); - - testWidgets( - 'renders nothing when capability missing but already dismissed', - (tester) async { - if (!Platform.isLinux) return; - await tester.pumpWidget( - _wrap( - LinuxCapabilitiesBanner( - config: const AppConfig( - linuxAppindicatorWarningDismissed: true, - linuxXtestWarningDismissed: true, - ), - capabilities: _caps(hasAppIndicator: false, hasXTest: false), - onDismiss: (_) async {}, - ), - ), - ); - expect(find.byIcon(Icons.warning_amber_rounded), findsNothing); - }, - ); - - testWidgets('dismiss callback fires when close icon tapped', ( - tester, - ) async { - if (!Platform.isLinux) return; - AppConfig? captured; - await tester.pumpWidget( - _wrap( - LinuxCapabilitiesBanner( - config: const AppConfig(), - capabilities: _caps(hasAppIndicator: false), - onDismiss: (update) async { - captured = update(const AppConfig()); - }, - ), - ), - ); - await tester.tap(find.byIcon(Icons.close_rounded)); - await tester.pump(); - expect(captured?.linuxAppindicatorWarningDismissed, isTrue); - }); - }); -} diff --git a/app/test/screens/main_screen_test.dart b/app/test/screens/main_screen_test.dart index 80b72bec..eb932557 100644 --- a/app/test/screens/main_screen_test.dart +++ b/app/test/screens/main_screen_test.dart @@ -7,7 +7,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:copypaste/helpers/url_helper.dart'; import 'package:copypaste/l10n/app_localizations.dart'; import 'package:copypaste/screens/main_screen.dart'; -import 'package:copypaste/services/linux_capabilities.dart'; import 'package:copypaste/services/release_manifest_service.dart'; import 'package:copypaste/theme/compact_theme.dart'; import 'package:copypaste/theme/theme_provider.dart'; @@ -29,9 +28,6 @@ Widget _buildApp({ VoidCallback? onDismissHint, String? updateVersion, ManifestSeverity? updateSeverity, - AppConfig? appConfig, - LinuxCapabilities? linuxCapabilities, - Future Function(AppConfig Function(AppConfig))? onLinuxConfigUpdate, Key? key, }) { return MaterialApp( @@ -57,9 +53,6 @@ Widget _buildApp({ onDismissHint: onDismissHint, updateVersion: updateVersion, updateSeverity: updateSeverity, - appConfig: appConfig, - linuxCapabilities: linuxCapabilities, - onLinuxConfigUpdate: onLinuxConfigUpdate, ), ), ), @@ -1640,31 +1633,6 @@ void main() { expect(find.byType(MainScreen), findsOneWidget); }); - testWidgets( - 'LinuxCapabilitiesBanner renders when all linux params provided', - (tester) async { - const capabilities = LinuxCapabilities.unsupported; - const config = AppConfig(); - bool callbackCalled = false; - - await tester.pumpWidget( - _buildApp( - service: service, - onPaste: (_) {}, - appConfig: config, - linuxCapabilities: capabilities, - onLinuxConfigUpdate: (fn) async { - callbackCalled = true; - }, - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(MainScreen), findsOneWidget); - expect(callbackCalled, isFalse); - }, - ); - testWidgets('Alt+T shortcut opens filter bar', (tester) async { final key = GlobalKey(); await tester.pumpWidget( diff --git a/app/test/screens/settings_screen_test.dart b/app/test/screens/settings_screen_test.dart index 7dd17cc9..1e232009 100644 --- a/app/test/screens/settings_screen_test.dart +++ b/app/test/screens/settings_screen_test.dart @@ -114,10 +114,9 @@ void main() { await tester.tap(find.text('Shortcuts')); await tester.pump(); - expect( - find.textContaining('Current: Ctrl + Alt + Shift + V'), - findsOneWidget, - ); + // macOS renders modifiers as SF symbols with no separator. + 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', diff --git a/app/test/screens/wayland_unsupported_screen_test.dart b/app/test/screens/wayland_unsupported_screen_test.dart deleted file mode 100644 index bba8a4f6..00000000 --- a/app/test/screens/wayland_unsupported_screen_test.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/l10n/app_localizations.dart'; -import 'package:copypaste/screens/wayland_unsupported_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(640, 960); - tester.view.devicePixelRatio = 2.0; - addTearDown(tester.view.reset); - await tester.pumpWidget(_wrap(child, locale: locale)); - await tester.pump(); -} - -void main() { - Widget screen({VoidCallback? onClose}) => - WaylandUnsupportedScreen(onClose: onClose ?? () {}); - - group('WaylandUnsupportedScreen', () { - testWidgets('renders title text', (tester) async { - await _pump(tester, screen()); - - expect(find.text('Wayland is not supported'), findsOneWidget); - }); - - testWidgets('renders badge chip', (tester) async { - await _pump(tester, screen()); - - expect(find.text('Open source · X11 only'), findsOneWidget); - }); - - testWidgets('renders body text', (tester) async { - await _pump(tester, screen()); - - expect( - find.textContaining('Linux support is still a work in progress'), - findsOneWidget, - ); - }); - - testWidgets('renders GitHub FilledButton', (tester) async { - await _pump(tester, screen()); - - expect(find.byType(FilledButton), findsOneWidget); - expect(find.text('View on GitHub'), findsOneWidget); - }); - - testWidgets('renders Close OutlinedButton', (tester) async { - await _pump(tester, screen()); - - expect(find.byType(OutlinedButton), findsOneWidget); - expect(find.text('Close'), findsOneWidget); - }); - - testWidgets('tapping close button invokes onClose', (tester) async { - var closed = false; - await _pump(tester, screen(onClose: () => closed = true)); - - await tester.ensureVisible(find.byType(OutlinedButton)); - await tester.tap(find.byType(OutlinedButton)); - await tester.pump(); - - expect(closed, isTrue); - }); - - testWidgets('app icon is displayed', (tester) async { - await _pump(tester, screen()); - - expect(find.byType(Image), findsOneWidget); - }); - - testWidgets('renders both action buttons', (tester) async { - await _pump(tester, screen()); - - expect(find.byType(FilledButton), findsOneWidget); - expect(find.byType(OutlinedButton), findsOneWidget); - }); - - testWidgets('renders in Spanish locale', (tester) async { - await _pump(tester, screen(), locale: const Locale('es')); - - expect(find.text('Wayland no está soportado'), findsOneWidget); - expect(find.text('Open source · Solo X11'), findsOneWidget); - expect(find.text('Cerrar'), findsOneWidget); - }); - - testWidgets('renders in dark mode without errors', (tester) async { - tester.view.physicalSize = const Size(640, 960); - 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('Wayland is not supported'), findsOneWidget); - }); - - testWidgets('open_in_new icon is present on GitHub button', (tester) async { - await _pump(tester, screen()); - - expect(find.byIcon(Icons.open_in_new_rounded), findsOneWidget); - }); - }); -} diff --git a/app/test/services/install_channel_test.dart b/app/test/services/install_channel_test.dart index 6423bc44..0b692e48 100644 --- a/app/test/services/install_channel_test.dart +++ b/app/test/services/install_channel_test.dart @@ -11,22 +11,6 @@ void main() { expect(c, InstallChannel.homebrew); }); - test('detects appImage paths', () { - final c = InstallChannelDetector.detect( - execPathOverride: '/home/user/Apps/CopyPaste-2.3.0.AppImage', - platformOverride: HostPlatform.linux, - ); - expect(c, InstallChannel.appImage); - }); - - test('detects snap paths', () { - final c = InstallChannelDetector.detect( - execPathOverride: '/snap/copypaste/x1/copypaste', - platformOverride: HostPlatform.linux, - ); - expect(c, InstallChannel.snap); - }); - test('detects scoop installs on the default root', () { final c = InstallChannelDetector.detect( execPathOverride: @@ -59,12 +43,5 @@ void main() { expect(InstallChannelDetector.manifestKey(c), isNotEmpty); } }); - - test('appImage falls back to github_linux bucket', () { - expect( - InstallChannelDetector.manifestKey(InstallChannel.appImage), - 'github_linux', - ); - }); }); } diff --git a/app/test/services/linux_capabilities_test.dart b/app/test/services/linux_capabilities_test.dart deleted file mode 100644 index 2785bd87..00000000 --- a/app/test/services/linux_capabilities_test.dart +++ /dev/null @@ -1,266 +0,0 @@ -import 'dart:io'; - -import 'package:copypaste/services/linux_capabilities.dart'; -import 'package:copypaste/services/linux_guard.dart'; -import 'package:copypaste/shell/linux_session.dart'; -import 'package:flutter_test/flutter_test.dart'; - -class _FakeChannel implements LinuxCapabilitiesChannel { - _FakeChannel({ - this.shellResponse, - this.listenerResponse, - this.shellThrows, - this.listenerThrows, - this.shellDelay = Duration.zero, - }); - - final Map? shellResponse; - final Map? listenerResponse; - final Object? shellThrows; - final Object? listenerThrows; - final Duration shellDelay; - - int shellCalls = 0; - int listenerCalls = 0; - - @override - Future?> invokeShell(String method) async { - shellCalls++; - if (shellDelay > Duration.zero) await Future.delayed(shellDelay); - if (shellThrows != null) throw shellThrows!; - return shellResponse; - } - - @override - Future?> invokeListener(String method) async { - listenerCalls++; - if (listenerThrows != null) throw listenerThrows!; - return listenerResponse; - } -} - -LinuxSessionInfo _x11Session() => const LinuxSessionInfo( - sessionType: 'x11', - hasDisplay: true, - hasWaylandDisplay: false, - hasWaylandSocket: false, - desktopEnv: 'GNOME', - wmName: 'Mutter', -); - -void main() { - setUp(() { - LinuxCapabilitiesService.resetForTesting(); - }); - group('LinuxCapabilitiesService.detect', () { - test('returns unsupported on non-Linux platforms', () async { - if (Platform.isLinux) return; - final caps = await LinuxCapabilitiesService.detect(); - expect(caps, equals(LinuxCapabilities.unsupported)); - expect(LinuxCapabilitiesService.isInitialized, isTrue); - expect(LinuxCapabilitiesService.current, equals(caps)); - }); - - test('parses full capability map from both channels', () async { - if (!Platform.isLinux) return; - final channel = _FakeChannel( - shellResponse: const { - 'isX11': true, - 'hasAppIndicator': true, - 'hasEwmh': true, - 'desktopEnv': 'GNOME', - 'wmName': 'Mutter', - }, - listenerResponse: const {'isX11': true, 'hasXTest': true}, - ); - final caps = await LinuxCapabilitiesService.detect( - channel: channel, - sessionOverride: _x11Session(), - ); - expect(caps.hasXTest, isTrue); - expect(caps.hasAppIndicator, isTrue); - expect(caps.hasEwmh, isTrue); - expect(caps.detectedDesktopEnv, equals('GNOME')); - expect(caps.detectedWmName, equals('Mutter')); - expect(caps.detectionTimedOut, isFalse); - }); - - test('returns conservative defaults when channels throw', () async { - if (!Platform.isLinux) return; - final channel = _FakeChannel( - shellThrows: Exception('shell boom'), - listenerThrows: Exception('listener boom'), - ); - final caps = await LinuxCapabilitiesService.detect( - channel: channel, - sessionOverride: _x11Session(), - ); - expect(caps.hasXTest, isFalse); - expect(caps.hasAppIndicator, isFalse); - expect(caps.hasEwmh, isFalse); - expect(caps.detectionTimedOut, isFalse); - }); - - test('marks detectionTimedOut when timeout fires', () async { - if (!Platform.isLinux) return; - final channel = _FakeChannel( - shellResponse: const {'isX11': true, 'hasEwmh': true}, - shellDelay: const Duration(milliseconds: 200), - ); - final caps = await LinuxCapabilitiesService.detect( - channel: channel, - timeout: const Duration(milliseconds: 20), - sessionOverride: _x11Session(), - ); - expect(caps.detectionTimedOut, isTrue); - expect(caps.hasEwmh, isFalse); - }); - - test('does not query channels when session is not X11', () async { - if (Platform.isLinux) return; - final channel = _FakeChannel(shellResponse: const {'isX11': true}); - await LinuxCapabilitiesService.detect(channel: channel); - expect(channel.shellCalls, equals(0)); - expect(channel.listenerCalls, equals(0)); - }); - - test('caches the last detected value in current', () async { - if (!Platform.isLinux) return; - final channel = _FakeChannel( - shellResponse: const {'isX11': true, 'hasEwmh': true}, - listenerResponse: const {'hasXTest': true}, - ); - final caps = await LinuxCapabilitiesService.detect( - channel: channel, - sessionOverride: _x11Session(), - ); - expect(LinuxCapabilitiesService.current, equals(caps)); - }); - }); - - group('LinuxGuard', () { - test('isLinux delegates to Platform', () { - expect(LinuxGuard.isLinux, equals(Platform.isLinux)); - }); - - test('all guards are false when capabilities are unsupported', () { - LinuxCapabilitiesService.resetForTesting(LinuxCapabilities.unsupported); - expect(LinuxGuard.canRegisterHotkey, isFalse); - expect(LinuxGuard.canPasteBack, isFalse); - expect(LinuxGuard.canShowTray, isFalse); - expect(LinuxGuard.canAutostart, isFalse); - expect(LinuxGuard.usesNativeWindowEffects, isFalse); - }); - - test('canPasteBack requires X11 + XTest', () { - if (!Platform.isLinux) return; - LinuxCapabilitiesService.resetForTesting( - LinuxCapabilities.unsupported.copyWith(isX11: true, hasXTest: true), - ); - expect(LinuxGuard.canPasteBack, isTrue); - LinuxCapabilitiesService.resetForTesting( - LinuxCapabilities.unsupported.copyWith(isX11: true, hasXTest: false), - ); - expect(LinuxGuard.canPasteBack, isFalse); - }); - - test('canShowTray requires X11 + AppIndicator', () { - if (!Platform.isLinux) return; - LinuxCapabilitiesService.resetForTesting( - LinuxCapabilities.unsupported.copyWith( - isX11: true, - hasAppIndicator: true, - ), - ); - expect(LinuxGuard.canShowTray, isTrue); - LinuxCapabilitiesService.resetForTesting( - LinuxCapabilities.unsupported.copyWith(isX11: true), - ); - expect(LinuxGuard.canShowTray, isFalse); - }); - - test('canRegisterHotkey requires X11 + EWMH', () { - if (!Platform.isLinux) return; - LinuxCapabilitiesService.resetForTesting( - LinuxCapabilities.unsupported.copyWith(isX11: true, hasEwmh: true), - ); - expect(LinuxGuard.canRegisterHotkey, isTrue); - }); - - test('isWayland returns true when capabilities have isWayland', () { - if (!Platform.isLinux) return; - const waylandSession = LinuxSessionInfo( - sessionType: 'wayland', - hasDisplay: false, - hasWaylandDisplay: true, - hasWaylandSocket: false, - desktopEnv: '', - wmName: '', - ); - const waylandCaps = LinuxCapabilities( - session: waylandSession, - isX11: false, - hasXTest: false, - hasAppIndicator: false, - hasEwmh: false, - detectedDesktopEnv: '', - detectedWmName: '', - detectionTimedOut: false, - ); - LinuxCapabilitiesService.resetForTesting(waylandCaps); - expect(LinuxGuard.isWayland, isTrue); - LinuxCapabilitiesService.resetForTesting(LinuxCapabilities.unsupported); - expect(LinuxGuard.isWayland, isFalse); - }); - - test('isUsable requires isLinux and X11', () { - if (!Platform.isLinux) return; - LinuxCapabilitiesService.resetForTesting( - LinuxCapabilities.unsupported.copyWith(isX11: true), - ); - expect(LinuxGuard.isUsable, isTrue); - LinuxCapabilitiesService.resetForTesting(LinuxCapabilities.unsupported); - expect(LinuxGuard.isUsable, isFalse); - }); - }); - - group('LinuxCapabilities', () { - test('copyWith preserves unchanged fields', () { - if (!Platform.isLinux) return; - final original = LinuxCapabilities.unsupported.copyWith( - isX11: true, - hasXTest: true, - hasEwmh: true, - detectedDesktopEnv: 'GNOME', - detectedWmName: 'Mutter', - ); - final copy = original.copyWith(hasAppIndicator: true); - expect(copy.isX11, isTrue); - expect(copy.hasXTest, isTrue); - expect(copy.hasEwmh, isTrue); - expect(copy.hasAppIndicator, isTrue); - expect(copy.detectedDesktopEnv, 'GNOME'); - expect(copy.detectedWmName, 'Mutter'); - }); - - test('toString contains key field values', () { - if (!Platform.isLinux) return; - final caps = LinuxCapabilities.unsupported.copyWith(isX11: true); - final s = caps.toString(); - expect(s, contains('isX11=true')); - expect(s, contains('LinuxCapabilities(')); - }); - - test('isUsable is false when isX11 is false', () { - if (!Platform.isLinux) return; - const caps = LinuxCapabilities.unsupported; - expect(caps.isUsable, isFalse); - }); - - test('isUsable is true when isX11 is true and running on Linux', () { - if (!Platform.isLinux) return; - final caps = LinuxCapabilities.unsupported.copyWith(isX11: true); - expect(caps.isUsable, isTrue); - }); - }); -} diff --git a/app/test/services/release_manifest_service_test.dart b/app/test/services/release_manifest_service_test.dart index f846d4fd..c9cd5da8 100644 --- a/app/test/services/release_manifest_service_test.dart +++ b/app/test/services/release_manifest_service_test.dart @@ -52,14 +52,14 @@ void main() { "latest": "1.0.0", "minimumSupported": "1.0.0", "channels": { - "github_linux": { "url": "http://insecure.example/x" }, - "snap": { "command": "sudo snap refresh copypaste" } + "github_windows": { "url": "http://insecure.example/x" }, + "scoop": { "command": "scoop update copypaste" } } } '''); expect(m, isNotNull); - expect(m!.channels.containsKey('github_linux'), isFalse); - expect(m.channels['snap']?.command, 'sudo snap refresh copypaste'); + expect(m!.channels.containsKey('github_windows'), isFalse); + expect(m.channels['scoop']?.command, 'scoop update copypaste'); }); }); diff --git a/app/test/shell/app_window_test.dart b/app/test/shell/app_window_test.dart index 41f3c6c2..97c2c0f5 100644 --- a/app/test/shell/app_window_test.dart +++ b/app/test/shell/app_window_test.dart @@ -84,7 +84,7 @@ void _teardownWindowManagerMock() { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - group('AppWindow.show() on Linux', () { + group('AppWindow.show()', () { late List calls; setUp(() { @@ -94,33 +94,8 @@ void main() { tearDown(_teardownWindowManagerMock); - test('calls show() and focus() on Linux without setOpacity', () async { - if (!Platform.isLinux) return; - - bool visibilityChanged = false; - final window = AppWindow( - onVisibilityChanged: (_) => visibilityChanged = true, - popupWidth: 368, - popupHeight: 500, - ); - - await window.show(); - - expect( - calls.any((c) => c.method == 'show'), - isTrue, - reason: 'show() should be called', - ); - expect( - calls.any((c) => c.method == 'setOpacity'), - isFalse, - reason: 'setOpacity should not be called — opacity trick was removed', - ); - expect(visibilityChanged, isTrue); - }); - test('isVisible becomes true after show()', () async { - if (!Platform.isLinux && !Platform.isMacOS) return; + if (Platform.isWindows) return; final window = AppWindow(popupWidth: 368, popupHeight: 500); expect(window.isVisible, isFalse); @@ -129,7 +104,7 @@ void main() { }); test('isVisible becomes false after hide()', () async { - if (!Platform.isLinux && !Platform.isMacOS) return; + if (Platform.isWindows) return; final window = AppWindow(popupWidth: 368, popupHeight: 500); await window.show(); @@ -139,7 +114,7 @@ void main() { }); test('hide() is no-op when already hidden', () async { - if (!Platform.isLinux && !Platform.isMacOS) return; + if (Platform.isWindows) return; final window = AppWindow(popupWidth: 368, popupHeight: 500); // Not yet shown — hiding should do nothing. @@ -153,7 +128,7 @@ void main() { }); test('toggle() shows when hidden and hides when visible', () async { - if (!Platform.isLinux && !Platform.isMacOS) return; + if (Platform.isWindows) return; final window = AppWindow(popupWidth: 368, popupHeight: 500); expect(window.isVisible, isFalse); @@ -166,7 +141,7 @@ void main() { }); test('onVisibilityChanged callback fires on show and hide', () async { - if (!Platform.isLinux && !Platform.isMacOS) return; + if (Platform.isWindows) return; final events = []; final window = AppWindow( diff --git a/app/test/shell/desktop_notifier_test.dart b/app/test/shell/desktop_notifier_test.dart index e6337c23..68833a68 100644 --- a/app/test/shell/desktop_notifier_test.dart +++ b/app/test/shell/desktop_notifier_test.dart @@ -5,10 +5,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:copypaste/shell/desktop_notifier.dart'; void main() { - tearDown(() { - DesktopNotifier.processRunnerOverride = null; - }); - group('DesktopNotifier – macOS', () { test('returns false on macOS (no-op)', () async { if (!Platform.isMacOS) return; @@ -17,112 +13,14 @@ void main() { }); }); - group('DesktopNotifier – Linux routing', () { - test('spawns notify-send with correct arguments', () async { - if (!Platform.isLinux) return; - - String? capturedExe; - List? capturedArgs; - DesktopNotifier.processRunnerOverride = - (String exe, List args) async { - capturedExe = exe; - capturedArgs = List.from(args); - return ProcessResult(0, 0, '', ''); - }; - - final result = await DesktopNotifier.show( - title: 'CopyPaste', - body: 'Running in the background.', - ); - - expect(result, isTrue); - expect(capturedExe, equals('notify-send')); - expect(capturedArgs, isNotNull); - expect(capturedArgs, contains('--app-name=CopyPaste')); - expect(capturedArgs, contains('--icon=copypaste')); - expect(capturedArgs, contains('--expire-time=7000')); - expect(capturedArgs, contains('CopyPaste')); - expect(capturedArgs, contains('Running in the background.')); - }); - - test('title and body are forwarded verbatim', () async { - if (!Platform.isLinux) return; - - const title = 'My Title'; - const body = 'My Body Line'; - String? gotTitle; - String? gotBody; - DesktopNotifier.processRunnerOverride = - (String exe, List args) async { - gotTitle = args[args.length - 2]; - gotBody = args[args.length - 1]; - return ProcessResult(0, 0, '', ''); - }; - - await DesktopNotifier.show(title: title, body: body); - expect(gotTitle, equals(title)); - expect(gotBody, equals(body)); - }); - - test('returns false when notify-send exits with non-zero code', () async { - if (!Platform.isLinux) return; - - DesktopNotifier.processRunnerOverride = - (String exe, List args) async { - return ProcessResult(0, 1, '', 'error'); - }; - - final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); - expect(result, isFalse); - }); - + group('DesktopNotifier – unsupported hosts', () { test( - 'returns false when notify-send is not installed (ProcessException)', + 'returns false when the platform has no notification channel', () async { - if (!Platform.isLinux) return; - - DesktopNotifier.processRunnerOverride = - (String exe, List args) async { - throw ProcessException(exe, args, 'No such file or directory', 2); - }; - + if (Platform.isWindows) return; final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); expect(result, isFalse); }, ); - - test('returns false on unexpected exception (never throws)', () async { - if (!Platform.isLinux) return; - - DesktopNotifier.processRunnerOverride = - (String exe, List args) async { - throw StateError('unexpected'); - }; - - final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); - expect(result, isFalse); - }); - }); - - group('DesktopNotifier – processRunnerOverride lifecycle', () { - test('override is invoked when set', () async { - if (!Platform.isLinux) return; - - var called = false; - DesktopNotifier.processRunnerOverride = - (String exe, List args) async { - called = true; - return ProcessResult(0, 0, '', ''); - }; - - await DesktopNotifier.show(title: 'T', body: 'B'); - expect(called, isTrue); - }); - - test('override resets to null after tearDown (isolation)', () { - // Confirms test isolation — override should be null at this point - // because tearDown clears it. - expect(DesktopNotifier.processRunnerOverride, isNull); - }); }); } diff --git a/app/test/shell/focus_manager_linux_test.dart b/app/test/shell/focus_manager_linux_test.dart deleted file mode 100644 index b6074575..00000000 --- a/app/test/shell/focus_manager_linux_test.dart +++ /dev/null @@ -1,337 +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 'org.gnome.Nautilus'; - case 'activateAndPaste': - return true; - default: - return null; - } - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - group('WindowFocusManager – Linux', () { - test('capturePreviousWindow calls captureFrontmostApp', () async { - if (!Platform.isLinux) return; - - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - if (call.method == 'captureFrontmostApp') return 'org.gnome.gedit'; - 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.isLinux) 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.isLinux) 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 bundleId', - () async { - if (!Platform.isLinux) return; - - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - if (call.method == 'captureFrontmostApp') { - return 'org.gnome.gedit'; - } - 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('org.gnome.gedit')); - expect(pasteCall.arguments['delayMs'], equals(250)); - }, - ); - - test('restoreAndPaste passes delayBeforePasteMs correctly', () async { - if (!Platform.isLinux) return; - - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - if (call.method == 'captureFrontmostApp') { - return 'org.kde.dolphin'; - } - if (call.method == 'activateAndPaste') return true; - return null; - }); - - final manager = WindowFocusManager(); - await manager.capturePreviousWindow(); - await manager.restoreAndPaste( - delayBeforeFocusMs: 0, - maxFocusVerifyAttempts: 1, - delayBeforePasteMs: 100, - ); - - final pasteCall = calls.firstWhere((c) => c.method == 'activateAndPaste'); - expect(pasteCall.arguments['delayMs'], equals(100)); - }); - - test( - 'clear() resets bundle id so restoreAndPaste becomes a no-op', - () async { - if (!Platform.isLinux) return; - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'captureFrontmostApp') { - return 'org.gnome.Nautilus'; - } - 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.isLinux) return; - - int callCount = 0; - final bundleIds = ['org.first.app', 'org.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 org.first.app - await manager.capturePreviousWindow(); // stores org.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('org.second.app')); - }, - ); - - test( - 'restoreAndPaste propagates ACCESSIBILITY_DENIED PlatformException', - () async { - if (!Platform.isLinux) return; - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'captureFrontmostApp') { - return 'org.gnome.Nautilus'; - } - 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'), - ), - ), - ); - }, - ); - - test('restoreAndPaste clears bundle id after completing paste', () async { - if (!Platform.isLinux) return; - - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - if (call.method == 'captureFrontmostApp') { - return 'org.gnome.gedit'; - } - if (call.method == 'activateAndPaste') return true; - return null; - }); - - final manager = WindowFocusManager(); - await manager.capturePreviousWindow(); - await manager.restoreAndPaste( - delayBeforeFocusMs: 0, - maxFocusVerifyAttempts: 1, - delayBeforePasteMs: 0, - ); - - // Second restoreAndPaste must be a no-op (bundle id was cleared) - final countBefore = calls.length; - await manager.restoreAndPaste( - delayBeforeFocusMs: 0, - maxFocusVerifyAttempts: 1, - delayBeforePasteMs: 0, - ); - expect( - calls.where((c) => c.method == 'activateAndPaste').length, - equals( - countBefore - - calls.where((c) => c.method != 'activateAndPaste').length, - ), - reason: 'activateAndPaste should not be called again after clear', - ); - }); - - test( - 'clear() is idempotent — safe to call without prior capture', - () async { - if (!Platform.isLinux) return; - - final manager = WindowFocusManager(); - manager.clear(); // no prior capture - manager.clear(); // double 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); - }, - ); - }); -} diff --git a/app/test/shell/focus_manager_test.dart b/app/test/shell/focus_manager_test.dart index 6d9295d2..5154aecd 100644 --- a/app/test/shell/focus_manager_test.dart +++ b/app/test/shell/focus_manager_test.dart @@ -242,5 +242,5 @@ void main() { ); }, ); - }); + }, skip: !Platform.isMacOS ? 'macOS-only' : null); } diff --git a/app/test/shell/hotkey_binding_test.dart b/app/test/shell/hotkey_binding_test.dart new file mode 100644 index 00000000..e2f6ac98 --- /dev/null +++ b/app/test/shell/hotkey_binding_test.dart @@ -0,0 +1,110 @@ +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/linux_hotkey_registration_test.dart b/app/test/shell/linux_hotkey_registration_test.dart deleted file mode 100644 index fd1efa43..00000000 --- a/app/test/shell/linux_hotkey_registration_test.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/linux_hotkey_registration.dart'; -import 'package:copypaste/shell/linux_shell.dart'; - -class _FakeLinuxHotkeyBindingApi implements LinuxHotkeyBindingApi { - _FakeLinuxHotkeyBindingApi(this.responses); - - final List responses; - final List attempts = []; - - @override - Future registerHotkey(HotkeyBinding binding) async { - attempts.add(binding); - if (responses.isEmpty) { - return const HotkeyRegisterResponse(success: false, errorCode: 'unknown'); - } - return responses.removeAt(0); - } -} - -HotkeyRegisterResponse _ok() => const HotkeyRegisterResponse(success: true); -HotkeyRegisterResponse _fail(String code) => - HotkeyRegisterResponse(success: false, errorCode: code); - -void main() { - group('isLinuxSupportedVirtualKey', () { - test('accepts A-Z, 0-9, F-keys, navigation, symbols', () { - expect(isLinuxSupportedVirtualKey(0x41), isTrue); - expect(isLinuxSupportedVirtualKey(0x5A), isTrue); - expect(isLinuxSupportedVirtualKey(0x30), isTrue); - expect(isLinuxSupportedVirtualKey(0x70), isTrue); - expect(isLinuxSupportedVirtualKey(0x87), isTrue); - expect(isLinuxSupportedVirtualKey(0x20), isTrue); - expect(isLinuxSupportedVirtualKey(0x25), isTrue); - expect(isLinuxSupportedVirtualKey(0xC0), isTrue); - }); - - test('rejects unmapped virtual keys', () { - expect(isLinuxSupportedVirtualKey(0x00), isFalse); - expect(isLinuxSupportedVirtualKey(0x90), isFalse); - expect(isLinuxSupportedVirtualKey(0xFF), isFalse); - }); - }); - - group('registerLinuxHotkeyWithFallback', () { - const requested = HotkeyBinding( - virtualKey: 0x56, - keyName: 'V', - useCtrl: true, - useWin: false, - useAlt: true, - useShift: false, - ); - - test( - 'short-circuits when requested key is unsupported (no remote call)', - () async { - const unsupported = HotkeyBinding( - virtualKey: 0x99, - keyName: '?', - useCtrl: true, - useWin: false, - useAlt: true, - useShift: false, - ); - final api = _FakeLinuxHotkeyBindingApi([]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: unsupported, - ); - - expect(result.status, HotkeyRegistrationStatus.failed); - expect(result.failureReason, HotkeyFailureReason.unsupportedKey); - expect(api.attempts, isEmpty); - }, - ); - - test('registers requested binding when available', () async { - final api = _FakeLinuxHotkeyBindingApi([_ok()]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: requested, - ); - - expect(result.status, HotkeyRegistrationStatus.registered); - expect(result.effectiveBinding, requested); - expect(result.failureReason, isNull); - expect(api.attempts, [requested]); - }); - - test('falls back when requested binding fails with grabFailed', () async { - final api = _FakeLinuxHotkeyBindingApi([ - _fail('grabFailed'), - _ok(), - ]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: requested, - ); - - expect(result.status, HotkeyRegistrationStatus.fallbackRegistered); - expect(result.effectiveBinding, kLinuxTemporaryFallbackHotkey); - expect(result.failureReason, HotkeyFailureReason.grabFailed); - expect(api.attempts, [ - requested, - kLinuxTemporaryFallbackHotkey, - ]); - }); - - test('fails cleanly when requested and fallback both fail', () async { - final api = _FakeLinuxHotkeyBindingApi([ - _fail('grabFailed'), - _fail('grabFailed'), - ]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: requested, - ); - - expect(result.status, HotkeyRegistrationStatus.failed); - expect(result.effectiveBinding, isNull); - expect(result.failureReason, HotkeyFailureReason.grabFailed); - }); - - test( - 'does not retry when requested binding equals temporary fallback', - () async { - final api = _FakeLinuxHotkeyBindingApi([ - _fail('grabFailed'), - ]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: kLinuxTemporaryFallbackHotkey, - ); - - expect(result.status, HotkeyRegistrationStatus.failed); - expect(result.failureReason, HotkeyFailureReason.grabFailed); - expect(api.attempts, [kLinuxTemporaryFallbackHotkey]); - }, - ); - - test('maps unknown error code to HotkeyFailureReason.unknown', () async { - final api = _FakeLinuxHotkeyBindingApi([ - _fail('something_weird'), - _fail('something_weird'), - ]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: requested, - ); - - expect(result.failureReason, HotkeyFailureReason.unknown); - }); - - test('maps noModifier and noX11 error codes', () async { - final api1 = _FakeLinuxHotkeyBindingApi([ - _fail('noModifier'), - _fail('noModifier'), - ]); - final r1 = await registerLinuxHotkeyWithFallback( - api: api1, - requestedBinding: requested, - ); - expect(r1.failureReason, HotkeyFailureReason.noModifier); - - final api2 = _FakeLinuxHotkeyBindingApi([ - _fail('noX11'), - _fail('noX11'), - ]); - final r2 = await registerLinuxHotkeyWithFallback( - api: api2, - requestedBinding: requested, - ); - expect(r2.failureReason, HotkeyFailureReason.noX11); - }); - }); -} diff --git a/app/test/shell/linux_session_extended_test.dart b/app/test/shell/linux_session_extended_test.dart deleted file mode 100644 index 90072c3b..00000000 --- a/app/test/shell/linux_session_extended_test.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/linux_session.dart'; - -void main() { - // --------------------------------------------------------------------------- - // isWaylandSession – environment-aware branch coverage - // --------------------------------------------------------------------------- - group('isWaylandSession – non-Linux', () { - test('returns false on non-Linux platforms', () { - if (Platform.isLinux) return; - expect(isWaylandSession(), isFalse); - }); - - test('return type is always bool on non-Linux', () { - if (Platform.isLinux) return; - expect(isWaylandSession(), isA()); - }); - }); - - group('isWaylandSession – XDG_SESSION_TYPE branch', () { - test('returns false when XDG_SESSION_TYPE is x11', () { - if (!Platform.isLinux) return; - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - if (sessionType != 'x11') return; // only meaningful on X11 session - expect(isWaylandSession(), isFalse); - }); - - test('returns true when XDG_SESSION_TYPE is wayland', () { - if (!Platform.isLinux) return; - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - if (sessionType != 'wayland') { - return; // only meaningful on Wayland session - } - expect(isWaylandSession(), isTrue); - }); - - test('returns false in typical headless X11 CI environment', () { - if (!Platform.isLinux) return; - final isWaylandEnv = - Platform.environment['XDG_SESSION_TYPE'] == 'wayland' || - (Platform.environment['WAYLAND_DISPLAY'] ?? '').isNotEmpty; - if (isWaylandEnv) return; // skip on actual Wayland - expect(isWaylandSession(), isFalse); - }); - }); - - group('isWaylandSession – WAYLAND_DISPLAY branch', () { - test('returns true when WAYLAND_DISPLAY is set (no XDG_SESSION_TYPE)', () { - if (!Platform.isLinux) return; - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - // Only verify when we're in this exact scenario - if (sessionType.isEmpty && waylandDisplay.isNotEmpty) { - expect(isWaylandSession(), isTrue); - } - }); - - test('returns false when DISPLAY is set and no wayland indicators', () { - if (!Platform.isLinux) return; - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - final display = Platform.environment['DISPLAY'] ?? ''; - // When no wayland indicators exist but X11 DISPLAY is set - if (sessionType.isEmpty && waylandDisplay.isEmpty && display.isNotEmpty) { - expect(isWaylandSession(), isFalse); - } - }); - }); - - group('isWaylandSession – consistency', () { - test('result is always a bool', () { - expect(isWaylandSession(), isA()); - }); - - test('is idempotent across 10 consecutive calls', () { - final first = isWaylandSession(); - for (var i = 0; i < 9; i++) { - expect( - isWaylandSession(), - equals(first), - reason: 'call ${i + 2} diverged from first result', - ); - } - }); - - test('result is consistent with environment variable state', () { - if (!Platform.isLinux) return; - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - - final envSaysWayland = - sessionType == 'wayland' || waylandDisplay.isNotEmpty; - final envSaysX11 = sessionType == 'x11' || sessionType == 'mir'; - - if (envSaysWayland) { - expect(isWaylandSession(), isTrue); - } else if (envSaysX11) { - expect(isWaylandSession(), isFalse); - } - // If neither is set, result depends on DISPLAY / socket scan — we just - // verify it returns a bool without asserting the direction. - }); - }); - - // --------------------------------------------------------------------------- - // linuxPrefersDarkMode – behaviour and error-safety - // --------------------------------------------------------------------------- - group('linuxPrefersDarkMode – non-Linux', () { - test('returns false on non-Linux platforms', () async { - if (Platform.isLinux) return; - expect(await linuxPrefersDarkMode(), isFalse); - }); - - test('return type is bool on non-Linux', () async { - if (Platform.isLinux) return; - expect(await linuxPrefersDarkMode(), isA()); - }); - }); - - group('linuxPrefersDarkMode – Linux behaviour', () { - test('completes without throwing', () async { - if (!Platform.isLinux) return; - await expectLater(linuxPrefersDarkMode(), completes); - }); - - test('returns a bool', () async { - final result = await linuxPrefersDarkMode(); - expect(result, isA()); - }); - - test('completes within 15 seconds (process spawn + timeout)', () async { - if (!Platform.isLinux) return; - final result = await linuxPrefersDarkMode().timeout( - const Duration(seconds: 15), - onTimeout: () => fail('linuxPrefersDarkMode did not complete in time'), - ); - expect(result, isA()); - }); - - test('multiple calls return consistent result', () async { - final first = await linuxPrefersDarkMode(); - final second = await linuxPrefersDarkMode(); - expect(first, equals(second)); - }); - - test('GTK_THEME env absent → result is false or gsettings-driven', () async { - if (!Platform.isLinux) return; - final gtkTheme = (Platform.environment['GTK_THEME'] ?? '').toLowerCase(); - final result = await linuxPrefersDarkMode(); - if (gtkTheme.isEmpty) { - // No GTK_THEME set — result comes from gsettings (or false if unavailable) - expect(result, isA()); - } else if (gtkTheme.contains('dark')) { - // Fallback: GTK_THEME says dark - expect(result, isA()); // may be true if gsettings also agrees - } else { - // GTK_THEME present but not dark; unless gsettings says dark, expect false - expect(result, isA()); - } - }); - - test('returns false in headless CI where gsettings is unavailable', () async { - if (!Platform.isLinux) return; - // In headless CI: no gsettings schema, GTK_THEME not set → should be false - final gtkTheme = (Platform.environment['GTK_THEME'] ?? '').toLowerCase(); - final display = Platform.environment['DISPLAY'] ?? ''; - final isHeadless = display.isEmpty; - - if (isHeadless && !gtkTheme.contains('dark')) { - expect(await linuxPrefersDarkMode(), isFalse); - } - }); - }); -} diff --git a/app/test/shell/linux_session_test.dart b/app/test/shell/linux_session_test.dart deleted file mode 100644 index eabad8e2..00000000 --- a/app/test/shell/linux_session_test.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/linux_session.dart'; - -void main() { - group('isWaylandSession', () { - test('returns false on non-Linux platforms', () { - if (Platform.isLinux) return; - expect(isWaylandSession(), isFalse); - }); - - test('is consistent with current environment variables', () { - if (!Platform.isLinux) { - expect(isWaylandSession(), isFalse); - return; - } - - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - - if (sessionType == 'wayland' || waylandDisplay.isNotEmpty) { - expect(isWaylandSession(), isTrue); - } - }); - - test('returns false on headless / X11 CI environment', () { - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - - final hasEnvIndicator = - sessionType == 'wayland' || waylandDisplay.isNotEmpty; - - if (!hasEnvIndicator && Platform.isLinux) { - expect(isWaylandSession(), isA()); - } - }); - - test('return type is bool', () { - expect(isWaylandSession(), isA()); - }); - - test('is idempotent — same result on repeated calls', () { - expect(isWaylandSession(), equals(isWaylandSession())); - }); - }); - - group('linuxPrefersDarkMode', () { - test('returns a bool', () async { - expect(await linuxPrefersDarkMode(), isA()); - }); - - test('returns false on non-Linux platforms', () async { - if (Platform.isLinux) return; - expect(await linuxPrefersDarkMode(), isFalse); - }); - }); - - group('LinuxSessionInfo', () { - test('unsupported is the safe default for non-Linux', () { - if (Platform.isLinux) return; - final info = detectLinuxSession(); - expect(info, equals(LinuxSessionInfo.unsupported)); - expect(info.isWayland, isFalse); - expect(info.isX11, isFalse); - expect(info.isUsable, isFalse); - }); - - test('detectLinuxSession returns a value type', () { - expect(detectLinuxSession(), isA()); - }); - - test('isWayland prioritises XDG_SESSION_TYPE=wayland', () { - const info = LinuxSessionInfo( - sessionType: 'wayland', - hasDisplay: true, - hasWaylandDisplay: true, - hasWaylandSocket: true, - desktopEnv: 'GNOME', - wmName: '', - ); - expect(info.isWayland, isTrue); - expect(info.isX11, isFalse); - expect(info.isXWayland, isTrue); - }); - - test('isX11 honours XDG_SESSION_TYPE=x11 even with Wayland socket', () { - const info = LinuxSessionInfo( - sessionType: 'x11', - hasDisplay: true, - hasWaylandDisplay: false, - hasWaylandSocket: true, - desktopEnv: 'KDE', - wmName: '', - ); - expect(info.isX11, isTrue); - expect(info.isWayland, isFalse); - }); - - test('empty sessionType + WAYLAND_DISPLAY set => Wayland', () { - const info = LinuxSessionInfo( - sessionType: '', - hasDisplay: true, - hasWaylandDisplay: true, - hasWaylandSocket: true, - desktopEnv: '', - wmName: '', - ); - expect(info.isWayland, isTrue); - expect(info.isX11, isFalse); - }); - - test('empty sessionType + only DISPLAY => X11', () { - const info = LinuxSessionInfo( - sessionType: '', - hasDisplay: true, - hasWaylandDisplay: false, - hasWaylandSocket: false, - desktopEnv: '', - wmName: '', - ); - expect(info.isX11, isTrue); - expect(info.isWayland, isFalse); - }); - - test('TTY / headless => neither X11 nor Wayland', () { - const info = LinuxSessionInfo( - sessionType: 'tty', - hasDisplay: false, - hasWaylandDisplay: false, - hasWaylandSocket: false, - desktopEnv: '', - wmName: '', - ); - expect(info.isUsable, isFalse); - }); - - test('isWaylandSession is a derived alias of detectLinuxSession', () { - expect(isWaylandSession(), equals(detectLinuxSession().isWayland)); - }); - - test('equality and hashCode work for value type', () { - const a = LinuxSessionInfo( - sessionType: 'x11', - hasDisplay: true, - hasWaylandDisplay: false, - hasWaylandSocket: false, - desktopEnv: 'GNOME', - wmName: 'gnome', - ); - const b = LinuxSessionInfo( - sessionType: 'x11', - hasDisplay: true, - hasWaylandDisplay: false, - hasWaylandSocket: false, - desktopEnv: 'GNOME', - wmName: 'gnome', - ); - expect(a, equals(b)); - expect(a.hashCode, equals(b.hashCode)); - }); - }); -} diff --git a/app/test/shell/linux_shell_test.dart b/app/test/shell/linux_shell_test.dart deleted file mode 100644 index 3b3271cd..00000000 --- a/app/test/shell/linux_shell_test.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'dart:async'; - -import 'package:copypaste/shell/linux_shell.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const eventChannelName = 'copypaste/linux_shell/events'; - StreamController? controller; - - Future emit(Object event) async { - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - final data = const StandardMethodCodec().encodeSuccessEnvelope(event); - await messenger.handlePlatformMessage(eventChannelName, data, (_) {}); - } - - setUp(() { - controller = StreamController.broadcast(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockStreamHandler( - const EventChannel(eventChannelName), - MockStreamHandler.inline( - onListen: (_, sink) { - controller!.stream.listen(sink.success); - }, - onCancel: (_) {}, - ), - ); - }); - - tearDown(() async { - await LinuxShell.dispose(); - await controller?.close(); - controller = null; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockStreamHandler(const EventChannel(eventChannelName), null); - }); - - group('LinuxShell.awaitEvent', () { - test('completes true when matching event arrives', () async { - final future = LinuxShell.awaitEvent( - 'unmapped', - timeout: const Duration(seconds: 1), - ); - await Future.delayed(const Duration(milliseconds: 20)); - await emit({'type': 'unmapped'}); - expect(await future, isTrue); - }); - - test('completes false on timeout when event never arrives', () async { - final result = await LinuxShell.awaitEvent( - 'unmapped', - timeout: const Duration(milliseconds: 50), - ); - expect(result, isFalse); - }); - - test('ignores non-matching events and times out', () async { - final future = LinuxShell.awaitEvent( - 'unmapped', - timeout: const Duration(milliseconds: 80), - ); - await Future.delayed(const Duration(milliseconds: 10)); - await emit({'type': 'mapped'}); - await emit({'type': 'hotkey'}); - expect(await future, isFalse); - }); - }); - - group('LinuxShell.getCursorMonitor', () { - const channel = MethodChannel('copypaste/linux_shell'); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - test('parses Map response into CursorMonitorInfo', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method != 'getCursorMonitor') return null; - return { - 'cursorX': 800.0, - 'cursorY': 450.0, - 'x': 0.0, - 'y': 0.0, - 'width': 1920.0, - 'height': 1080.0, - 'scaleFactor': 2.0, - }; - }); - final info = await LinuxShell.getCursorMonitor(); - expect(info, isNotNull); - expect(info!.cursorX, equals(800.0)); - expect(info.width, equals(1920.0)); - expect(info.scaleFactor, equals(2.0)); - }); - - test('returns null when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); - expect(await LinuxShell.getCursorMonitor(), isNull); - }); - }); - - group('LinuxShell.registerHotkey', () { - const channel = MethodChannel('copypaste/linux_shell'); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - test('passes the hotkey id to the native shell', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return {'success': true}; - }); - - final response = await LinuxShell.registerHotkey( - id: 'plainPaste', - virtualKey: 0x56, - useCtrl: true, - useWin: false, - useAlt: true, - useShift: false, - ); - - expect(response.success, isTrue); - expect(captured?.method, equals('registerHotkey')); - expect(captured?.arguments['id'], equals('plainPaste')); - }); - }); - - group('LinuxShell.getInputFocus', () { - const channel = MethodChannel('copypaste/linux_shell'); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - test('parses Map response into InputFocusInfo', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method != 'getInputFocus') return null; - return { - 'ownsFocus': true, - 'focusWindow': 0xabc, - 'ownWindow': 0xabc, - }; - }); - final info = await LinuxShell.getInputFocus(); - expect(info, isNotNull); - expect(info!.ownsFocus, isTrue); - expect(info.focusWindow, equals(0xabc)); - }); - - test('returns null when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); - expect(await LinuxShell.getInputFocus(), isNull); - }); - }); -} diff --git a/app/test/shell/single_instance_test.dart b/app/test/shell/single_instance_test.dart index 3cffbf36..6d87157c 100644 --- a/app/test/shell/single_instance_test.dart +++ b/app/test/shell/single_instance_test.dart @@ -172,31 +172,31 @@ void main() { }); }); - group('SingleInstance – Unix (macOS / Linux)', () { + group('SingleInstance – Unix (macOS)', () { setUp(() { - if (!Platform.isMacOS && !Platform.isLinux) return; + if (Platform.isWindows) return; SingleInstance.release(); }); tearDown(() { - if (!Platform.isMacOS && !Platform.isLinux) return; + if (Platform.isWindows) return; SingleInstance.release(); }); test('acquire() returns true on first call', () { - if (!Platform.isMacOS && !Platform.isLinux) return; + if (Platform.isWindows) return; expect(SingleInstance.acquire(), isTrue); }); test('acquire() creates the lock file', () { - if (!Platform.isMacOS && !Platform.isLinux) return; + 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.isMacOS && !Platform.isLinux) return; + if (Platform.isWindows) return; SingleInstance.acquire(); SingleInstance.release(); final lockPath = '${Directory.systemTemp.path}/copypaste.lock'; @@ -204,14 +204,14 @@ void main() { }); test('can re-acquire after release', () { - if (!Platform.isMacOS && !Platform.isLinux) return; + 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.isMacOS && !Platform.isLinux) return; + if (Platform.isWindows) return; SingleInstance.release(); SingleInstance.release(); // After double release, re-acquire must still work @@ -219,7 +219,7 @@ void main() { }); test('lock file contains the process pid', () { - if (!Platform.isMacOS && !Platform.isLinux) return; + if (Platform.isWindows) return; SingleInstance.acquire(); final lockPath = '${Directory.systemTemp.path}/copypaste.lock'; final content = File(lockPath).readAsStringSync().trim(); diff --git a/app/test/shell/startup_helper_linux_test.dart b/app/test/shell/startup_helper_linux_test.dart deleted file mode 100644 index 75876c2d..00000000 --- a/app/test/shell/startup_helper_linux_test.dart +++ /dev/null @@ -1,180 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/linux_session.dart'; -import 'package:copypaste/shell/startup_helper.dart'; - -String _xdgConfigDir() { - final xdg = Platform.environment['XDG_CONFIG_HOME']; - if (xdg != null && xdg.startsWith('/')) return xdg; - final home = Platform.environment['HOME'] ?? '/tmp'; - return '$home/.config'; -} - -String _desktopPath() { - const appName = 'CopyPaste'; - return '${_xdgConfigDir()}/autostart/$appName.desktop'; -} - -void main() { - tearDown(() async { - if (!Platform.isLinux) return; - final f = File(_desktopPath()); - if (f.existsSync()) f.deleteSync(); - }); - - group('StartupHelper – Linux', () { - test('apply(true) creates the autostart .desktop file', () async { - if (!Platform.isLinux || isWaylandSession()) return; - - await StartupHelper.apply(true); - expect(File(_desktopPath()).existsSync(), isTrue); - }); - - test('.desktop file contains [Desktop Entry] header', () async { - if (!Platform.isLinux || isWaylandSession()) return; - - await StartupHelper.apply(true); - final content = File(_desktopPath()).readAsStringSync(); - expect(content, contains('[Desktop Entry]')); - }); - - test('.desktop file has Type=Application', () async { - if (!Platform.isLinux || isWaylandSession()) return; - - await StartupHelper.apply(true); - final content = File(_desktopPath()).readAsStringSync(); - expect(content, contains('Type=Application')); - }); - - test('.desktop file contains Name=CopyPaste', () async { - if (!Platform.isLinux || isWaylandSession()) return; - - await StartupHelper.apply(true); - final content = File(_desktopPath()).readAsStringSync(); - expect(content, contains('Name=CopyPaste')); - }); - - test( - '.desktop file contains Exec pointing to current executable', - () async { - if (!Platform.isLinux || isWaylandSession()) return; - - await StartupHelper.apply(true); - final content = File(_desktopPath()).readAsStringSync(); - expect(content, contains('Exec=${Platform.resolvedExecutable}')); - }, - ); - - test('.desktop file has X-GNOME-Autostart-enabled=true', () async { - if (!Platform.isLinux || isWaylandSession()) return; - - await StartupHelper.apply(true); - final content = File(_desktopPath()).readAsStringSync(); - expect(content, contains('X-GNOME-Autostart-enabled=true')); - }); - - test('.desktop file has Terminal=false', () async { - if (!Platform.isLinux || isWaylandSession()) return; - - await StartupHelper.apply(true); - final content = File(_desktopPath()).readAsStringSync(); - expect(content, contains('Terminal=false')); - }); - - test('apply(false) removes the autostart .desktop file', () async { - if (!Platform.isLinux || isWaylandSession()) return; - - await StartupHelper.apply(true); - expect(File(_desktopPath()).existsSync(), isTrue); - await StartupHelper.apply(false); - expect(File(_desktopPath()).existsSync(), isFalse); - }); - - test( - 'apply(false) does not throw when .desktop file does not exist', - () async { - if (!Platform.isLinux) return; - - final f = File(_desktopPath()); - if (f.existsSync()) f.deleteSync(); - await expectLater(StartupHelper.apply(false), completes); - }, - ); - - test('apply(true) overwrites an existing .desktop file', () async { - if (!Platform.isLinux || isWaylandSession()) return; - - await StartupHelper.apply(true); - final first = File(_desktopPath()).lastModifiedSync(); - - await Future.delayed(const Duration(milliseconds: 5)); - await StartupHelper.apply(true); - final second = File(_desktopPath()).lastModifiedSync(); - - expect(second.isAtSameMomentAs(first) || second.isAfter(first), isTrue); - }); - - test('.desktop file is placed in the XDG autostart dir', () async { - if (!Platform.isLinux || isWaylandSession()) return; - - await StartupHelper.apply(true); - final expectedDir = '${_xdgConfigDir()}/autostart'; - expect(File(_desktopPath()).parent.path, equals(expectedDir)); - }); - }); - - group('StartupHelper – Linux Wayland skip', () { - test( - 'apply(true) does NOT create .desktop file on Wayland session', - () async { - if (!Platform.isLinux) return; - if (!isWaylandSession()) return; // only meaningful on Wayland - - final f = File(_desktopPath()); - if (f.existsSync()) f.deleteSync(); - - await StartupHelper.apply(true); - - expect( - f.existsSync(), - isFalse, - reason: 'Autostart must be skipped on Wayland', - ); - }, - ); - - test( - 'apply(true) removes existing .desktop file on Wayland session', - () async { - if (!Platform.isLinux) return; - if (!isWaylandSession()) return; - - // Pre-create the file to simulate a stale entry from a previous X11 session. - final f = File(_desktopPath()); - f.parent.createSync(recursive: true); - f.writeAsStringSync('[Desktop Entry]\nType=Application\n'); - - await StartupHelper.apply(true); - - expect( - f.existsSync(), - isFalse, - reason: 'Stale autostart entry must be removed on Wayland', - ); - }, - ); - - test( - 'on X11 session, apply(true) creates the .desktop file normally', - () async { - if (!Platform.isLinux) return; - if (isWaylandSession()) return; // skip on Wayland - - await StartupHelper.apply(true); - expect(File(_desktopPath()).existsSync(), isTrue); - }, - ); - }); -} diff --git a/app/test/shell/startup_helper_test.dart b/app/test/shell/startup_helper_test.dart index 4b7a95ad..c9569120 100644 --- a/app/test/shell/startup_helper_test.dart +++ b/app/test/shell/startup_helper_test.dart @@ -100,5 +100,5 @@ void main() { isTrue, ); }); - }); + }, skip: !Platform.isMacOS ? 'macOS-only' : null); } diff --git a/app/test/shell/wayland_detection_test.dart b/app/test/shell/wayland_detection_test.dart deleted file mode 100644 index 2d947085..00000000 --- a/app/test/shell/wayland_detection_test.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/main.dart' show isWaylandSession; - -void main() { - group('isWaylandSession', () { - test('returns false on non-Linux platforms', () { - if (Platform.isLinux) return; - expect(isWaylandSession(), isFalse); - }); - - test( - 'returns false when neither env var is set (typical X11 / headless)', - () { - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - - final expected = sessionType == 'wayland' || waylandDisplay.isNotEmpty; - if (!expected) { - expect(isWaylandSession(), isFalse); - } - }, - ); - }); -} diff --git a/codecov.yml b/codecov.yml index 83fcf0d5..330d295c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,6 +1,17 @@ coverage: precision: 2 round: down + status: + project: + default: + target: auto + threshold: 0% + # Los archivos borrados salen también de la base, así que retirar + # código cubierto no cuenta como caída de cobertura. + removed_code_behavior: adjust_base + patch: + default: + target: auto ignore: - "core/lib/repository/sqlite_repository.g.dart" - "app/lib/l10n/app_localizations_en.dart" @@ -11,8 +22,12 @@ coverage: - "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/lib/config/app_config.dart b/core/lib/config/app_config.dart index 77d9345d..6e27424b 100644 --- a/core/lib/config/app_config.dart +++ b/core/lib/config/app_config.dart @@ -46,22 +46,19 @@ class AppConfig { this.accessibilityWasGranted = false, this.lastRunVersion = '', this.hasSeenOnboarding = false, - this.hasCompletedOnboarding = false, this.generateImageThumbnails = true, this.generateVideoThumbnails = true, this.generateAudioThumbnails = true, this.maxImageProcessingSizeMB = 25, this.imagesQuotaMB = 0, - this.linuxAppindicatorWarningDismissed = false, - this.linuxXtestWarningDismissed = false, 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 Linux, where the Windows branches - /// would otherwise never execute. + /// 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'; @@ -262,11 +259,6 @@ class AppConfig { json['hasSeenOnboarding'] as bool? ?? json['hasSeenWindowsOnboarding'] as bool? ?? defaults.hasSeenOnboarding, - hasCompletedOnboarding: - json['hasCompletedOnboarding'] as bool? ?? - (json['hasSeenOnboarding'] as bool? ?? - json['hasSeenWindowsOnboarding'] as bool? ?? - defaults.hasCompletedOnboarding), generateImageThumbnails: json['generateImageThumbnails'] as bool? ?? defaults.generateImageThumbnails, @@ -280,12 +272,6 @@ class AppConfig { json['maxImageProcessingSizeMB'] as int? ?? defaults.maxImageProcessingSizeMB, imagesQuotaMB: json['imagesQuotaMB'] as int? ?? defaults.imagesQuotaMB, - linuxAppindicatorWarningDismissed: - json['linuxAppindicatorWarningDismissed'] as bool? ?? - defaults.linuxAppindicatorWarningDismissed, - linuxXtestWarningDismissed: - json['linuxXtestWarningDismissed'] as bool? ?? - defaults.linuxXtestWarningDismissed, rememberWindowPosition: json['rememberWindowPosition'] as bool? ?? defaults.rememberWindowPosition, @@ -332,18 +318,6 @@ class AppConfig { plainPasteHotkeyUseAlt: true, plainPasteHotkeyUseShift: true, ), - // Super+V is the desktop-oriented history gesture. Ctrl+Shift+V remains - // available to terminals because the optional global binding is disabled. - 'linux' => const AppConfig( - hotkeyUseCtrl: false, - hotkeyUseWin: true, - hotkeyUseAlt: false, - hotkeyUseShift: false, - plainPasteHotkeyEnabled: false, - plainPasteHotkeyUseCtrl: true, - plainPasteHotkeyUseShift: true, - plainPasteHotkeyUseAlt: false, - ), _ => const AppConfig(), }; @@ -405,7 +379,6 @@ class AppConfig { final bool accessibilityWasGranted; final String lastRunVersion; final bool hasSeenOnboarding; - final bool hasCompletedOnboarding; // Multimedia & thumbnails final bool generateImageThumbnails; @@ -418,10 +391,6 @@ class AppConfig { // owned bytes drop back below the limit. Pinned items are never purged. final int imagesQuotaMB; - // Linux capability warning banners (dismissible). - final bool linuxAppindicatorWarningDismissed; - final bool linuxXtestWarningDismissed; - final bool rememberWindowPosition; final double? lastWindowX; final double? lastWindowY; @@ -466,14 +435,11 @@ class AppConfig { bool? accessibilityWasGranted, String? lastRunVersion, bool? hasSeenOnboarding, - bool? hasCompletedOnboarding, bool? generateImageThumbnails, bool? generateVideoThumbnails, bool? generateAudioThumbnails, int? maxImageProcessingSizeMB, int? imagesQuotaMB, - bool? linuxAppindicatorWarningDismissed, - bool? linuxXtestWarningDismissed, bool? rememberWindowPosition, Object? lastWindowX = _sentinel, Object? lastWindowY = _sentinel, @@ -529,8 +495,6 @@ class AppConfig { accessibilityWasGranted ?? this.accessibilityWasGranted, lastRunVersion: lastRunVersion ?? this.lastRunVersion, hasSeenOnboarding: hasSeenOnboarding ?? this.hasSeenOnboarding, - hasCompletedOnboarding: - hasCompletedOnboarding ?? this.hasCompletedOnboarding, generateImageThumbnails: generateImageThumbnails ?? this.generateImageThumbnails, generateVideoThumbnails: @@ -540,11 +504,6 @@ class AppConfig { maxImageProcessingSizeMB: maxImageProcessingSizeMB ?? this.maxImageProcessingSizeMB, imagesQuotaMB: imagesQuotaMB ?? this.imagesQuotaMB, - linuxAppindicatorWarningDismissed: - linuxAppindicatorWarningDismissed ?? - this.linuxAppindicatorWarningDismissed, - linuxXtestWarningDismissed: - linuxXtestWarningDismissed ?? this.linuxXtestWarningDismissed, rememberWindowPosition: rememberWindowPosition ?? this.rememberWindowPosition, lastWindowX: lastWindowX == _sentinel @@ -598,14 +557,11 @@ class AppConfig { 'accessibilityWasGranted': accessibilityWasGranted, 'lastRunVersion': lastRunVersion, 'hasSeenOnboarding': hasSeenOnboarding, - 'hasCompletedOnboarding': hasCompletedOnboarding, 'generateImageThumbnails': generateImageThumbnails, 'generateVideoThumbnails': generateVideoThumbnails, 'generateAudioThumbnails': generateAudioThumbnails, 'maxImageProcessingSizeMB': maxImageProcessingSizeMB, 'imagesQuotaMB': imagesQuotaMB, - 'linuxAppindicatorWarningDismissed': linuxAppindicatorWarningDismissed, - 'linuxXtestWarningDismissed': linuxXtestWarningDismissed, 'rememberWindowPosition': rememberWindowPosition, if (lastWindowX != null) 'lastWindowX': lastWindowX, if (lastWindowY != null) 'lastWindowY': lastWindowY, diff --git a/core/lib/config/storage_config.dart b/core/lib/config/storage_config.dart index e1a21730..2e89f3e5 100644 --- a/core/lib/config/storage_config.dart +++ b/core/lib/config/storage_config.dart @@ -49,29 +49,6 @@ class StorageConfig { for (final dir in [baseDir, imagesPath, configPath, logsPath]) { await Directory(dir).create(recursive: true); } - await _restrictToOwner(); - } - - /// The history is stored in the clear, and on Linux `~/.local/share` inherits - /// the umask (0755), so other local accounts can read whatever was copied. - /// Closing the directories is enough and stays O(1): POSIX resolves a path - /// through every parent, so 0700 here puts the files out of reach whatever - /// mode SQLite gave them. Windows and macOS already confine the container. - Future _restrictToOwner() async { - if (!Platform.isLinux) return; - try { - await Process.run('chmod', [ - '700', - baseDir, - imagesPath, - configPath, - logsPath, - ], runInShell: false); - // coverage:ignore-start - } catch (e) { - AppLogger.warn('Could not restrict permissions on $baseDir: $e'); - // coverage:ignore-end - } } bool get isFirstRun => !File(_initFlagPath).existsSync(); diff --git a/core/lib/services/cleanup_service.dart b/core/lib/services/cleanup_service.dart index 570a3159..a7e25085 100644 --- a/core/lib/services/cleanup_service.dart +++ b/core/lib/services/cleanup_service.dart @@ -430,7 +430,7 @@ class CleanupService { } return true; } - // Linux / others: best-effort; mount discovery is out of scope. Treat + // 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 (_) { diff --git a/core/lib/services/native_thumbnail_provider.dart b/core/lib/services/native_thumbnail_provider.dart index 47fe1620..e850cf9b 100644 --- a/core/lib/services/native_thumbnail_provider.dart +++ b/core/lib/services/native_thumbnail_provider.dart @@ -2,8 +2,8 @@ import 'dart:typed_data'; /// Contract for OS-backed thumbnail providers. Implementations request a /// thumbnail bitmap from the native shell (Windows `IShellItemImageFactory`, -/// macOS `QLThumbnailGenerator`, Linux `Tumbler`) and return the encoded -/// PNG bytes ready to be written to disk. +/// 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). diff --git a/core/lib/services/support_service.dart b/core/lib/services/support_service.dart index be53bf92..3580001b 100644 --- a/core/lib/services/support_service.dart +++ b/core/lib/services/support_service.dart @@ -96,10 +96,8 @@ class SupportService { await Process.run('explorer', ['/select,$filePath']); } else if (Platform.isMacOS) { await Process.run('open', ['-R', filePath]); - } else // coverage:ignore-end - if (Platform.isLinux) { - await Process.run('xdg-open', [File(filePath).parent.path]); } + // coverage:ignore-end } catch (e, s) { AppLogger.exception(e, s, 'revealFile'); } @@ -124,10 +122,8 @@ class SupportService { await Process.run('cmd', ['/c', 'start', '', logsDir.path]); } else if (Platform.isMacOS) { await Process.run('open', [logsDir.path]); - } else // coverage:ignore-end - if (Platform.isLinux) { - await Process.run('xdg-open', [logsDir.path]); } + // coverage:ignore-end } catch (e, s) { AppLogger.exception(e, s, 'openLogsFolder'); rethrow; diff --git a/core/lib/services/thumbnail_service.dart b/core/lib/services/thumbnail_service.dart index e93a356a..d69aa536 100644 --- a/core/lib/services/thumbnail_service.dart +++ b/core/lib/services/thumbnail_service.dart @@ -31,8 +31,8 @@ class ThumbnailResult { /// Two paths: /// 1. **Native** (preferred when `nativeProvider` is set): asks the OS /// shell for a cached thumbnail (Win `IShellItemImageFactory`, -/// macOS `QLThumbnailGenerator`, Linux `Tumbler`). Covers -/// [ClipboardContentType.image], [video] and [audio] (cover art). +/// 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]. diff --git a/core/test/app_config_test.dart b/core/test/app_config_test.dart index 88e2d8e0..8c95aa72 100644 --- a/core/test/app_config_test.dart +++ b/core/test/app_config_test.dart @@ -139,15 +139,6 @@ void main() { expect(macos.plainPasteHotkeyUseAlt, isTrue); expect(macos.plainPasteHotkeyUseShift, isTrue); expect(macos.plainPasteHotkeyUseCtrl, isTrue); - - final linux = AppConfig.defaultForPlatform('linux'); - expect(linux.hotkeyUseWin, isTrue); - expect(linux.hotkeyUseCtrl, isFalse); - expect(linux.hotkeyUseAlt, isFalse); - expect(linux.hotkeyUseShift, isFalse); - expect(linux.plainPasteHotkeyUseCtrl, isTrue); - expect(linux.plainPasteHotkeyUseShift, isTrue); - expect(linux.plainPasteHotkeyUseAlt, isFalse); }); test('legacy JSON does not silently enable the new global hotkey', () { @@ -517,38 +508,6 @@ void main() { ); }); - test('linux capability dismiss flags default to false', () { - const config = AppConfig(); - expect(config.linuxAppindicatorWarningDismissed, isFalse); - expect(config.linuxXtestWarningDismissed, isFalse); - }); - - test('linux capability dismiss flags round-trip via JSON', () { - const config = AppConfig( - linuxAppindicatorWarningDismissed: true, - linuxXtestWarningDismissed: true, - ); - final restored = AppConfig.fromJson(config.toJson()); - expect(restored.linuxAppindicatorWarningDismissed, isTrue); - expect(restored.linuxXtestWarningDismissed, isTrue); - }); - - test('copyWith updates linux capability dismiss flags individually', () { - const config = AppConfig(); - expect( - config - .copyWith(linuxAppindicatorWarningDismissed: true) - .linuxAppindicatorWarningDismissed, - isTrue, - ); - expect( - config - .copyWith(linuxXtestWarningDismissed: true) - .linuxXtestWarningDismissed, - isTrue, - ); - }); - test('toJson omits lastBackupDateUtc when null', () { const config = AppConfig(); expect(config.toJson().containsKey('lastBackupDateUtc'), isFalse); @@ -874,11 +833,9 @@ void main() { }); test('defaultForPlatform returns platform-specific hotkeys', () { - final linux = AppConfig.defaultForPlatform('linux'); final macos = AppConfig.defaultForPlatform('macos'); final windows = AppConfig.defaultForPlatform('windows'); - expect(linux.hotkeyUseWin, isTrue); expect(macos.plainPasteHotkeyUseWin, isTrue); expect(windows.hotkeyKeyName, equals('C')); }); @@ -928,7 +885,6 @@ void main() { group('AppConfig PR #10 fields (thumbnails / onboarding / image cap)', () { test('default values', () { const c = AppConfig(); - expect(c.hasCompletedOnboarding, isFalse); expect(c.generateImageThumbnails, isTrue); expect(c.generateVideoThumbnails, isTrue); expect(c.generateAudioThumbnails, isTrue); @@ -937,14 +893,12 @@ void main() { test('JSON round-trip preserves new fields', () { const c = AppConfig( - hasCompletedOnboarding: true, generateImageThumbnails: false, generateVideoThumbnails: false, generateAudioThumbnails: false, maxImageProcessingSizeMB: 5, ); final restored = AppConfig.fromJson(c.toJson()); - expect(restored.hasCompletedOnboarding, isTrue); expect(restored.generateImageThumbnails, isFalse); expect(restored.generateVideoThumbnails, isFalse); expect(restored.generateAudioThumbnails, isFalse); @@ -954,24 +908,14 @@ void main() { test('copyWith updates each new field independently', () { const c = AppConfig(); final u = c.copyWith( - hasCompletedOnboarding: true, generateImageThumbnails: false, maxImageProcessingSizeMB: 10, ); - expect(u.hasCompletedOnboarding, isTrue); expect(u.generateImageThumbnails, isFalse); expect(u.generateVideoThumbnails, isTrue); // unchanged expect(u.maxImageProcessingSizeMB, equals(10)); }); - test( - 'hasCompletedOnboarding migrates from legacy hasSeenWindowsOnboarding', - () { - final c = AppConfig.fromJson({'hasSeenWindowsOnboarding': true}); - expect(c.hasCompletedOnboarding, isTrue); - }, - ); - test('hasSeenOnboarding migrates from legacy hasSeenWindowsOnboarding', () { final c = AppConfig.fromJson({'hasSeenWindowsOnboarding': true}); expect(c.hasSeenOnboarding, isTrue); @@ -986,29 +930,12 @@ void main() { }); test( - 'both hasSeenOnboarding and hasCompletedOnboarding populated from legacy', - () { - final c = AppConfig.fromJson({'hasSeenWindowsOnboarding': true}); - expect(c.hasSeenOnboarding, isTrue); - expect(c.hasCompletedOnboarding, isTrue); - }, - ); - - test( - 'hasCompletedOnboarding stays false when neither legacy nor new is set', + 'hasSeenOnboarding stays false when neither legacy nor new is set', () { final c = AppConfig.fromJson({}); - expect(c.hasCompletedOnboarding, isFalse); + expect(c.hasSeenOnboarding, isFalse); }, ); - - test('explicit hasCompletedOnboarding overrides legacy', () { - final c = AppConfig.fromJson({ - 'hasSeenWindowsOnboarding': true, - 'hasCompletedOnboarding': false, - }); - expect(c.hasCompletedOnboarding, isFalse); - }); }); group('AppConfig PR #9 field (keepBrokenItemsDays)', () { diff --git a/core/test/clipboard_service_platform_test.dart b/core/test/clipboard_service_platform_test.dart index c24fbaa3..8e056f30 100644 --- a/core/test/clipboard_service_platform_test.dart +++ b/core/test/clipboard_service_platform_test.dart @@ -1,5 +1,5 @@ /// Integration tests that verify ClipboardService behaviour is identical -/// across Windows, macOS, and Linux — no platform-specific branching exists +/// 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; diff --git a/core/test/repository_search_integration_test.dart b/core/test/repository_search_integration_test.dart index a22227e3..56f3ed58 100644 --- a/core/test/repository_search_integration_test.dart +++ b/core/test/repository_search_integration_test.dart @@ -1,6 +1,6 @@ /// Cross-platform repository search integration tests. /// Verifies that FTS5, LIKE fallback, and Unicode normalization work correctly -/// across Windows, macOS, and Linux — all using the in-memory SQLite instance. +/// across Windows and macOS — all using the in-memory SQLite instance. library; import 'package:flutter_test/flutter_test.dart'; diff --git a/core/test/support_service_test.dart b/core/test/support_service_test.dart index 9dea286e..5ad13782 100644 --- a/core/test/support_service_test.dart +++ b/core/test/support_service_test.dart @@ -229,14 +229,6 @@ void main() { }); group('SupportService.revealFile', () { - test('completes without throwing on Linux', () async { - if (!Platform.isLinux) return; - final file = File(p.join(tempDir.path, 'reveal_test.log')) - ..writeAsStringSync('data'); - // xdg-open is called internally; exceptions are caught, so always completes - await expectLater(SupportService.revealFile(file.path), completes); - }); - test('completes without throwing when path is empty string', () async { // Platform checks guard the Process.run call; no spawn attempted for empty await expectLater(SupportService.revealFile(''), completes); @@ -249,19 +241,9 @@ void main() { try { await SupportService.openLogsFolder(storage); } catch (_) { - // xdg-open may not be available in headless CI; that's acceptable + // The shell opener may not be available in headless CI; that's acceptable } expect(Directory(storage.logsPath).existsSync(), isTrue); }); - - test('opens existing logs folder on Linux', () async { - if (!Platform.isLinux) return; - // xdg-open may fail in headless CI, but the function body is covered - try { - await SupportService.openLogsFolder(storage); - } catch (_) { - // ProcessException acceptable when no display server available - } - }); }); } diff --git a/core/test/thumbnail_queue_test.dart b/core/test/thumbnail_queue_test.dart index 7303ca99..99b5e386 100644 --- a/core/test/thumbnail_queue_test.dart +++ b/core/test/thumbnail_queue_test.dart @@ -114,7 +114,7 @@ void main() { // 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 Linux CI runners. + // slow CI runners. for (var i = 0; i < 100; i++) { if (queue.isIdle) { // One more pump so the `whenComplete` chain in `_scheduleNext` diff --git a/listener/.metadata b/listener/.metadata index 356a0c42..2667e966 100644 --- a/listener/.metadata +++ b/listener/.metadata @@ -15,9 +15,6 @@ migration: - platform: root create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - - platform: linux - create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - platform: macos create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 diff --git a/listener/lib/clipboard_writer.dart b/listener/lib/clipboard_writer.dart index f1726c44..34e1e3d6 100644 --- a/listener/lib/clipboard_writer.dart +++ b/listener/lib/clipboard_writer.dart @@ -203,6 +203,4 @@ class PasteResponse { final bool success; final String? errorCode; - - bool get isFocusTimeout => errorCode == 'focusTimeout'; } diff --git a/listener/lib/linux_native_thumbnail_provider.dart b/listener/lib/linux_native_thumbnail_provider.dart deleted file mode 100644 index f69edb93..00000000 --- a/listener/lib/linux_native_thumbnail_provider.dart +++ /dev/null @@ -1,21 +0,0 @@ -// coverage:ignore-file -import 'dart:io' show Platform; - -import 'base_native_thumbnail_provider.dart'; - -/// Linux-backed [BaseNativeThumbnailProvider]. The native handler uses -/// `gdk_pixbuf_new_from_file_at_size()` to decode the source and -/// `gdk_pixbuf_save_to_buffer(... "png")` to encode PNG bytes. -/// -/// GdkPixbuf natively decodes PNG/JPEG/BMP/GIF/TIFF/ICO, plus SVG (via -/// librsvg-loader). Video/audio frames are not handled here (would require -/// libavformat); the Dart fallback covers those (returns null → type icon). -class LinuxNativeThumbnailProvider extends BaseNativeThumbnailProvider { - LinuxNativeThumbnailProvider({super.channel}); - - @override - bool get isCurrentPlatform => Platform.isLinux; - - @override - String get debugLabel => 'LinuxNativeThumbnailProvider'; -} diff --git a/listener/lib/listener.dart b/listener/lib/listener.dart index b8a3d08f..5b4c8ac7 100644 --- a/listener/lib/listener.dart +++ b/listener/lib/listener.dart @@ -1,6 +1,5 @@ export 'clipboard_event.dart'; export 'clipboard_listener.dart'; export 'clipboard_writer.dart'; -export 'linux_native_thumbnail_provider.dart'; export 'macos_native_thumbnail_provider.dart'; export 'windows_native_thumbnail_provider.dart'; diff --git a/listener/linux/CMakeLists.txt b/listener/linux/CMakeLists.txt deleted file mode 100644 index e73cf3d1..00000000 --- a/listener/linux/CMakeLists.txt +++ /dev/null @@ -1,82 +0,0 @@ -cmake_minimum_required(VERSION 3.10) - -set(PROJECT_NAME "listener") -project(${PROJECT_NAME} LANGUAGES C CXX) - -set(PLUGIN_NAME "listener_plugin") - -list(APPEND PLUGIN_SOURCES - "listener_plugin.c" -) - -add_library(${PLUGIN_NAME} SHARED - ${PLUGIN_SOURCES} -) - -apply_standard_settings(${PLUGIN_NAME}) - -set_target_properties(${PLUGIN_NAME} PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) - -target_include_directories(${PLUGIN_NAME} INTERFACE - "${CMAKE_CURRENT_SOURCE_DIR}/include") -target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) -target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) - -find_package(PkgConfig REQUIRED) -pkg_check_modules(X11 IMPORTED_TARGET x11 xtst) -if(X11_FOUND) - target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::X11) -else() - message(WARNING "X11/XTest not found — clipboard source detection and paste-back disabled") -endif() - -set(listener_bundled_libraries - "" - PARENT_SCOPE -) - -# === Tests === -# These unit tests can be run from a terminal after building the example. - -# 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}) -if(${CMAKE_VERSION} VERSION_LESS "3.11.0") -message("Unit tests require CMake 3.11.0 or later") -else() -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 exported API is not very useful for unit testing, so build the -# sources directly into the test binary rather than using the shared library. -add_executable(${TEST_RUNNER} - test/listener_plugin_test.cc - ${PLUGIN_SOURCES} -) -apply_standard_settings(${TEST_RUNNER}) -target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") -target_link_libraries(${TEST_RUNNER} PRIVATE flutter) -target_link_libraries(${TEST_RUNNER} PRIVATE PkgConfig::GTK) -target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) - -# Enable automatic test discovery. -include(GoogleTest) -gtest_discover_tests(${TEST_RUNNER}) - -endif() # CMake version check -endif() # include_${PROJECT_NAME}_tests \ No newline at end of file diff --git a/listener/linux/include/listener/listener_plugin.h b/listener/linux/include/listener/listener_plugin.h deleted file mode 100644 index 21365cf8..00000000 --- a/listener/linux/include/listener/listener_plugin.h +++ /dev/null @@ -1,26 +0,0 @@ -#ifndef FLUTTER_PLUGIN_LISTENER_PLUGIN_H_ -#define FLUTTER_PLUGIN_LISTENER_PLUGIN_H_ - -#include - -G_BEGIN_DECLS - -#ifdef FLUTTER_PLUGIN_IMPL -#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) -#else -#define FLUTTER_PLUGIN_EXPORT -#endif - -typedef struct _ListenerPlugin ListenerPlugin; -typedef struct { - GObjectClass parent_class; -} ListenerPluginClass; - -FLUTTER_PLUGIN_EXPORT GType listener_plugin_get_type(); - -FLUTTER_PLUGIN_EXPORT void listener_plugin_register_with_registrar( - FlPluginRegistrar* registrar); - -G_END_DECLS - -#endif // FLUTTER_PLUGIN_LISTENER_PLUGIN_H_ diff --git a/listener/linux/listener_plugin.c b/listener/linux/listener_plugin.c deleted file mode 100644 index c874b1df..00000000 --- a/listener/linux/listener_plugin.c +++ /dev/null @@ -1,1328 +0,0 @@ -#include "include/listener/listener_plugin.h" - -#include -#include -#include -#include -#include - -#ifdef GDK_WINDOWING_X11 -#include -#include -#include -#include -#include -#include -#endif - -#include -#include -#include -#include -#include - -#include "listener_plugin_private.h" - -// Clipboard content type codes — must match Dart ClipboardDataType enum order. -#define CLIP_TYPE_TEXT 0 -#define CLIP_TYPE_IMAGE 1 -#define CLIP_TYPE_FILE 2 -#define CLIP_TYPE_FOLDER 3 -#define CLIP_TYPE_LINK 4 -#define CLIP_TYPE_AUDIO 5 -#define CLIP_TYPE_VIDEO 6 - -#define LISTENER_PLUGIN(obj) \ - (G_TYPE_CHECK_INSTANCE_CAST((obj), listener_plugin_get_type(), ListenerPlugin)) - -static const gchar* kClipboardChannelName = "copypaste/clipboard"; -static const gchar* kClipboardWriterChannelName = "copypaste/clipboard_writer"; -static const guint kClipboardPollIntervalMs = 1500; -static const guint kClipboardOwnerDebounceMs = 80; -static const guint64 kClipboardWriteIgnoreMs = 700; - -typedef struct { -#ifdef GDK_WINDOWING_X11 - Window window; -#else - unsigned long window; -#endif - gboolean valid; -} ActiveX11Window; - -struct _ListenerPlugin { - GObject parent_instance; - - FlEventChannel* event_channel; - FlMethodChannel* method_channel; - - gboolean is_listening; - guint poll_timer_id; - gulong owner_change_handler_id; - guint owner_debounce_timer_id; - gchar* last_content_hash; - guint64 last_change_tick_ms; - guint64 last_write_tick_ms; -}; - -G_DEFINE_TYPE(ListenerPlugin, listener_plugin, g_object_get_type()) - -static guint64 now_ms(void) { - return (guint64)(g_get_monotonic_time() / 1000); -} - -static gchar* compute_fnv1a_hash(const gchar* text) { - uint64_t hash = 14695981039346656037ULL; - const guchar* bytes = (const guchar*)text; - for (gsize i = 0; bytes[i] != 0; i++) { - hash ^= bytes[i]; - hash *= 1099511628211ULL; - } - return g_strdup_printf("%" G_GINT64_MODIFIER "x", (guint64)hash); -} - -static gboolean is_url_text(const gchar* text) { - if (text == NULL || *text == '\0') { - return FALSE; - } - - const gchar* prefixes[] = { - "https://", "http://", "ftp://", "file:///", "mailto:", NULL, - }; - - gchar* lower = g_ascii_strdown(text, -1); - gboolean matches = FALSE; - for (guint i = 0; prefixes[i] != NULL; i++) { - if (g_str_has_prefix(lower, prefixes[i])) { - matches = TRUE; - break; - } - } - g_free(lower); - - return matches && strchr(text, ' ') == NULL && strchr(text, '\n') == NULL; -} - -static int detect_file_type(const gchar* path) { - if (path == NULL || *path == '\0') { - return CLIP_TYPE_FILE; - } - - if (g_file_test(path, G_FILE_TEST_IS_DIR)) { - return CLIP_TYPE_FOLDER; - } - - gchar* lower = g_ascii_strdown(path, -1); - const gchar* ext = strrchr(lower, '.'); - int type = CLIP_TYPE_FILE; - - if (ext != NULL) { - if (g_strcmp0(ext, ".png") == 0 || g_strcmp0(ext, ".jpg") == 0 || - g_strcmp0(ext, ".jpeg") == 0 || g_strcmp0(ext, ".gif") == 0 || - g_strcmp0(ext, ".bmp") == 0 || g_strcmp0(ext, ".webp") == 0 || - g_strcmp0(ext, ".svg") == 0 || g_strcmp0(ext, ".ico") == 0 || - g_strcmp0(ext, ".tiff") == 0 || g_strcmp0(ext, ".heic") == 0) { - type = CLIP_TYPE_IMAGE; - } else if (g_strcmp0(ext, ".mp3") == 0 || g_strcmp0(ext, ".wav") == 0 || - g_strcmp0(ext, ".flac") == 0 || g_strcmp0(ext, ".aac") == 0 || - g_strcmp0(ext, ".ogg") == 0 || g_strcmp0(ext, ".m4a") == 0) { - type = CLIP_TYPE_AUDIO; - } else if (g_strcmp0(ext, ".mp4") == 0 || g_strcmp0(ext, ".avi") == 0 || - g_strcmp0(ext, ".mkv") == 0 || g_strcmp0(ext, ".mov") == 0 || - g_strcmp0(ext, ".wmv") == 0 || g_strcmp0(ext, ".flv") == 0 || - g_strcmp0(ext, ".webm") == 0) { - type = CLIP_TYPE_VIDEO; - } - } - - g_free(lower); - return type; -} - -static gboolean plugin_is_x11(void) { -#ifdef GDK_WINDOWING_X11 - GdkDisplay* display = gdk_display_get_default(); - return display != NULL && GDK_IS_X11_DISPLAY(display); -#else - return FALSE; -#endif -} - -#ifdef GDK_WINDOWING_X11 -// Cached X11 atoms — interned once per process. -static Atom s_atom_net_active_window = None; -static Atom s_atom_net_wm_pid = None; - -static Atom atom_net_active_window(Display* display) { - if (s_atom_net_active_window == None) { - s_atom_net_active_window = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); - } - return s_atom_net_active_window; -} - -static Atom atom_net_wm_pid(Display* display) { - if (s_atom_net_wm_pid == None) { - s_atom_net_wm_pid = XInternAtom(display, "_NET_WM_PID", False); - } - return s_atom_net_wm_pid; -} - -// XTest extension availability — checked once per process. -static gboolean s_xtest_checked = FALSE; -static gboolean s_xtest_available = FALSE; - -static gboolean ensure_xtest(Display* display) { - if (s_xtest_checked) { - return s_xtest_available; - } - s_xtest_checked = TRUE; - int event_base, error_base, major, minor; - s_xtest_available = XTestQueryExtension(display, &event_base, &error_base, - &major, &minor) != 0; - if (!s_xtest_available) { - g_warning("XTest extension not available — paste simulation disabled"); - } - return s_xtest_available; -} - -static Display* get_xdisplay(void) { - GdkDisplay* display = gdk_display_get_default(); - if (display == NULL || !GDK_IS_X11_DISPLAY(display)) { - return NULL; - } - - return gdk_x11_display_get_xdisplay(display); -} - -static ActiveX11Window get_active_x11_window(void) { - ActiveX11Window result = {0}; - Display* display = get_xdisplay(); - if (display == NULL) { - return result; - } - - Atom property = atom_net_active_window(display); - Atom actual_type = None; - int actual_format = 0; - unsigned long item_count = 0; - unsigned long bytes_after = 0; - unsigned char* data = NULL; - - if (XGetWindowProperty(display, DefaultRootWindow(display), property, 0, 1, - False, AnyPropertyType, &actual_type, &actual_format, - &item_count, &bytes_after, &data) == Success && - data != NULL && item_count == 1) { - result.window = *(Window*)data; - result.valid = result.window != 0; - } - - (void)actual_type; - (void)actual_format; - (void)bytes_after; - - if (data != NULL) { - XFree(data); - } - - return result; -} - -static gchar* read_proc_comm(unsigned long pid) { - gchar path[64]; - g_snprintf(path, sizeof(path), "/proc/%lu/comm", pid); - gchar* content = NULL; - gsize length = 0; - if (!g_file_get_contents(path, &content, &length, NULL) || content == NULL) { - return NULL; - } - - g_strchomp(content); - return content; -} - -static gchar* prettify_app_id(gchar* value) { - if (value == NULL || *value == '\0') { - return value; - } - guint dots = 0; - for (const gchar* p = value; *p != '\0'; p++) { - if (*p == '.') { - dots++; - } - } - if (dots < 2) { - return value; - } - const gchar* last = strrchr(value, '.'); - if (last == NULL || *(last + 1) == '\0') { - return value; - } - gchar* trimmed = g_strdup(last + 1); - g_free(value); - return trimmed; -} - -static gchar* get_x11_window_source(Window window) { - Display* display = get_xdisplay(); - if (display == NULL || window == 0) { - return g_strdup(""); - } - - XClassHint class_hint; - if (XGetClassHint(display, window, &class_hint) != 0) { - gchar* value = g_strdup(class_hint.res_class != NULL ? class_hint.res_class - : class_hint.res_name); - if (class_hint.res_name != NULL) { - XFree(class_hint.res_name); - } - if (class_hint.res_class != NULL) { - XFree(class_hint.res_class); - } - if (value != NULL && *value != '\0') { - return prettify_app_id(value); - } - g_free(value); - } - - Atom pid_atom = atom_net_wm_pid(display); - Atom actual_type = None; - int actual_format = 0; - unsigned long item_count = 0; - unsigned long bytes_after = 0; - unsigned char* data = NULL; - - if (XGetWindowProperty(display, window, pid_atom, 0, 1, False, - XA_CARDINAL, &actual_type, &actual_format, - &item_count, &bytes_after, &data) == Success && - data != NULL && item_count == 1) { - unsigned long pid = *(unsigned long*)data; - XFree(data); - data = NULL; - gchar* comm = read_proc_comm(pid); - if (comm != NULL) { - return prettify_app_id(comm); - } - } - - (void)actual_type; - (void)actual_format; - (void)bytes_after; - - if (data != NULL) { - XFree(data); - } - - return g_strdup(""); -} - -static gchar* capture_frontmost_x11_identifier(void) { - ActiveX11Window active = get_active_x11_window(); - if (!active.valid) { - return NULL; - } - - return g_strdup_printf("x11:0x%lx", (unsigned long)active.window); -} - -static int activate_noop_error_handler(Display* display, XErrorEvent* event) { - (void)display; - (void)event; - return 0; -} - -static FlValue* make_paste_result(gboolean success, const gchar* error_code) { - FlValue* result = fl_value_new_map(); - fl_value_set_string_take(result, "success", fl_value_new_bool(success)); - if (error_code != NULL) { - fl_value_set_string_take(result, "errorCode", - fl_value_new_string(error_code)); - } - return result; -} - -static gboolean inject_paste_keystroke_x11(Display* display) { - if (!ensure_xtest(display)) { - return FALSE; - } - KeyCode ctrl = XKeysymToKeycode(display, XK_Control_L); - KeyCode v = XKeysymToKeycode(display, XK_v); - if (ctrl == 0 || v == 0) { - return FALSE; - } - XTestFakeKeyEvent(display, ctrl, True, CurrentTime); - XTestFakeKeyEvent(display, v, True, CurrentTime); - XTestFakeKeyEvent(display, v, False, CurrentTime); - XTestFakeKeyEvent(display, ctrl, False, CurrentTime); - XFlush(display); - return TRUE; -} - -static FlValue* activate_and_paste_x11(Window window, gint timeout_ms) { - Display* display = get_xdisplay(); - if (display == NULL) { - return make_paste_result(FALSE, "noX11"); - } - if (window == 0) { - return make_paste_result(FALSE, "invalidWindow"); - } - if (!ensure_xtest(display)) { - return make_paste_result(FALSE, "noXTest"); - } - - XWindowAttributes prev_attrs; - long prev_event_mask = 0; - gboolean restored_mask = FALSE; - int (*prev_handler)(Display*, XErrorEvent*) = - XSetErrorHandler(activate_noop_error_handler); - - if (XGetWindowAttributes(display, window, &prev_attrs) != 0) { - prev_event_mask = prev_attrs.your_event_mask; - XSelectInput(display, window, prev_event_mask | FocusChangeMask); - restored_mask = TRUE; - } - - XEvent event; - memset(&event, 0, sizeof(event)); - event.xclient.type = ClientMessage; - event.xclient.window = window; - event.xclient.message_type = atom_net_active_window(display); - event.xclient.format = 32; - event.xclient.data.l[0] = 2; - event.xclient.data.l[1] = CurrentTime; - XSendEvent(display, DefaultRootWindow(display), False, - SubstructureNotifyMask | SubstructureRedirectMask, &event); - - XRaiseWindow(display, window); - XSetInputFocus(display, window, RevertToParent, CurrentTime); - XSync(display, False); - - gint64 deadline_us = g_get_monotonic_time() + ((gint64)timeout_ms * 1000); - XEvent received; - gboolean focus_in_received = FALSE; - while (g_get_monotonic_time() < deadline_us) { - if (XCheckTypedWindowEvent(display, window, FocusIn, &received)) { - focus_in_received = TRUE; - break; - } - g_usleep(5000); - } - - Window focused = None; - int revert_to = 0; - XGetInputFocus(display, &focused, &revert_to); - gboolean focus_ok = focus_in_received || focused == window; - - if (restored_mask) { - XSelectInput(display, window, prev_event_mask); - } - XSetErrorHandler(prev_handler); - - if (!focus_ok) { - return make_paste_result(FALSE, "focusTimeout"); - } - - if (!inject_paste_keystroke_x11(display)) { - return make_paste_result(FALSE, "noXTest"); - } - - return make_paste_result(TRUE, NULL); -} -#endif - -static gchar* get_clipboard_source(void) { -#ifdef GDK_WINDOWING_X11 - if (plugin_is_x11()) { - ActiveX11Window active = get_active_x11_window(); - if (active.valid) { - return get_x11_window_source(active.window); - } - } -#endif - return g_strdup(""); -} - -static GtkSelectionData* get_target_contents(GtkClipboard* clipboard, - const gchar* target_name) { - GdkAtom atom = gdk_atom_intern(target_name, FALSE); - return gtk_clipboard_wait_for_contents(clipboard, atom); -} - -// Targets whose presence means the source application asked clipboard managers -// not to record this content. `x-kde-passwordManagerHint` is what KeePassXC and -// KDE-aware managers set on a copied secret; it is the Linux counterpart of -// Windows' `ExcludeClipboardContentFromMonitorProcessing` and of the -// `org.nspasteboard.ConcealedType` pasteboard type on macOS. -// -// Presence alone is enough. The target exists for no other purpose than -// flagging a secret, and erring towards excluding costs the user one history -// entry while erring the other way writes their password to disk. -static const gchar* const kExcludedTargets[] = { - "x-kde-passwordManagerHint", - "org.nspasteboard.ConcealedType", - NULL, -}; - -// Asks only for the list of offered targets, never for their contents: a -// `wait_for_contents` call would pull the secret's bytes into this process -// just to decide not to keep them. One round-trip, and it runs on every -// clipboard change. -static gboolean should_exclude_clipboard(GtkClipboard* clipboard) { - GdkAtom* targets = NULL; - gint n_targets = 0; - if (!gtk_clipboard_wait_for_targets(clipboard, &targets, &n_targets)) { - return FALSE; - } - - gboolean excluded = FALSE; - for (gint i = 0; i < n_targets && !excluded; i++) { - g_autofree gchar* name = gdk_atom_name(targets[i]); - if (name == NULL) { - continue; - } - for (guint j = 0; kExcludedTargets[j] != NULL; j++) { - if (g_ascii_strcasecmp(name, kExcludedTargets[j]) == 0) { - excluded = TRUE; - break; - } - } - } - - g_free(targets); - return excluded; -} - -static FlValue* get_selection_data_value(GtkClipboard* clipboard, - const gchar* const* targets) { - for (guint i = 0; targets[i] != NULL; i++) { - GtkSelectionData* data = get_target_contents(clipboard, targets[i]); - if (data == NULL) { - continue; - } - - gint length = gtk_selection_data_get_length(data); - const guchar* bytes = gtk_selection_data_get_data(data); - FlValue* result = NULL; - if (bytes != NULL && length > 0) { - result = fl_value_new_uint8_list(bytes, (size_t)length); - } - - gtk_selection_data_free(data); - if (result != NULL) { - return result; - } - } - - return NULL; -} - -static gchar* build_clipboard_signature(GtkClipboard* clipboard) { - GString* signature = g_string_new(""); - - gchar** uris = gtk_clipboard_wait_for_uris(clipboard); - if (uris != NULL && uris[0] != NULL) { - for (guint i = 0; uris[i] != NULL; i++) { - g_autofree gchar* path = g_filename_from_uri(uris[i], NULL, NULL); - if (path != NULL) { - g_string_append_printf(signature, "F:%s|", path); - } else { - g_string_append_printf(signature, "U:%s|", uris[i]); - } - } - g_strfreev(uris); - return g_string_free(signature, FALSE); - } - - if (uris != NULL) { - g_strfreev(uris); - } - - gchar* text = gtk_clipboard_wait_for_text(clipboard); - if (text != NULL && *text != '\0') { - gsize length = strlen(text); - gsize sample_length = length > 100 ? 100 : length; - g_string_append(signature, "T:"); - g_string_append_len(signature, text, sample_length); - g_free(text); - return g_string_free(signature, FALSE); - } - g_free(text); - - GdkPixbuf* image = gtk_clipboard_wait_for_image(clipboard); - if (image != NULL) { - const guchar* pixels = gdk_pixbuf_read_pixels(image); - gsize rowstride = (gsize)gdk_pixbuf_get_rowstride(image); - gint height = gdk_pixbuf_get_height(image); - gsize total = rowstride * (gsize)height; - gsize sample_len = total > 256 ? 256 : total; - g_string_append(signature, "I:"); - g_string_append_printf(signature, "%" G_GSIZE_FORMAT ":", total); - for (gsize i = 0; i < sample_len; i++) { - g_string_append_printf(signature, "%02x", pixels[i]); - } - g_object_unref(image); - return g_string_free(signature, FALSE); - } - - return g_string_free(signature, FALSE); -} - -static gboolean is_duplicate_change(ListenerPlugin* self, const gchar* hash) { - if (self->last_content_hash != NULL && g_strcmp0(self->last_content_hash, hash) == 0) { - return TRUE; - } - - g_free(self->last_content_hash); - self->last_content_hash = g_strdup(hash); - self->last_change_tick_ms = now_ms(); - return FALSE; -} - -static gboolean should_ignore_recent_write(ListenerPlugin* self) { - guint64 now = now_ms(); - return self->last_write_tick_ms != 0 && - (now - self->last_write_tick_ms) < kClipboardWriteIgnoreMs; -} - -static gboolean send_clipboard_event(ListenerPlugin* self, FlValue* event) { - if (!self->is_listening || self->event_channel == NULL || event == NULL) { - return FALSE; - } - - g_autoptr(GError) error = NULL; - gboolean success = fl_event_channel_send(self->event_channel, event, NULL, &error); - if (!success && error != NULL) { - g_warning("Failed to send clipboard event: %s", error->message); - } - return success; -} - -static FlValue* build_file_event(GtkClipboard* clipboard, - const gchar* source, - const gchar* hash) { - gchar** uris = gtk_clipboard_wait_for_uris(clipboard); - if (uris == NULL || uris[0] == NULL) { - g_strfreev(uris); - return NULL; - } - - g_autoptr(FlValue) files = fl_value_new_list(); - guint count = 0; - gint event_type = CLIP_TYPE_FILE; - gchar* first_path = NULL; - - for (guint i = 0; uris[i] != NULL; i++) { - g_autofree gchar* path = g_filename_from_uri(uris[i], NULL, NULL); - if (path == NULL || *path == '\0') { - continue; - } - if (first_path == NULL) { - first_path = g_strdup(path); - } - fl_value_append_take(files, fl_value_new_string(path)); - count++; - } - - g_strfreev(uris); - - if (count == 0) { - g_free(first_path); - return NULL; - } - - if (count == 1 && first_path != NULL) { - event_type = detect_file_type(first_path); - } - g_free(first_path); - - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", fl_value_new_int(event_type)); - fl_value_set_string_take(event, "files", fl_value_ref(files)); - fl_value_set_string_take(event, "source", fl_value_new_string(source)); - fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); - return fl_value_ref(event); -} - -static FlValue* build_text_event(GtkClipboard* clipboard, - const gchar* source, - const gchar* hash) { - gchar* text = gtk_clipboard_wait_for_text(clipboard); - if (text == NULL || *text == '\0') { - g_free(text); - return NULL; - } - - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", - fl_value_new_int(is_url_text(text) ? CLIP_TYPE_LINK : CLIP_TYPE_TEXT)); - fl_value_set_string_take(event, "text", fl_value_new_string(text)); - fl_value_set_string_take(event, "source", fl_value_new_string(source)); - fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); - - if (!is_url_text(text)) { - const gchar* const rtf_targets[] = {"text/rtf", "application/rtf", - "Rich Text Format", NULL}; - const gchar* const html_targets[] = {"text/html", "HTML Format", NULL}; - - FlValue* rtf = get_selection_data_value(clipboard, rtf_targets); - if (rtf != NULL) { - fl_value_set_string_take(event, "rtf", rtf); - } - FlValue* html = get_selection_data_value(clipboard, html_targets); - if (html != NULL) { - fl_value_set_string_take(event, "html", html); - } - } - - g_free(text); - return fl_value_ref(event); -} - -static FlValue* build_image_event(GtkClipboard* clipboard, - const gchar* source, - const gchar* hash) { - GdkPixbuf* pixbuf = gtk_clipboard_wait_for_image(clipboard); - if (pixbuf == NULL) { - return NULL; - } - - gchar* buffer = NULL; - gsize buffer_size = 0; - g_autoptr(GError) error = NULL; - gboolean ok = gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "png", - &error, NULL); - g_object_unref(pixbuf); - if (!ok || buffer == NULL || buffer_size == 0) { - if (error != NULL) { - g_warning("Failed to serialize clipboard image: %s", error->message); - } - g_free(buffer); - return NULL; - } - - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", fl_value_new_int(CLIP_TYPE_IMAGE)); - fl_value_set_string_take(event, "bytes", - fl_value_new_uint8_list((const uint8_t*)buffer, - (size_t)buffer_size)); - fl_value_set_string_take(event, "source", fl_value_new_string(source)); - fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); - - g_free(buffer); - return fl_value_ref(event); -} - -static void process_clipboard(ListenerPlugin* self) { - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - return; - } - - if (should_ignore_recent_write(self)) { - return; - } - - // Checked before anything reads the content, so excluded data never reaches - // the signature, the event, or the database. - if (should_exclude_clipboard(clipboard)) { - return; - } - - g_autofree gchar* signature = build_clipboard_signature(clipboard); - if (signature == NULL || *signature == '\0') { - if (self->last_content_hash != NULL) { - g_free(self->last_content_hash); - self->last_content_hash = NULL; - } - return; - } - - g_autofree gchar* hash = compute_fnv1a_hash(signature); - if (hash == NULL || *hash == '\0' || is_duplicate_change(self, hash)) { - return; - } - - g_autofree gchar* source = get_clipboard_source(); - - g_autoptr(FlValue) event = build_file_event(clipboard, source, hash); - if (event == NULL) { - event = build_text_event(clipboard, source, hash); - } - if (event == NULL) { - event = build_image_event(clipboard, source, hash); - } - - if (event != NULL) { - send_clipboard_event(self, event); - } -} - -static gboolean clipboard_poll_cb(gpointer user_data) { - ListenerPlugin* self = LISTENER_PLUGIN(user_data); - if (!self->is_listening) { - self->poll_timer_id = 0; - return G_SOURCE_REMOVE; - } - - process_clipboard(self); - return G_SOURCE_CONTINUE; -} - -static gboolean owner_debounce_cb(gpointer user_data) { - ListenerPlugin* self = LISTENER_PLUGIN(user_data); - self->owner_debounce_timer_id = 0; - if (self->is_listening) { - process_clipboard(self); - } - return G_SOURCE_REMOVE; -} - -static void on_owner_change(GtkClipboard* clipboard, - GdkEvent* event, - gpointer user_data) { - (void)clipboard; - (void)event; - ListenerPlugin* self = LISTENER_PLUGIN(user_data); - if (!self->is_listening) { - return; - } - if (self->last_content_hash != NULL) { - g_free(self->last_content_hash); - self->last_content_hash = NULL; - } - if (self->owner_debounce_timer_id != 0) { - g_source_remove(self->owner_debounce_timer_id); - self->owner_debounce_timer_id = 0; - } - self->owner_debounce_timer_id = g_timeout_add( - kClipboardOwnerDebounceMs, owner_debounce_cb, self); -} - -static void ensure_polling(ListenerPlugin* self) { - if (self->poll_timer_id == 0) { - self->poll_timer_id = g_timeout_add(kClipboardPollIntervalMs, - clipboard_poll_cb, self); - } - if (self->owner_change_handler_id == 0) { - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard != NULL) { - self->owner_change_handler_id = g_signal_connect( - clipboard, "owner-change", G_CALLBACK(on_owner_change), self); - } - } -} - -static void stop_polling(ListenerPlugin* self) { - if (self->poll_timer_id != 0) { - g_source_remove(self->poll_timer_id); - self->poll_timer_id = 0; - } - if (self->owner_debounce_timer_id != 0) { - g_source_remove(self->owner_debounce_timer_id); - self->owner_debounce_timer_id = 0; - } - if (self->owner_change_handler_id != 0) { - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard != NULL) { - g_signal_handler_disconnect(clipboard, self->owner_change_handler_id); - } - self->owner_change_handler_id = 0; - } -} - -static FlValue* get_cursor_and_screen_info(void) { - GdkDisplay* display = gdk_display_get_default(); - if (display == NULL) { - return NULL; - } - - GdkSeat* seat = gdk_display_get_default_seat(display); - if (seat == NULL) { - return NULL; - } - - GdkDevice* pointer = gdk_seat_get_pointer(seat); - if (pointer == NULL) { - return NULL; - } - - gint cursor_x = 0; - gint cursor_y = 0; - gdk_device_get_position(pointer, NULL, &cursor_x, &cursor_y); - - GdkMonitor* monitor = gdk_display_get_monitor_at_point(display, cursor_x, cursor_y); - if (monitor == NULL) { - return NULL; - } - - GdkRectangle workarea; - memset(&workarea, 0, sizeof(workarea)); - gdk_monitor_get_workarea(monitor, &workarea); - - g_autoptr(FlValue) info = fl_value_new_map(); - fl_value_set_string_take(info, "cursorX", fl_value_new_float((double)cursor_x)); - fl_value_set_string_take(info, "cursorY", fl_value_new_float((double)cursor_y)); - fl_value_set_string_take(info, "waLeft", fl_value_new_float((double)workarea.x)); - fl_value_set_string_take(info, "waTop", fl_value_new_float((double)workarea.y)); - fl_value_set_string_take(info, "waRight", - fl_value_new_float((double)(workarea.x + workarea.width))); - fl_value_set_string_take(info, "waBottom", - fl_value_new_float((double)(workarea.y + workarea.height))); - return fl_value_ref(info); -} - -static gboolean set_text_to_clipboard(const gchar* text) { - if (text == NULL || *text == '\0') { - return FALSE; - } - - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - return FALSE; - } - - gtk_clipboard_set_text(clipboard, text, -1); - gtk_clipboard_store(clipboard); - return TRUE; -} - -typedef struct { - GdkPixbuf* pixbuf; - gchar* uri; -} ImageClipData; - -static void image_clip_get_cb(GtkClipboard* clipboard, - GtkSelectionData* selection_data, - guint info, - gpointer user_data) { - (void)clipboard; - ImageClipData* d = (ImageClipData*)user_data; - - if (info == 0) { - GdkAtom target = gdk_atom_intern_static_string("text/uri-list"); - gtk_selection_data_set(selection_data, target, 8, - (const guchar*)d->uri, (gint)strlen(d->uri)); - } else { - gtk_selection_data_set_pixbuf(selection_data, d->pixbuf); - } -} - -static void image_clip_clear_cb(GtkClipboard* clipboard, gpointer user_data) { - (void)clipboard; - ImageClipData* d = (ImageClipData*)user_data; - if (d->pixbuf) g_object_unref(d->pixbuf); - g_free(d->uri); - g_free(d); -} - -static gboolean set_image_to_clipboard(const gchar* image_path) { - if (image_path == NULL || *image_path == '\0') { - return FALSE; - } - - g_autoptr(GError) error = NULL; - GdkPixbuf* pixbuf = gdk_pixbuf_new_from_file(image_path, &error); - if (pixbuf == NULL) { - if (error != NULL) { - g_warning("Failed to load image for clipboard: %s", error->message); - } - return FALSE; - } - - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - g_object_unref(pixbuf); - return FALSE; - } - - GtkTargetList* tl = gtk_target_list_new(NULL, 0); - gtk_target_list_add(tl, gdk_atom_intern_static_string("text/uri-list"), 0, 0); - gtk_target_list_add_image_targets(tl, 1, TRUE); - - gint n_targets = 0; - GtkTargetEntry* targets = gtk_target_table_new_from_list(tl, &n_targets); - gtk_target_list_unref(tl); - - gchar* uri = g_filename_to_uri(image_path, NULL, NULL); - if (uri == NULL) { - g_object_unref(pixbuf); - gtk_target_table_free(targets, n_targets); - return FALSE; - } - - gchar* uri_line = g_strdup_printf("%s\r\n", uri); - g_free(uri); - - ImageClipData* data = g_new0(ImageClipData, 1); - data->pixbuf = pixbuf; - data->uri = uri_line; - - gboolean ok = gtk_clipboard_set_with_data( - clipboard, targets, n_targets, - image_clip_get_cb, image_clip_clear_cb, data); - gtk_target_table_free(targets, n_targets); - - if (!ok) { - g_object_unref(pixbuf); - g_free(uri_line); - g_free(data); - return FALSE; - } - - gtk_clipboard_store(clipboard); - return TRUE; -} - -static void clipboard_uri_list_get_cb(GtkClipboard* clipboard, - GtkSelectionData* selection_data, - guint info, - gpointer user_data) { - (void)clipboard; - (void)info; - - const gchar* uri_list = (const gchar*)user_data; - if (uri_list == NULL || *uri_list == '\0') { - return; - } - - GdkAtom target = gdk_atom_intern_static_string("text/uri-list"); - gtk_selection_data_set(selection_data, target, 8, (const guchar*)uri_list, - (gint)strlen(uri_list)); -} - -static void clipboard_uri_list_clear_cb(GtkClipboard* clipboard, gpointer user_data) { - (void)clipboard; - g_free(user_data); -} - -static gboolean set_files_to_clipboard(const gchar* content) { - if (content == NULL || *content == '\0') { - return FALSE; - } - - gchar** parts = g_strsplit(content, "\n", -1); - g_autoptr(GString) uri_list = g_string_new(NULL); - for (guint i = 0; parts[i] != NULL; i++) { - if (parts[i][0] == '\0' || !g_file_test(parts[i], G_FILE_TEST_EXISTS)) { - continue; - } - gchar* uri = g_filename_to_uri(parts[i], NULL, NULL); - if (uri != NULL) { - g_string_append(uri_list, uri); - g_string_append(uri_list, "\r\n"); - g_free(uri); - } - } - g_strfreev(parts); - - if (uri_list->len == 0) { - return FALSE; - } - - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - return FALSE; - } - - static GtkTargetEntry targets[] = { - {(gchar*)"text/uri-list", 0, 0}, - }; - - gchar* uri_payload = g_string_free(g_steal_pointer(&uri_list), FALSE); - gboolean set_ok = gtk_clipboard_set_with_data( - clipboard, targets, G_N_ELEMENTS(targets), clipboard_uri_list_get_cb, - clipboard_uri_list_clear_cb, uri_payload); - if (!set_ok) { - g_free(uri_payload); - return FALSE; - } - - gtk_clipboard_store(clipboard); - return TRUE; -} - -static FlValue* get_media_info(void) { - return NULL; -} - -// Generates a native PNG thumbnail for `path`, scaled so the longest side -// is `size_px`. Returns a Uint8 list FlValue with PNG bytes, or NULL when -// the file cannot be decoded by GdkPixbuf (e.g. video/audio without a -// loader). Caller takes ownership of the returned FlValue. -// -// Uses gdk_pixbuf_new_from_file_at_size() which preserves aspect ratio. -// Rejects results smaller than 64 px on the longest side (icon fallback). -static FlValue* get_native_thumbnail(const gchar* path, gint size_px) { - if (path == NULL || *path == '\0' || size_px <= 0) return NULL; - - GError* error = NULL; - GdkPixbuf* pixbuf = - gdk_pixbuf_new_from_file_at_size(path, size_px, size_px, &error); - if (pixbuf == NULL) { - if (error != NULL) { - g_warning("get_native_thumbnail: %s", error->message); - g_error_free(error); - } - return NULL; - } - - gint w = gdk_pixbuf_get_width(pixbuf); - gint h = gdk_pixbuf_get_height(pixbuf); - gint longest = w > h ? w : h; - if (longest < 64) { - g_object_unref(pixbuf); - return NULL; - } - - gchar* buffer = NULL; - gsize buffer_size = 0; - gboolean ok = gdk_pixbuf_save_to_buffer( - pixbuf, &buffer, &buffer_size, "png", &error, NULL); - g_object_unref(pixbuf); - if (!ok || buffer == NULL || buffer_size == 0) { - if (error != NULL) { - g_warning("get_native_thumbnail save: %s", error->message); - g_error_free(error); - } - g_free(buffer); - return NULL; - } - - FlValue* value = - fl_value_new_uint8_list((const uint8_t*)buffer, buffer_size); - g_free(buffer); - return value; -} - -static void respond_success(FlMethodCall* method_call, FlValue* result) { - g_autoptr(GError) error = NULL; - if (!fl_method_call_respond_success(method_call, result, &error) && error != NULL) { - g_warning("Failed to respond to method call: %s", error->message); - } -} - -static void listener_plugin_handle_method_call(ListenerPlugin* self, - FlMethodCall* method_call) { - const gchar* method = fl_method_call_get_name(method_call); - FlValue* args = fl_method_call_get_args(method_call); - - if (strcmp(method, "getCapabilities") == 0) { - g_autoptr(FlValue) caps = fl_value_new_map(); - fl_value_set_string_take(caps, "isX11", fl_value_new_bool(plugin_is_x11())); -#ifdef GDK_WINDOWING_X11 - Display* display = get_xdisplay(); - gboolean has_xtest = display != NULL && ensure_xtest(display); -#else - gboolean has_xtest = FALSE; -#endif - fl_value_set_string_take(caps, "hasXTest", fl_value_new_bool(has_xtest)); - respond_success(method_call, fl_value_ref(caps)); - return; - } - - if (strcmp(method, "setClipboardContent") == 0) { - FlValue* type_value = args != NULL ? fl_value_lookup_string(args, "type") : NULL; - gint64 type = type_value != NULL ? fl_value_get_int(type_value) : -1; - FlValue* content_value = args != NULL ? fl_value_lookup_string(args, "content") : NULL; - const gchar* content = content_value != NULL && - fl_value_get_type(content_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(content_value) - : ""; - gboolean success = FALSE; - - switch (type) { - case CLIP_TYPE_TEXT: - case CLIP_TYPE_LINK: - success = set_text_to_clipboard(content); - break; - case CLIP_TYPE_IMAGE: - success = set_image_to_clipboard(content); - break; - case CLIP_TYPE_FILE: - case CLIP_TYPE_FOLDER: - case CLIP_TYPE_AUDIO: - case CLIP_TYPE_VIDEO: - success = set_files_to_clipboard(content); - break; - default: - success = FALSE; - break; - } - - if (success) { - self->last_write_tick_ms = now_ms(); - } - respond_success(method_call, fl_value_new_bool(success)); - return; - } - - if (strcmp(method, "getMediaInfo") == 0) { - respond_success(method_call, get_media_info()); - return; - } - - if (strcmp(method, "getNativeThumbnail") == 0) { - FlValue* path_value = - args != NULL ? fl_value_lookup_string(args, "path") : NULL; - FlValue* size_value = - args != NULL ? fl_value_lookup_string(args, "sizePx") : NULL; - const gchar* path = - path_value != NULL && fl_value_get_type(path_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(path_value) - : NULL; - gint size_px = - size_value != NULL && fl_value_get_type(size_value) == FL_VALUE_TYPE_INT - ? (gint)fl_value_get_int(size_value) - : 256; - respond_success(method_call, get_native_thumbnail(path, size_px)); - return; - } - - if (strcmp(method, "captureFrontmostApp") == 0) { -#ifdef GDK_WINDOWING_X11 - if (plugin_is_x11()) { - gchar* id = capture_frontmost_x11_identifier(); - FlValue* value = id != NULL ? fl_value_new_string(id) : NULL; - g_free(id); - respond_success(method_call, value); - return; - } -#endif - respond_success(method_call, NULL); - return; - } - - if (strcmp(method, "activateAndPaste") == 0) { -#ifdef GDK_WINDOWING_X11 - if (plugin_is_x11()) { - FlValue* id_value = args != NULL ? fl_value_lookup_string(args, "bundleId") : NULL; - FlValue* timeout_value = - args != NULL ? fl_value_lookup_string(args, "focusTimeoutMs") : NULL; - const gchar* identifier = id_value != NULL && - fl_value_get_type(id_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(id_value) - : NULL; - gint timeout_ms = - timeout_value != NULL && fl_value_get_type(timeout_value) == FL_VALUE_TYPE_INT - ? (gint)fl_value_get_int(timeout_value) - : 250; - if (timeout_ms < 50) timeout_ms = 50; - if (timeout_ms > 2000) timeout_ms = 2000; - - if (identifier == NULL || !g_str_has_prefix(identifier, "x11:0x")) { - respond_success(method_call, make_paste_result(FALSE, "invalidWindow")); - return; - } - - Window window = (Window)g_ascii_strtoull(identifier + 6, NULL, 16); - respond_success(method_call, activate_and_paste_x11(window, timeout_ms)); - return; - } -#endif - respond_success(method_call, make_paste_result(FALSE, "noX11")); - return; - } - - if (strcmp(method, "getCursorAndScreenInfo") == 0) { - respond_success(method_call, get_cursor_and_screen_info()); - return; - } - - if (strcmp(method, "checkAccessibility") == 0 || - strcmp(method, "requestAccessibility") == 0 || - strcmp(method, "openAccessibilitySettings") == 0) { - respond_success(method_call, fl_value_new_bool(TRUE)); - return; - } - - g_autoptr(FlMethodResponse) response = - FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); - fl_method_call_respond(method_call, response, NULL); -} - -static FlMethodErrorResponse* stream_listen_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - ListenerPlugin* self = LISTENER_PLUGIN(user_data); - self->is_listening = TRUE; - ensure_polling(self); - process_clipboard(self); - return NULL; -} - -static FlMethodErrorResponse* stream_cancel_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - ListenerPlugin* self = LISTENER_PLUGIN(user_data); - self->is_listening = FALSE; - stop_polling(self); - return NULL; -} - -static void method_call_cb(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data) { - (void)channel; - listener_plugin_handle_method_call(LISTENER_PLUGIN(user_data), method_call); -} - -FlMethodResponse* get_platform_version(void) { - struct utsname uname_data = {}; - uname(&uname_data); - g_autofree gchar* version = g_strdup_printf("Linux %s", uname_data.version); - g_autoptr(FlValue) result = fl_value_new_string(version); - return FL_METHOD_RESPONSE(fl_method_success_response_new(result)); -} - -static void listener_plugin_dispose(GObject* object) { - ListenerPlugin* self = LISTENER_PLUGIN(object); - stop_polling(self); - self->is_listening = FALSE; - - g_clear_object(&self->event_channel); - g_clear_object(&self->method_channel); - g_free(self->last_content_hash); - self->last_content_hash = NULL; - - G_OBJECT_CLASS(listener_plugin_parent_class)->dispose(object); -} - -static void listener_plugin_class_init(ListenerPluginClass* klass) { - G_OBJECT_CLASS(klass)->dispose = listener_plugin_dispose; -} - -static void listener_plugin_init(ListenerPlugin* self) { - self->last_content_hash = NULL; - self->last_change_tick_ms = 0; - self->last_write_tick_ms = 0; - self->is_listening = FALSE; - self->poll_timer_id = 0; - self->owner_change_handler_id = 0; - self->owner_debounce_timer_id = 0; -} - -void listener_plugin_register_with_registrar(FlPluginRegistrar* registrar) { - ListenerPlugin* plugin = LISTENER_PLUGIN( - g_object_new(listener_plugin_get_type(), NULL)); - - FlBinaryMessenger* messenger = fl_plugin_registrar_get_messenger(registrar); - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - - plugin->event_channel = fl_event_channel_new(messenger, kClipboardChannelName, - FL_METHOD_CODEC(codec)); - fl_event_channel_set_stream_handlers(plugin->event_channel, stream_listen_cb, - stream_cancel_cb, g_object_ref(plugin), - g_object_unref); - - plugin->method_channel = fl_method_channel_new( - messenger, kClipboardWriterChannelName, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler(plugin->method_channel, - method_call_cb, - g_object_ref(plugin), - g_object_unref); - - g_object_unref(plugin); -} diff --git a/listener/linux/listener_plugin_private.h b/listener/linux/listener_plugin_private.h deleted file mode 100644 index 99f2fcad..00000000 --- a/listener/linux/listener_plugin_private.h +++ /dev/null @@ -1,5 +0,0 @@ -#include - -#include "include/listener/listener_plugin.h" - -FlMethodResponse* get_platform_version(); diff --git a/listener/linux/test/listener_plugin_test.cc b/listener/linux/test/listener_plugin_test.cc deleted file mode 100644 index fe48ef32..00000000 --- a/listener/linux/test/listener_plugin_test.cc +++ /dev/null @@ -1,31 +0,0 @@ -#include -#include -#include - -#include "include/listener/listener_plugin.h" -#include "listener_plugin_private.h" - -// This demonstrates a simple unit test of the C portion of this plugin's -// implementation. -// -// Once you have built the plugin's example app, you can run these tests -// from the command line. For instance, for a plugin called my_plugin -// built for x64 debug, run: -// $ build/linux/x64/debug/plugins/my_plugin/my_plugin_test - -namespace listener { -namespace test { - -TEST(ListenerPlugin, GetPlatformVersion) { - g_autoptr(FlMethodResponse) response = get_platform_version(); - ASSERT_NE(response, nullptr); - ASSERT_TRUE(FL_IS_METHOD_SUCCESS_RESPONSE(response)); - FlValue* result = fl_method_success_response_get_result( - FL_METHOD_SUCCESS_RESPONSE(response)); - ASSERT_EQ(fl_value_get_type(result), FL_VALUE_TYPE_STRING); - // The full string varies, so just validate that it has the right format. - EXPECT_THAT(fl_value_get_string(result), testing::StartsWith("Linux ")); -} - -} // namespace test -} // namespace listener diff --git a/listener/pubspec.yaml b/listener/pubspec.yaml index cb819a64..f2f1cbdf 100644 --- a/listener/pubspec.yaml +++ b/listener/pubspec.yaml @@ -23,8 +23,6 @@ dev_dependencies: flutter: plugin: platforms: - linux: - pluginClass: ListenerPlugin macos: pluginClass: ListenerPlugin windows: diff --git a/listener/test/clipboard_event_platform_test.dart b/listener/test/clipboard_event_platform_test.dart index d1d4028d..e4f375b1 100644 --- a/listener/test/clipboard_event_platform_test.dart +++ b/listener/test/clipboard_event_platform_test.dart @@ -1,6 +1,6 @@ /// Platform-agnostic tests that verify ClipboardEvent parsing is robust -/// across all content types and unusual native payloads (Windows, macOS, Linux -/// all send `Map` via BasicMessageChannel). +/// across all content types and unusual native payloads (Windows and macOS +/// both send `Map` via BasicMessageChannel). library; import 'dart:typed_data'; diff --git a/listener/test/clipboard_writer_test.dart b/listener/test/clipboard_writer_test.dart index c2b1bad9..95bc3fb5 100644 --- a/listener/test/clipboard_writer_test.dart +++ b/listener/test/clipboard_writer_test.dart @@ -366,12 +366,11 @@ void main() { }; }); final result = await ClipboardWriter.activateAndPaste( - bundleId: 'x11:0xabc', + bundleId: 'com.example.editor', delayMs: 0, ); expect(result.success, isFalse); expect(result.errorCode, equals('focusTimeout')); - expect(result.isFocusTimeout, isTrue); }); test('sends bundleId, delayMs and focusTimeoutMs as arguments', () async { diff --git a/listener/test/linux_native_thumbnail_provider_test.dart b/listener/test/linux_native_thumbnail_provider_test.dart deleted file mode 100644 index e524b97a..00000000 --- a/listener/test/linux_native_thumbnail_provider_test.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:listener/linux_native_thumbnail_provider.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const channel = MethodChannel('copypaste/clipboard_writer'); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - group('LinuxNativeThumbnailProvider', () { - test('returns null on non-Linux hosts (or empty channel)', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async => null); - - final provider = LinuxNativeThumbnailProvider(); - final result = await provider.request('/tmp/missing.png', sizePx: 256); - expect(result, isNull); - }); - - test('returns Uint8List bytes when channel succeeds', () async { - final fakeBytes = Uint8List.fromList(List.generate(64, (i) => i)); - String? receivedPath; - int? receivedSize; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method != 'getNativeThumbnail') return null; - final args = call.arguments as Map; - receivedPath = args['path'] as String?; - receivedSize = args['sizePx'] as int?; - return fakeBytes; - }); - - final provider = LinuxNativeThumbnailProvider(); - final result = await provider.request('/tmp/photo.jpg', sizePx: 128); - - // Outside the platform guard this is a no-op on non-Linux hosts. - if (receivedPath != null) { - expect(result, equals(fakeBytes)); - expect(receivedPath, equals('/tmp/photo.jpg')); - expect(receivedSize, greaterThanOrEqualTo(128)); - } else { - expect(result, isNull); - } - }); - - test('treats empty list as null (no thumbnail available)', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'getNativeThumbnail') return Uint8List(0); - return null; - }); - - final provider = LinuxNativeThumbnailProvider(); - final result = await provider.request('/tmp/missing.bin'); - expect(result, isNull); - }); - - test('swallows PlatformException and returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'getNativeThumbnail') { - throw PlatformException(code: 'boom', message: 'native failure'); - } - return null; - }); - - final provider = LinuxNativeThumbnailProvider(); - final result = await provider.request('/tmp/whatever.png'); - expect(result, isNull); - }); - - test( - 'rejects empty path / non-positive size before invoking channel', - () async { - var called = false; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - called = true; - return null; - }); - - final provider = LinuxNativeThumbnailProvider(); - expect(await provider.request(''), isNull); - expect(await provider.request('x', sizePx: 0), isNull); - expect(await provider.request('x', sizePx: -1), isNull); - expect(called, isFalse); - }, - ); - - test('survives MissingPluginException (no listener registered)', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - - final provider = LinuxNativeThumbnailProvider(); - final result = await provider.request('/tmp/anything.png'); - expect(result, isNull); - }); - }); -} diff --git a/listener/test/windows_native_thumbnail_provider_test.dart b/listener/test/windows_native_thumbnail_provider_test.dart index cdee0cb8..86c548a4 100644 --- a/listener/test/windows_native_thumbnail_provider_test.dart +++ b/listener/test/windows_native_thumbnail_provider_test.dart @@ -17,7 +17,7 @@ void main() { // 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 Linux/macOS hosts the early `Platform.isWindows` guard takes + // On non-Windows hosts the early `Platform.isWindows` guard takes // over before any channel call happens. TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async => null); diff --git a/packaging/obs/README.md b/packaging/obs/README.md deleted file mode 100644 index f6d225f5..00000000 --- a/packaging/obs/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# OBS packaging — `home:rgdevment/copypaste` - -This directory holds the source files OBS (`build.opensuse.org`) consumes -to build native `.deb` and `.rpm` packages from the prebuilt portable -tarball published on each GitHub Release -(`CopyPaste--linux-x64.tar.gz`). - -## Files - -| File | Purpose | -| ------------------------ | ---------------------------------------------------------- | -| `_service` | Tells OBS to download the upstream tarball at build time. | -| `copypaste.spec` | RPM spec used for Fedora and openSUSE Tumbleweed targets. | -| `copypaste.dsc` | Debian source description used for Debian/Ubuntu targets. | -| `debian/` | Debian packaging metadata (control, rules, changelog, …). | - -The literal `@VERSION@` token in `_service`, `copypaste.spec`, -`copypaste.dsc` and `debian/changelog` is substituted at release time by -the GitHub Actions job `publish-obs` in -`.github/workflows/release-linux.yml`, which then commits the rendered -files into the OBS package via `osc`. - -## How the build works - -1. CI publishes the GitHub Release with - `CopyPaste--linux-x64.tar.gz` containing the Flutter bundle - plus `LICENSE`, `packaging/com.rgdevment.copypaste.desktop` and - `packaging/icon_app_256.png`. -2. The `publish-obs` job renders the templates and pushes them to - `home:rgdevment/copypaste` on `build.opensuse.org`. -3. OBS downloads the tarball through `_service` and rebuilds the - package against every enabled target. Built repositories appear at - `https://download.opensuse.org/repositories/home:/rgdevment//`. - -The tarball is **not** rebuilt by OBS — it is only repackaged. This -keeps OBS workers free of the Flutter toolchain and matches the pattern -used by other Flutter/Electron desktop apps published on OBS. - -## Targets - -| Family | OBS target name | -| --------- | ---------------------------- | -| Debian | `Debian_12`, `Debian_13` | -| Ubuntu | `xUbuntu_22.04`, `xUbuntu_24.04` | -| Fedora | `Fedora_40`, `Fedora_41` | -| openSUSE | `openSUSE_Tumbleweed` | - -End-user installation instructions live in the project README. diff --git a/packaging/obs/copypaste-rpmlintrc b/packaging/obs/copypaste-rpmlintrc deleted file mode 100644 index 6a1c79b9..00000000 --- a/packaging/obs/copypaste-rpmlintrc +++ /dev/null @@ -1,24 +0,0 @@ -addFilter("binary-or-shlib-defines-rpath") -addFilter("explicit-lib-dependency") -addFilter("branding-requires-unversioned") -addFilter("dir-or-file-outside-snapshot") -addFilter("desktopfile-without-binary") -addFilter("non-standard-dir-in-opt") -addFilter("non-standard-uid") -addFilter("non-standard-gid") -addFilter("no-manual-page-for-binary") -addFilter("zero-length") -addFilter("devel-file-in-non-devel-package") -addFilter("missing-hash-section") -addFilter("missing-gnu-hash-section") -addFilter("no-soname") -addFilter("shared-library-not-executable") -addFilter("unstripped-binary-or-object") -addFilter("filelist-forbidden-opt") -addFilter("no-changelogname-tag") -addFilter("no-%check-section") -addFilter("invalid-license") -addFilter("description-line-too-long") -addFilter("summary-ended-with-dot") -addFilter("suse-filelist-forbidden-opt") -addFilter("suse-filelist-forbidden-fhs23") diff --git a/packaging/obs/copypaste.dsc b/packaging/obs/copypaste.dsc deleted file mode 100644 index 2c788b9a..00000000 --- a/packaging/obs/copypaste.dsc +++ /dev/null @@ -1,12 +0,0 @@ -Format: 3.0 (quilt) -Source: copypaste -Binary: copypaste -Architecture: amd64 -Version: @VERSION@-1 -Maintainer: rgdevment -Homepage: https://github.com/rgdevment/CopyPaste -Standards-Version: 4.6.2 -Build-Depends: debhelper-compat (= 13) -# DEBTRANSFORM-TAR: CopyPaste-@VERSION@-linux-x64.tar.gz -# DEBTRANSFORM-FILES-TAR: debian.tar.xz -Files: diff --git a/packaging/obs/copypaste.spec b/packaging/obs/copypaste.spec deleted file mode 100644 index bf482721..00000000 --- a/packaging/obs/copypaste.spec +++ /dev/null @@ -1,65 +0,0 @@ -Name: copypaste -Version: @VERSION@ -Release: 0 -Summary: Free, open source clipboard manager and clipboard history tool -License: GPL-3.0-only -Group: Productivity/Utilities -URL: https://github.com/rgdevment/CopyPaste -Source0: CopyPaste-%{version}-linux-x64.tar.gz -BuildRequires: desktop-file-utils -BuildRequires: hicolor-icon-theme -ExclusiveArch: x86_64 - -%global __brp_check_rpaths %{nil} -%global __requires_exclude_from ^/opt/copypaste/.*$ -%global __provides_exclude_from ^/opt/copypaste/.*$ -Requires: hicolor-icon-theme -%if 0%{?suse_version} -Requires: libayatana-appindicator3-1 -Requires: libkeybinder-3_0-0 -Requires: libgtk-3-0 -Requires: libX11-6 -Requires: libXtst6 -%else -Requires: libayatana-appindicator-gtk3 -Requires: keybinder3 -Requires: gtk3 -Requires: libX11 -Requires: libXtst -%endif - -%description -CopyPaste is a free, open source, local-first clipboard manager and -clipboard history tool for X11 sessions on Linux. No telemetry, no -accounts, no cloud — your clipboard data never leaves your computer. - -%global debug_package %{nil} - -%prep -%setup -q -n CopyPaste-%{version}-linux-x64 - -%build - -%install -install -d %{buildroot}/opt/copypaste -cp -a bundle/. %{buildroot}/opt/copypaste/ -chmod 0755 %{buildroot}/opt/copypaste/copypaste -find %{buildroot}/opt/copypaste/lib -type f -name '*.so' -exec chmod 0755 {} + -install -d %{buildroot}%{_bindir} -ln -s /opt/copypaste/copypaste %{buildroot}%{_bindir}/copypaste -install -Dm644 packaging/com.rgdevment.copypaste.desktop \ - %{buildroot}%{_datadir}/applications/com.rgdevment.copypaste.desktop -install -Dm644 packaging/icon_app_256.png \ - %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/com.rgdevment.copypaste.png -desktop-file-validate %{buildroot}%{_datadir}/applications/com.rgdevment.copypaste.desktop - -%files -%license LICENSE -/opt/copypaste -%{_bindir}/copypaste -%{_datadir}/applications/com.rgdevment.copypaste.desktop -%{_datadir}/icons/hicolor/256x256/apps/com.rgdevment.copypaste.png - -%changelog -* Thu Apr 23 2026 rgdevment - @VERSION@-0 -- Automated release from GitHub Actions diff --git a/packaging/obs/debian/changelog b/packaging/obs/debian/changelog deleted file mode 100644 index b6091cb3..00000000 --- a/packaging/obs/debian/changelog +++ /dev/null @@ -1,6 +0,0 @@ -copypaste (@VERSION@-1) unstable; urgency=medium - - * Automated release from upstream tag v@VERSION@. - Full notes: https://github.com/rgdevment/CopyPaste/releases/tag/v@VERSION@ - - -- rgdevment Thu, 23 Apr 2026 00:00:00 +0000 diff --git a/packaging/obs/debian/control b/packaging/obs/debian/control deleted file mode 100644 index 25c588d1..00000000 --- a/packaging/obs/debian/control +++ /dev/null @@ -1,20 +0,0 @@ -Source: copypaste -Section: x11 -Priority: optional -Maintainer: rgdevment -Build-Depends: debhelper-compat (= 13) -Standards-Version: 4.6.2 -Homepage: https://github.com/rgdevment/CopyPaste - -Package: copypaste -Architecture: amd64 -Depends: ${misc:Depends}, - libayatana-appindicator3-1, - libkeybinder-3.0-0, - libgtk-3-0 | libgtk-3-0t64, - libx11-6, - libxtst6 -Description: Free, open source clipboard manager and clipboard history tool - CopyPaste is a free, open source, local-first clipboard manager and - clipboard history tool for X11 sessions on Linux. No telemetry, no - accounts, no cloud — your clipboard data never leaves your computer. diff --git a/packaging/obs/debian/copyright b/packaging/obs/debian/copyright deleted file mode 100644 index 3f256596..00000000 --- a/packaging/obs/debian/copyright +++ /dev/null @@ -1,21 +0,0 @@ -Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: CopyPaste -Upstream-Contact: rgdevment -Source: https://github.com/rgdevment/CopyPaste - -Files: * -Copyright: 2024-2026 rgdevment -License: GPL-3.0-only - -License: GPL-3.0-only - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, version 3 of the License. - . - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - General Public License for more details. - . - On Debian systems, the complete text of the GNU General Public - License version 3 can be found in `/usr/share/common-licenses/GPL-3'. diff --git a/packaging/obs/debian/rules b/packaging/obs/debian/rules deleted file mode 100644 index 4bf5598a..00000000 --- a/packaging/obs/debian/rules +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/make -f - -%: - dh $@ - -override_dh_auto_build: - -override_dh_auto_install: - install -d debian/copypaste/opt/copypaste - cp -a bundle/. debian/copypaste/opt/copypaste/ - chmod 0755 debian/copypaste/opt/copypaste/copypaste - install -d debian/copypaste/usr/bin - ln -s /opt/copypaste/copypaste debian/copypaste/usr/bin/copypaste - install -Dm644 packaging/com.rgdevment.copypaste.desktop \ - debian/copypaste/usr/share/applications/com.rgdevment.copypaste.desktop - install -Dm644 packaging/icon_app_256.png \ - debian/copypaste/usr/share/icons/hicolor/256x256/apps/com.rgdevment.copypaste.png - -override_dh_strip: - -override_dh_dwz: - -override_dh_shlibdeps: diff --git a/packaging/obs/debian/source/format b/packaging/obs/debian/source/format deleted file mode 100644 index 163aaf8d..00000000 --- a/packaging/obs/debian/source/format +++ /dev/null @@ -1 +0,0 @@ -3.0 (quilt) diff --git a/release-manifest.json b/release-manifest.json index b282018f..8791f916 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -21,10 +21,7 @@ "command": "brew upgrade copypaste" }, "github_linux": { - "url": "https://github.com/rgdevment/CopyPaste/releases/latest" - }, - "appimage": { - "url": "https://github.com/rgdevment/CopyPaste/releases/latest" + "url": "https://github.com/rgdevment/CopyPaste/releases/tag/v2.11.0" } }, "releaseNotes": { diff --git a/resources/copypaste_v2_en_4_multiplatform.png b/resources/copypaste_v2_en_4_multiplatform.png index 468cc37b..53e045b0 100644 Binary files a/resources/copypaste_v2_en_4_multiplatform.png and b/resources/copypaste_v2_en_4_multiplatform.png differ diff --git a/resources/copypaste_v2_en_screenshot4_multiplatform.png b/resources/copypaste_v2_en_screenshot4_multiplatform.png index 2fac237a..91562388 100644 Binary files a/resources/copypaste_v2_en_screenshot4_multiplatform.png and b/resources/copypaste_v2_en_screenshot4_multiplatform.png differ diff --git a/resources/copypaste_v2_es_4_multiplatform.png b/resources/copypaste_v2_es_4_multiplatform.png index 0b46194f..8a05a22b 100644 Binary files a/resources/copypaste_v2_es_4_multiplatform.png and b/resources/copypaste_v2_es_4_multiplatform.png differ diff --git a/resources/copypaste_v2_screenshot4_multiplatform.png b/resources/copypaste_v2_screenshot4_multiplatform.png index 5fee429d..d277d432 100644 Binary files a/resources/copypaste_v2_screenshot4_multiplatform.png and b/resources/copypaste_v2_screenshot4_multiplatform.png differ diff --git a/resources/scripts/linux-package-smoke.sh b/resources/scripts/linux-package-smoke.sh deleted file mode 100644 index a000a8ad..00000000 --- a/resources/scripts/linux-package-smoke.sh +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - echo "Usage: $0 " >&2 -} - -if [[ $# -ne 2 ]]; then - usage - exit 1 -fi - -package_type="$1" -package_path="$2" - -if [[ "$package_path" = /* ]]; then - host_package="$package_path" -else - host_package="$PWD/$package_path" -fi - -if [[ ! -f "$host_package" ]]; then - echo "Package file not found: $host_package" >&2 - exit 1 -fi - -if [[ "$package_type" == "deb" ]]; then - echo "[smoke] Running deb smoke in ubuntu:22.04" - docker run --rm -v "$host_package:/tmp/copypaste.deb:ro" ubuntu:22.04 bash -lc ' - set -euo pipefail - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y file /tmp/copypaste.deb - - BIN=$(command -v copypaste || true) - if [[ -z "$BIN" ]]; then - BIN=$(find /usr -type f -name copypaste 2>/dev/null | head -n 1) - fi - test -n "$BIN" - - BIN_REAL=$(readlink -f "$BIN" || echo "$BIN") - APP_DIR=$(dirname "$BIN_REAL") - LIB_PATHS=() - for candidate in "$APP_DIR/lib" "/usr/share/copypaste/lib"; do - if [[ -d "$candidate" ]]; then - LIB_PATHS+=("$candidate") - fi - done - if [[ "${#LIB_PATHS[@]}" -gt 0 ]]; then - export LD_LIBRARY_PATH="$(IFS=:; echo "${LIB_PATHS[*]}"):${LD_LIBRARY_PATH:-}" - echo "Using LD_LIBRARY_PATH=$LD_LIBRARY_PATH" - fi - - FLUTTER_GTK_LIB="" - for candidate in "${LIB_PATHS[@]}"; do - if [[ -f "$candidate/libflutter_linux_gtk.so" ]]; then - FLUTTER_GTK_LIB="$candidate/libflutter_linux_gtk.so" - break - fi - done - - mapfile -t ELF_FILES < <( - dpkg -L copypaste | while read -r path; do - [[ -f "$path" ]] || continue - if file -b "$path" | grep -Eq "ELF .* (executable|shared object)"; then - echo "$path" - fi - done - ) - - test "${#ELF_FILES[@]}" -gt 0 - MAX_IGNORED_FLUTTER_GTK=30 - ignored_flutter_gtk=0 - missing=0 - ignored_dartjni=0 - for elf in "${ELF_FILES[@]}"; do - echo "Checking ELF deps: $elf" - - # libdartjni.so is an Android JNI bridge pulled transitively by - # path_provider_android -> jni. It requires libjvm.so which is - # never present on desktop Linux. Skip it entirely. - if [[ "$(basename "$elf")" == "libdartjni.so" ]]; then - echo "Skipping $elf (Android JNI bridge, not used on Linux)" - ignored_dartjni=$((ignored_dartjni + 1)) - continue - fi - - ldd_output=$(ldd "$elf" 2>&1 || true) - echo "$ldd_output" >> /tmp/ldd.out - - missing_lines=$(echo "$ldd_output" | grep "not found" || true) - if [[ -n "$missing_lines" ]]; then - filtered_missing="$missing_lines" - if [[ -n "$FLUTTER_GTK_LIB" ]]; then - ignored_in_block=$(echo "$filtered_missing" | grep -Ec "libflutter_linux_gtk\.so[[:space:]]*=>[[:space:]]*not found" || true) - ignored_flutter_gtk=$((ignored_flutter_gtk + ignored_in_block)) - filtered_missing=$(echo "$filtered_missing" | grep -vE "libflutter_linux_gtk\.so[[:space:]]*=>[[:space:]]*not found" || true) - filtered_missing=$(echo "$filtered_missing" | sed "/^[[:space:]]*$/d" || true) - if [[ -n "$missing_lines" && -z "$filtered_missing" ]]; then - echo "Ignoring plugin-local unresolved libflutter_linux_gtk.so (resolved via bundled runtime at $FLUTTER_GTK_LIB)" - fi - fi - - if [[ -n "$filtered_missing" ]]; then - echo "Missing libraries in: $elf" - echo "$filtered_missing" - missing=1 - fi - fi - done - - echo "[smoke] deb summary: checked=${#ELF_FILES[@]} ignored_flutter_linux_gtk=$ignored_flutter_gtk ignored_dartjni=$ignored_dartjni missing=$missing" - if [[ "$ignored_flutter_gtk" -gt "$MAX_IGNORED_FLUTTER_GTK" ]]; then - echo "[smoke] deb guardrail failed: too many ignored libflutter_linux_gtk.so entries ($ignored_flutter_gtk > $MAX_IGNORED_FLUTTER_GTK)" - exit 1 - fi - - if [[ "$missing" -ne 0 ]]; then - echo "[smoke] deb package has unresolved shared libraries" - exit 1 - fi - - echo "[smoke] deb package passed" - ' -elif [[ "$package_type" == "rpm" ]]; then - echo "[smoke] Running rpm smoke in fedora:40" - docker run --rm -v "$host_package:/tmp/copypaste.rpm:ro" fedora:40 bash -lc ' - set -euo pipefail - dnf -y install file /tmp/copypaste.rpm - - BIN=$(command -v copypaste || true) - if [[ -z "$BIN" ]]; then - BIN=$(find /usr -type f -name copypaste 2>/dev/null | head -n 1) - fi - test -n "$BIN" - - BIN_REAL=$(readlink -f "$BIN" || echo "$BIN") - APP_DIR=$(dirname "$BIN_REAL") - LIB_PATHS=() - for candidate in "$APP_DIR/lib" "/usr/share/copypaste/lib"; do - if [[ -d "$candidate" ]]; then - LIB_PATHS+=("$candidate") - fi - done - if [[ "${#LIB_PATHS[@]}" -gt 0 ]]; then - export LD_LIBRARY_PATH="$(IFS=:; echo "${LIB_PATHS[*]}"):${LD_LIBRARY_PATH:-}" - echo "Using LD_LIBRARY_PATH=$LD_LIBRARY_PATH" - fi - - FLUTTER_GTK_LIB="" - for candidate in "${LIB_PATHS[@]}"; do - if [[ -f "$candidate/libflutter_linux_gtk.so" ]]; then - FLUTTER_GTK_LIB="$candidate/libflutter_linux_gtk.so" - break - fi - done - - mapfile -t ELF_FILES < <( - rpm -ql copypaste | while read -r path; do - [[ -f "$path" ]] || continue - if file -b "$path" | grep -Eq "ELF .* (executable|shared object)"; then - echo "$path" - fi - done - ) - - test "${#ELF_FILES[@]}" -gt 0 - MAX_IGNORED_FLUTTER_GTK=30 - ignored_flutter_gtk=0 - missing=0 - ignored_dartjni=0 - for elf in "${ELF_FILES[@]}"; do - echo "Checking ELF deps: $elf" - - if [[ "$(basename "$elf")" == "libdartjni.so" ]]; then - echo "Skipping $elf (Android JNI bridge, not used on Linux)" - ignored_dartjni=$((ignored_dartjni + 1)) - continue - fi - - ldd_output=$(ldd "$elf" 2>&1 || true) - echo "$ldd_output" >> /tmp/ldd.out - - missing_lines=$(echo "$ldd_output" | grep "not found" || true) - if [[ -n "$missing_lines" ]]; then - filtered_missing="$missing_lines" - if [[ -n "$FLUTTER_GTK_LIB" ]]; then - ignored_in_block=$(echo "$filtered_missing" | grep -Ec "libflutter_linux_gtk\.so[[:space:]]*=>[[:space:]]*not found" || true) - ignored_flutter_gtk=$((ignored_flutter_gtk + ignored_in_block)) - filtered_missing=$(echo "$filtered_missing" | grep -vE "libflutter_linux_gtk\.so[[:space:]]*=>[[:space:]]*not found" || true) - filtered_missing=$(echo "$filtered_missing" | sed "/^[[:space:]]*$/d" || true) - if [[ -n "$missing_lines" && -z "$filtered_missing" ]]; then - echo "Ignoring plugin-local unresolved libflutter_linux_gtk.so (resolved via bundled runtime at $FLUTTER_GTK_LIB)" - fi - fi - - if [[ -n "$filtered_missing" ]]; then - echo "Missing libraries in: $elf" - echo "$filtered_missing" - missing=1 - fi - fi - done - - echo "[smoke] rpm summary: checked=${#ELF_FILES[@]} ignored_flutter_linux_gtk=$ignored_flutter_gtk ignored_dartjni=$ignored_dartjni missing=$missing" - if [[ "$ignored_flutter_gtk" -gt "$MAX_IGNORED_FLUTTER_GTK" ]]; then - echo "[smoke] rpm guardrail failed: too many ignored libflutter_linux_gtk.so entries ($ignored_flutter_gtk > $MAX_IGNORED_FLUTTER_GTK)" - exit 1 - fi - - if [[ "$missing" -ne 0 ]]; then - echo "[smoke] rpm package has unresolved shared libraries" - exit 1 - fi - - echo "[smoke] rpm package passed" - ' -else - usage - exit 1 -fi