From 41fd7555d83b43bcfc9f199647ae7c1fec546370 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 6 Aug 2026 16:40:58 -0400 Subject: [PATCH 1/5] feat: enhance release workflow with build provenance and update Scoop bucket --- .github/workflows/release.yml | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7d8356..9ff3b46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,11 @@ jobs: timeout-minutes: 10 name: Create GitHub Release + permissions: + contents: write + id-token: write + attestations: write + steps: - uses: actions/checkout@v6 with: @@ -81,6 +86,17 @@ jobs: with: path: artifacts + - uses: actions/attest-build-provenance@v4 + with: + subject-path: | + 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 with: @@ -389,3 +405,58 @@ jobs: git push origin main echo "Homebrew Tap updated: cask ${CASK_NAME} and formula ${FORMULA_FILE} → ${VERSION}" + + update-scoop-bucket: + runs-on: ubuntu-latest + needs: github-release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + timeout-minutes: 5 + name: Update Scoop Bucket + + steps: + - name: Update Scoop Bucket + env: + GH_TOKEN: ${{ secrets.GIST_TOKEN }} + run: | + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + BASE="https://github.com/${{ github.repository }}/releases/download/${TAG}" + SETUP="CopyPaste_${VERSION}_x64_Setup.exe" + + if [[ "$VERSION" == *-* ]]; then + NAME="copypaste-beta"; SUFFIX=" (beta)" + else + NAME="copypaste"; SUFFIX="" + fi + + curl -fSL -o "/tmp/${SETUP}" "${BASE}/${SETUP}" + SHA=$(sha256sum "/tmp/${SETUP}" | awk '{print $1}') + + git clone "https://x-access-token:${GH_TOKEN}@github.com/rgdevment/scoop-bucket.git" /tmp/bucket + cd /tmp/bucket + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + mkdir -p bucket + + jq -n \ + --arg version "$VERSION" \ + --arg desc "Clipboard history manager${SUFFIX}" \ + --arg home "https://github.com/${{ github.repository }}" \ + --arg url "${BASE}/${SETUP}" \ + --arg hash "$SHA" \ + '{ + version: $version, + description: $desc, + homepage: $home, + license: "GPL-3.0-only", + architecture: {"64bit": {url: $url, hash: $hash}}, + innosetup: true, + extract_dir: "{app}", + shortcuts: [["copypaste.exe", "CopyPaste"]] + }' > "bucket/${NAME}.json" + + git add "bucket/${NAME}.json" + git commit -m "${NAME} ${VERSION}" + git push origin main + + echo "Scoop bucket updated: ${NAME} → ${VERSION}" From 5a9e3bbacb67abd876b8bb96fbd16e91eb865b83 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 6 Aug 2026 17:30:13 -0400 Subject: [PATCH 2/5] fix: preserve formatting when re-copying text and enhance related tests --- README.md | 1 + core/lib/services/clipboard_service.dart | 19 ++++++-- core/test/clipboard_service_test.dart | 61 ++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 79b8866..d495e15 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ If you care about privacy and control, this clipboard manager is made for you. R - **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. +- **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 diff --git a/core/lib/services/clipboard_service.dart b/core/lib/services/clipboard_service.dart index 692fb97..720c61d 100644 --- a/core/lib/services/clipboard_service.dart +++ b/core/lib/services/clipboard_service.dart @@ -155,12 +155,17 @@ class ClipboardService { return elapsed < pasteIgnoreWindowMs; } - /// Rebuilds the `rtf`/`html` keys from the copy being processed. + /// Refreshes the `rtf`/`html` keys from the copy being processed. /// - /// These keys describe the *last* copy, so re-copying the same text as plain - /// must drop a stale RTF: otherwise the item would keep claiming a format the - /// clipboard no longer carries, and pasting would restore it. Keys owned by - /// other flows (media metadata) are preserved. + /// A plain copy of text already in the history leaves them untouched: the + /// styles are a layer over `content`, never a replacement for it, and "paste + /// as plain text" already serves the unstyled view without destroying that + /// layer. Dropping it would be irreversible. + /// + /// When the copy does carry styles, both keys are replaced together — a copy + /// bringing only HTML must not leave the previous RTF behind, or the item + /// would mix payloads from two different sources. Keys owned by other flows + /// (media metadata) are always preserved. String? _mergeFormatMetadata( String? current, List? rtfBytes, @@ -173,6 +178,10 @@ class ClipboardService { if (decoded is Map) meta.addAll(decoded); } catch (_) {} } + final carriesFormat = + (rtfBytes != null && rtfBytes.isNotEmpty) || + (htmlBytes != null && htmlBytes.isNotEmpty); + if (!carriesFormat) return meta.isEmpty ? null : jsonEncode(meta); meta.remove('rtf'); meta.remove('html'); if (rtfBytes != null) meta['rtf'] = base64Encode(rtfBytes); diff --git a/core/test/clipboard_service_test.dart b/core/test/clipboard_service_test.dart index d82c9d0..7e1a2de 100644 --- a/core/test/clipboard_service_test.dart +++ b/core/test/clipboard_service_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -117,22 +118,72 @@ void main() { expect(rich.hasRichText, isTrue); }); - test('re-copying as plain clears a stale rtf', () async { + test('re-copying as plain keeps the stored format', () async { final rich = await service.processText( 'same text', ClipboardContentType.text, rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], + htmlBytes: [0x3C, 0x62, 0x3E], ); - expect(rich!.hasRichText, isTrue); final plain = await service.processText( 'same text', ClipboardContentType.text, ); - expect(plain!.id, equals(rich.id)); - expect(plain.hasRichText, isFalse); - expect(plain.metadata, isNull); + expect(plain!.id, equals(rich!.id)); + expect(plain.hasRichText, isTrue); + expect(plain.hasFormatting, isTrue); + }); + + test('an empty format payload does not clear the stored one', () async { + await service.processText( + 'same text', + ClipboardContentType.text, + rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], + ); + + final plain = await service.processText( + 'same text', + ClipboardContentType.text, + rtfBytes: const [], + htmlBytes: const [], + ); + + expect(plain!.hasRichText, isTrue); + }); + + test('a styled copy replaces both format keys at once', () async { + await service.processText( + 'same text', + ClipboardContentType.text, + rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], + ); + + final htmlOnly = await service.processText( + 'same text', + ClipboardContentType.text, + htmlBytes: [0x3C, 0x62, 0x3E], + ); + + final meta = jsonDecode(htmlOnly!.metadata!) as Map; + expect(meta.containsKey('rtf'), isFalse); + expect(meta['html'], isNotEmpty); + }); + + test('a plain re-copy preserves keys owned by other flows', () async { + final first = await service.processText( + 'media caption', + ClipboardContentType.text, + ); + await service.updateMetadata(first!.id, '{"duration":42}'); + + final second = await service.processText( + 'media caption', + ClipboardContentType.text, + ); + + expect(second!.metadata, contains('duration')); }); test('metadata refresh preserves keys owned by other flows', () async { From fa5b69cd8f220a16d9d1e8067591fc7af004eb19 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 6 Aug 2026 18:07:41 -0400 Subject: [PATCH 3/5] fix: enhance Scoop bucket update process with error handling and conditional commit --- .github/workflows/release.yml | 71 +++++++++++++++---- app/lib/l10n/app_en.arb | 7 +- app/lib/l10n/app_es.arb | 2 +- app/lib/l10n/app_localizations.dart | 6 +- app/lib/l10n/app_localizations_en.dart | 4 +- app/lib/l10n/app_localizations_es.dart | 4 +- app/lib/screens/blocked_version_screen.dart | 14 +++- app/lib/services/install_channel.dart | 17 ++++- .../screens/blocked_version_screen_test.dart | 21 +++++- app/test/services/install_channel_test.dart | 25 +++++++ release-manifest.json | 3 + 11 files changed, 149 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ff3b46..5a9fb2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,6 +86,32 @@ jobs: with: path: artifacts + # attest-build-provenance only fails when *every* pattern comes up + # empty, so a missing artifact would otherwise be signed away in + # silence. Each pattern must match on its own. + - name: Verify every artifact to attest is present + run: | + set -euo pipefail + shopt -s globstar nullglob + status=0 + for pattern in \ + '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'; do + matches=( $pattern ) + if (( ${#matches[@]} == 0 )); then + echo "::error::No artifact matched '${pattern}'" + status=1 + else + printf '%s -> %s\n' "$pattern" "${matches[*]}" + fi + done + exit $status + - uses: actions/attest-build-provenance@v4 with: subject-path: | @@ -306,6 +332,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GIST_TOKEN }} run: | + set -o pipefail + TAG="${GITHUB_REF_NAME}" VERSION="${TAG#v}" @@ -316,12 +344,14 @@ jobs: DEB_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${DEB_NAME}" echo "Downloading DMG to compute SHA256..." - curl -fSL -o "/tmp/${DMG_NAME}" "${DMG_URL}" + 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 -o "/tmp/${DEB_NAME}" "${DEB_URL}" + 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}" @@ -401,10 +431,13 @@ jobs: FORMULA_EOF git add "${CASK_FILE}" "${FORMULA_FILE}" - git commit -m "Update ${CASK_NAME} and Linux formula to ${VERSION}" - git push origin main - - echo "Homebrew Tap updated: cask ${CASK_NAME} and formula ${FORMULA_FILE} → ${VERSION}" + if git diff --cached --quiet; then + echo "Homebrew Tap already at ${VERSION}, nothing to push" + else + git commit -m "Update ${CASK_NAME} and Linux formula to ${VERSION}" + git push origin main + echo "Homebrew Tap updated: cask ${CASK_NAME} and formula ${FORMULA_FILE} → ${VERSION}" + fi update-scoop-bucket: runs-on: ubuntu-latest @@ -418,6 +451,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GIST_TOKEN }} run: | + set -o pipefail + TAG="${GITHUB_REF_NAME}" VERSION="${TAG#v}" BASE="https://github.com/${{ github.repository }}/releases/download/${TAG}" @@ -429,9 +464,15 @@ jobs: NAME="copypaste"; SUFFIX="" fi - curl -fSL -o "/tmp/${SETUP}" "${BASE}/${SETUP}" + curl -fSL --retry 5 --retry-delay 10 --retry-all-errors \ + -o "/tmp/${SETUP}" "${BASE}/${SETUP}" SHA=$(sha256sum "/tmp/${SETUP}" | awk '{print $1}') + # Scoop only unpacks the installer, so the uninstaller never runs + # and the Run value the app writes on first launch would outlive it. + RUN_KEY='HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' + UNINSTALL="Remove-ItemProperty -Path '${RUN_KEY}' -Name 'CopyPaste' -ErrorAction SilentlyContinue" + git clone "https://x-access-token:${GH_TOKEN}@github.com/rgdevment/scoop-bucket.git" /tmp/bucket cd /tmp/bucket git config user.name "github-actions[bot]" @@ -444,6 +485,8 @@ jobs: --arg home "https://github.com/${{ github.repository }}" \ --arg url "${BASE}/${SETUP}" \ --arg hash "$SHA" \ + --arg shortcut "CopyPaste${SUFFIX}" \ + --arg uninstall "$UNINSTALL" \ '{ version: $version, description: $desc, @@ -452,11 +495,15 @@ jobs: architecture: {"64bit": {url: $url, hash: $hash}}, innosetup: true, extract_dir: "{app}", - shortcuts: [["copypaste.exe", "CopyPaste"]] + shortcuts: [["CopyPaste.exe", $shortcut]], + pre_uninstall: [$uninstall] }' > "bucket/${NAME}.json" git add "bucket/${NAME}.json" - git commit -m "${NAME} ${VERSION}" - git push origin main - - echo "Scoop bucket updated: ${NAME} → ${VERSION}" + if git diff --cached --quiet; then + echo "Scoop bucket already at ${VERSION}, nothing to push" + else + git commit -m "${NAME} ${VERSION}" + git push origin main + echo "Scoop bucket updated: ${NAME} → ${VERSION}" + fi diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 351fb57..786807a 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -533,8 +533,11 @@ "updateActionOpenStore": "Open Microsoft Store", "@updateActionOpenStore": { "description": "Action button to open the MS Store update page" }, - "updateActionCopyBrew": "Copy brew command", - "@updateActionCopyBrew": { "description": "Action button to copy the Homebrew upgrade command" }, + "updateActionCopyCommand": "Copy {tool} command", + "@updateActionCopyCommand": { + "description": "Action button to copy the package manager upgrade command", + "placeholders": { "tool": { "type": "String" } } + }, "updateActionCopied": "Copied to clipboard", "@updateActionCopied": { "description": "Snack/tooltip shown after copying the upgrade command" }, diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index a6a4cef..b223a9c 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -268,7 +268,7 @@ "updateBadgeImportant": "v{version} disponible — actualizaci\u00f3n importante", "updateActionDownload": "Descargar instalador", "updateActionOpenStore": "Abrir Microsoft Store", - "updateActionCopyBrew": "Copiar comando brew", + "updateActionCopyCommand": "Copiar comando {tool}", "updateActionCopied": "Copiado al portapapeles", "blockedTitle": "Actualizaci\u00f3n requerida", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index d78099e..379da34 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -1412,11 +1412,11 @@ abstract class AppLocalizations { /// **'Open Microsoft Store'** String get updateActionOpenStore; - /// Action button to copy the Homebrew upgrade command + /// Action button to copy the package manager upgrade command /// /// In en, this message translates to: - /// **'Copy brew command'** - String get updateActionCopyBrew; + /// **'Copy {tool} command'** + String updateActionCopyCommand(String tool); /// Snack/tooltip shown after copying the upgrade command /// diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 3c199d1..d673e60 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -732,7 +732,9 @@ class AppLocalizationsEn extends AppLocalizations { String get updateActionOpenStore => 'Open Microsoft Store'; @override - String get updateActionCopyBrew => 'Copy brew command'; + String updateActionCopyCommand(String tool) { + return 'Copy $tool command'; + } @override String get updateActionCopied => 'Copied to clipboard'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 77f66e1..e6780a4 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -738,7 +738,9 @@ class AppLocalizationsEs extends AppLocalizations { String get updateActionOpenStore => 'Abrir Microsoft Store'; @override - String get updateActionCopyBrew => 'Copiar comando brew'; + String updateActionCopyCommand(String tool) { + return 'Copiar comando $tool'; + } @override String get updateActionCopied => 'Copiado al portapapeles'; diff --git a/app/lib/screens/blocked_version_screen.dart b/app/lib/screens/blocked_version_screen.dart index d8518fc..c51b064 100644 --- a/app/lib/screens/blocked_version_screen.dart +++ b/app/lib/screens/blocked_version_screen.dart @@ -131,11 +131,12 @@ class BlockedVersionScreen extends StatelessWidget { ); } - if (channel == InstallChannel.homebrew || channel == InstallChannel.snap) { + final tool = _packageManagerName(channel); + if (tool != null) { final cmd = info.command; if (cmd == null) return null; return _BlockAction( - label: l.updateActionCopyBrew, + label: l.updateActionCopyCommand(tool), onPressed: () async { await Clipboard.setData(ClipboardData(text: cmd)); if (!context.mounted) return; @@ -153,6 +154,15 @@ class BlockedVersionScreen extends StatelessWidget { onPressed: () => UrlHelper.open(url), ); } + + // Channels whose update is a command the user runs, not a download. + static String? _packageManagerName(InstallChannel channel) => + switch (channel) { + InstallChannel.homebrew => 'brew', + InstallChannel.snap => 'snap', + InstallChannel.scoop => 'scoop', + _ => null, + }; } class _BlockAction { diff --git a/app/lib/services/install_channel.dart b/app/lib/services/install_channel.dart index 45a125d..0d9986b 100644 --- a/app/lib/services/install_channel.dart +++ b/app/lib/services/install_channel.dart @@ -10,6 +10,7 @@ const bool _isStoreBuild = bool.fromEnvironment( enum InstallChannel { msStore, githubWindows, + scoop, githubMacos, homebrew, githubLinux, @@ -52,7 +53,10 @@ class InstallChannelDetector { return InstallChannel.githubLinux; } - if (host == HostPlatform.windows) return InstallChannel.githubWindows; + if (host == HostPlatform.windows) { + if (_isScoopPath(path)) return InstallChannel.scoop; + return InstallChannel.githubWindows; + } return InstallChannel.unknown; } @@ -70,6 +74,8 @@ class InstallChannelDetector { return 'msstore'; case InstallChannel.githubWindows: return 'github_windows'; + case InstallChannel.scoop: + return 'scoop'; case InstallChannel.githubMacos: return 'github_macos'; case InstallChannel.homebrew: @@ -90,4 +96,13 @@ class InstallChannelDetector { path.contains('/opt/homebrew/') || path.contains('/usr/local/Cellar/'); } + + /// Scoop keeps every app under `/apps//`, and the + /// root is relocatable, so the layout below the root is what identifies it. + static bool _isScoopPath(String path) { + final lower = path.toLowerCase(); + return lower.contains('/scoop/apps/') || + lower.contains('/apps/copypaste/') || + lower.contains('/apps/copypaste-beta/'); + } } diff --git a/app/test/screens/blocked_version_screen_test.dart b/app/test/screens/blocked_version_screen_test.dart index 9e5a0b7..31b66b5 100644 --- a/app/test/screens/blocked_version_screen_test.dart +++ b/app/test/screens/blocked_version_screen_test.dart @@ -22,6 +22,7 @@ ReleaseManifest _manifest({ String? homebrewCommand, String? msStoreUrl, String? snapCommand, + String? scoopCommand, }) { return ReleaseManifest( schema: 1, @@ -36,6 +37,7 @@ ReleaseManifest _manifest({ '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) @@ -112,7 +114,7 @@ void main() { ); await tester.pumpAndSettle(); final l = await AppLocalizations.delegate.load(const Locale('en')); - expect(find.text(l.updateActionCopyBrew), findsOneWidget); + expect(find.text(l.updateActionCopyCommand('brew')), findsOneWidget); }); testWidgets('shows Copy command button for snap channel', (tester) async { @@ -127,7 +129,22 @@ void main() { ); await tester.pumpAndSettle(); final l = await AppLocalizations.delegate.load(const Locale('en')); - expect(find.text(l.updateActionCopyBrew), findsOneWidget); + expect(find.text(l.updateActionCopyCommand('snap')), findsOneWidget); + }); + + testWidgets('shows Copy command button for scoop channel', (tester) async { + InstallChannelDetector.channelOverride = InstallChannel.scoop; + await tester.pumpWidget( + _wrap( + BlockedVersionScreen( + currentVersion: '2.2.6', + manifest: _manifest(scoopCommand: 'scoop update copypaste'), + ), + ), + ); + await tester.pumpAndSettle(); + final l = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text(l.updateActionCopyCommand('scoop')), findsOneWidget); }); testWidgets('shows fallback hint when channel has no info', (tester) async { diff --git a/app/test/services/install_channel_test.dart b/app/test/services/install_channel_test.dart index 1494065..6423bc4 100644 --- a/app/test/services/install_channel_test.dart +++ b/app/test/services/install_channel_test.dart @@ -26,6 +26,31 @@ void main() { ); expect(c, InstallChannel.snap); }); + + test('detects scoop installs on the default root', () { + final c = InstallChannelDetector.detect( + execPathOverride: + r'C:\Users\dev\scoop\apps\copypaste\current\CopyPaste.exe', + platformOverride: HostPlatform.windows, + ); + expect(c, InstallChannel.scoop); + }); + + test('detects scoop installs on a relocated root', () { + final c = InstallChannelDetector.detect( + execPathOverride: r'D:\tools\apps\copypaste-beta\2.9.0\CopyPaste.exe', + platformOverride: HostPlatform.windows, + ); + expect(c, InstallChannel.scoop); + }); + + test('a standalone install is still githubWindows', () { + final c = InstallChannelDetector.detect( + execPathOverride: r'C:\Users\dev\AppData\Local\CopyPaste\CopyPaste.exe', + platformOverride: HostPlatform.windows, + ); + expect(c, InstallChannel.githubWindows); + }); }); group('manifestKey', () { diff --git a/release-manifest.json b/release-manifest.json index c9e9085..b282018 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -8,6 +8,9 @@ "github_windows": { "url": "https://github.com/rgdevment/CopyPaste/releases/latest" }, + "scoop": { + "command": "scoop update copypaste" + }, "msstore": { "url": "ms-windows-store://pdp/?productid=PLACEHOLDER" }, From 02d142799a52546be16965ce9d51a93d8be99b40 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 6 Aug 2026 18:20:53 -0400 Subject: [PATCH 4/5] fix: improve artifact verification and update commit logic for Homebrew and Scoop buckets --- .github/workflows/release.yml | 57 +++++++++++++----- app/lib/screens/blocked_version_screen.dart | 1 - app/lib/services/install_channel.dart | 3 +- app/lib/shell/startup_helper.dart | 24 ++++++-- .../shell/startup_helper_windows_test.dart | 58 +++++++++++++++++++ core/lib/services/clipboard_service.dart | 15 ++--- 6 files changed, 124 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a9fb2e..38c30db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,8 +87,7 @@ jobs: path: artifacts # attest-build-provenance only fails when *every* pattern comes up - # empty, so a missing artifact would otherwise be signed away in - # silence. Each pattern must match on its own. + # empty, so a single missing artifact would be signed away in silence. - name: Verify every artifact to attest is present run: | set -euo pipefail @@ -332,7 +331,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GIST_TOKEN }} run: | - set -o pipefail + set -euo pipefail TAG="${GITHUB_REF_NAME}" VERSION="${TAG#v}" @@ -433,11 +432,25 @@ jobs: git add "${CASK_FILE}" "${FORMULA_FILE}" if git diff --cached --quiet; then echo "Homebrew Tap already at ${VERSION}, nothing to push" - else - git commit -m "Update ${CASK_NAME} and Linux formula to ${VERSION}" - git push origin main - echo "Homebrew Tap updated: cask ${CASK_NAME} and formula ${FORMULA_FILE} → ${VERSION}" + exit 0 fi + git commit -m "Update ${CASK_NAME} and Linux formula to ${VERSION}" + + # Shared tap: a concurrent release can land between fetch and push. + for attempt in 1 2 3 4 5; do + if (( attempt > 1 )); then + sleep $(( (attempt - 1) * 5 )) + git fetch origin main + git rebase origin/main + fi + if git push origin HEAD:main; then + echo "Homebrew Tap updated: cask ${CASK_NAME} and formula ${FORMULA_FILE} → ${VERSION}" + exit 0 + fi + echo "Push rejected (attempt ${attempt})" + done + echo "::error::Could not push ${VERSION} to the Homebrew Tap" + exit 1 update-scoop-bucket: runs-on: ubuntu-latest @@ -451,7 +464,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GIST_TOKEN }} run: | - set -o pipefail + set -euo pipefail TAG="${GITHUB_REF_NAME}" VERSION="${TAG#v}" @@ -468,8 +481,8 @@ jobs: -o "/tmp/${SETUP}" "${BASE}/${SETUP}" SHA=$(sha256sum "/tmp/${SETUP}" | awk '{print $1}') - # Scoop only unpacks the installer, so the uninstaller never runs - # and the Run value the app writes on first launch would outlive it. + # Scoop unpacks the installer without ever running its + # uninstaller, so the Run value the app writes would outlive it. RUN_KEY='HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' UNINSTALL="Remove-ItemProperty -Path '${RUN_KEY}' -Name 'CopyPaste' -ErrorAction SilentlyContinue" @@ -494,7 +507,6 @@ jobs: license: "GPL-3.0-only", architecture: {"64bit": {url: $url, hash: $hash}}, innosetup: true, - extract_dir: "{app}", shortcuts: [["CopyPaste.exe", $shortcut]], pre_uninstall: [$uninstall] }' > "bucket/${NAME}.json" @@ -502,8 +514,23 @@ jobs: git add "bucket/${NAME}.json" if git diff --cached --quiet; then echo "Scoop bucket already at ${VERSION}, nothing to push" - else - git commit -m "${NAME} ${VERSION}" - git push origin main - echo "Scoop bucket updated: ${NAME} → ${VERSION}" + exit 0 fi + git commit -m "${NAME} ${VERSION}" + + # Three repos share this bucket: a concurrent release can land + # between fetch and push. + for attempt in 1 2 3 4 5; do + if (( attempt > 1 )); then + sleep $(( (attempt - 1) * 5 )) + git fetch origin main + git rebase origin/main + fi + if git push origin HEAD:main; then + echo "Scoop bucket updated: ${NAME} → ${VERSION}" + exit 0 + fi + echo "Push rejected (attempt ${attempt})" + done + echo "::error::Could not push ${NAME} ${VERSION} to the Scoop bucket" + exit 1 diff --git a/app/lib/screens/blocked_version_screen.dart b/app/lib/screens/blocked_version_screen.dart index c51b064..33b35a2 100644 --- a/app/lib/screens/blocked_version_screen.dart +++ b/app/lib/screens/blocked_version_screen.dart @@ -155,7 +155,6 @@ class BlockedVersionScreen extends StatelessWidget { ); } - // Channels whose update is a command the user runs, not a download. static String? _packageManagerName(InstallChannel channel) => switch (channel) { InstallChannel.homebrew => 'brew', diff --git a/app/lib/services/install_channel.dart b/app/lib/services/install_channel.dart index 0d9986b..2ba4ab1 100644 --- a/app/lib/services/install_channel.dart +++ b/app/lib/services/install_channel.dart @@ -97,8 +97,7 @@ class InstallChannelDetector { path.contains('/usr/local/Cellar/'); } - /// Scoop keeps every app under `/apps//`, and the - /// root is relocatable, so the layout below the root is what identifies it. + // The Scoop root is relocatable, so the layout below it is the tell. static bool _isScoopPath(String path) { final lower = path.toLowerCase(); return lower.contains('/scoop/apps/') || diff --git a/app/lib/shell/startup_helper.dart b/app/lib/shell/startup_helper.dart index ccde10f..c00cfb7 100644 --- a/app/lib/shell/startup_helper.dart +++ b/app/lib/shell/startup_helper.dart @@ -104,7 +104,7 @@ class StartupHelper { ); } else { if (runOnStartup) { - _setRegistryValue(Platform.resolvedExecutable); + _setRegistryValue(stableExecutablePath(Platform.resolvedExecutable)); } else { _removeRegistryValue(); } @@ -157,16 +157,30 @@ class StartupHelper { } } - // Detects executables running from a Flutter build folder (dev runs). - // Writing those paths to HKCU\...\Run produces stale entries that Windows - // renders with a generic icon and only the registry path text once the - // build folder is cleaned. + // Writing a build-folder path to HKCU\...\Run leaves an entry Windows renders + // with a generic icon once the folder is cleaned. @visibleForTesting static bool isDevBuildPath(String exePath) { final normalized = exePath.replaceAll('/', r'\').toLowerCase(); return normalized.contains(r'\build\windows\'); } + static final RegExp _versionedAppDir = RegExp( + r'^(.*[\\/]apps[\\/][^\\/]+[\\/])[^\\/]+([\\/].*)$', + caseSensitive: false, + ); + + /// `Platform.resolvedExecutable` reports the versioned target behind Scoop's + /// `current` junction, and that path dies on the next `scoop cleanup`. + @visibleForTesting + static String stableExecutablePath(String exePath) { + final match = _versionedAppDir.firstMatch(exePath); + if (match == null) return exePath; + final candidate = '${match.group(1)}current${match.group(2)}'; + if (candidate == exePath || !File(candidate).existsSync()) return exePath; + return candidate; + } + static void _setRegistryValue(String exePath) { if (!exePath.toLowerCase().endsWith('.exe') || !File(exePath).existsSync()) { diff --git a/app/test/shell/startup_helper_windows_test.dart b/app/test/shell/startup_helper_windows_test.dart index 945602f..822c345 100644 --- a/app/test/shell/startup_helper_windows_test.dart +++ b/app/test/shell/startup_helper_windows_test.dart @@ -97,6 +97,64 @@ void main() { }); }); + group('StartupHelper.stableExecutablePath', () { + late Directory root; + + setUp(() { + root = Directory.systemTemp.createTempSync('scoop_layout_'); + }); + + tearDown(() => root.deleteSync(recursive: true)); + + String seed(String versionDir, {bool withCurrent = true}) { + final versioned = Directory( + '${root.path}${Platform.pathSeparator}apps' + '${Platform.pathSeparator}copypaste' + '${Platform.pathSeparator}$versionDir', + )..createSync(recursive: true); + final exe = File( + '${versioned.path}${Platform.pathSeparator}CopyPaste.exe', + )..writeAsStringSync(''); + if (withCurrent) { + final current = Directory( + '${root.path}${Platform.pathSeparator}apps' + '${Platform.pathSeparator}copypaste' + '${Platform.pathSeparator}current', + )..createSync(recursive: true); + File( + '${current.path}${Platform.pathSeparator}CopyPaste.exe', + ).writeAsStringSync(''); + } + return exe.path; + } + + test('rewrites a versioned Scoop path to current', () { + final resolved = StartupHelper.stableExecutablePath(seed('2.9.0')); + expect(resolved, contains('current')); + expect(resolved, isNot(contains('2.9.0'))); + }); + + test('keeps the versioned path when current does not exist', () { + final versioned = seed('2.9.0', withCurrent: false); + expect(StartupHelper.stableExecutablePath(versioned), versioned); + }); + + test('leaves a path already on current untouched', () { + seed('2.9.0'); + final currentExe = + '${root.path}${Platform.pathSeparator}apps' + '${Platform.pathSeparator}copypaste' + '${Platform.pathSeparator}current' + '${Platform.pathSeparator}CopyPaste.exe'; + expect(StartupHelper.stableExecutablePath(currentExe), currentExe); + }); + + test('leaves a standalone install untouched', () { + const standalone = r'C:\Users\dev\AppData\Local\CopyPaste\CopyPaste.exe'; + expect(StartupHelper.stableExecutablePath(standalone), standalone); + }); + }); + // --------------------------------------------------------------------------- // apply() on Windows — MSIX path: calls enable/disable and clears registry // --------------------------------------------------------------------------- diff --git a/core/lib/services/clipboard_service.dart b/core/lib/services/clipboard_service.dart index 720c61d..5dc3400 100644 --- a/core/lib/services/clipboard_service.dart +++ b/core/lib/services/clipboard_service.dart @@ -155,17 +155,10 @@ class ClipboardService { return elapsed < pasteIgnoreWindowMs; } - /// Refreshes the `rtf`/`html` keys from the copy being processed. - /// - /// A plain copy of text already in the history leaves them untouched: the - /// styles are a layer over `content`, never a replacement for it, and "paste - /// as plain text" already serves the unstyled view without destroying that - /// layer. Dropping it would be irreversible. - /// - /// When the copy does carry styles, both keys are replaced together — a copy - /// bringing only HTML must not leave the previous RTF behind, or the item - /// would mix payloads from two different sources. Keys owned by other flows - /// (media metadata) are always preserved. + /// A plain copy leaves `rtf`/`html` untouched: styles are a layer over + /// `content` and "paste as plain text" already serves the unstyled view, so + /// dropping them would be an irreversible loss. A copy that does carry styles + /// replaces both keys at once, or the item would mix two sources. String? _mergeFormatMetadata( String? current, List? rtfBytes, From fd296a756bf4111601842c7d5843cb7ef7b31dc2 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 7 Aug 2026 09:10:35 -0400 Subject: [PATCH 5/5] fix: correct formatting in README for consistency in text styling --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d495e15..75c3177 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ If you care about privacy and control, this clipboard manager is made for you. R - **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. -- **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. +- **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