diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7d8356e..38c30db0 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,42 @@ jobs: with: path: artifacts + # attest-build-provenance only fails when *every* pattern comes up + # empty, so a single missing artifact would be signed away in silence. + - name: Verify every 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: | + 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: @@ -290,6 +331,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GIST_TOKEN }} run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" VERSION="${TAG#v}" @@ -300,12 +343,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}" @@ -385,7 +430,107 @@ jobs: FORMULA_EOF git add "${CASK_FILE}" "${FORMULA_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 push origin main - echo "Homebrew Tap updated: cask ${CASK_NAME} and formula ${FORMULA_FILE} → ${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 + 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: | + set -euo pipefail + + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + BASE="https://github.com/${{ github.repository }}/releases/download/${TAG}" + SETUP="CopyPaste_${VERSION}_x64_Setup.exe" + + if [[ "$VERSION" == *-* ]]; then + NAME="copypaste-beta"; SUFFIX=" (beta)" + else + NAME="copypaste"; SUFFIX="" + fi + + curl -fSL --retry 5 --retry-delay 10 --retry-all-errors \ + -o "/tmp/${SETUP}" "${BASE}/${SETUP}" + SHA=$(sha256sum "/tmp/${SETUP}" | awk '{print $1}') + + # Scoop unpacks the installer without ever running its + # uninstaller, so the Run value the app writes would outlive it. + RUN_KEY='HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' + UNINSTALL="Remove-ItemProperty -Path '${RUN_KEY}' -Name 'CopyPaste' -ErrorAction SilentlyContinue" + + git clone "https://x-access-token:${GH_TOKEN}@github.com/rgdevment/scoop-bucket.git" /tmp/bucket + cd /tmp/bucket + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + mkdir -p bucket + + jq -n \ + --arg version "$VERSION" \ + --arg desc "Clipboard history manager${SUFFIX}" \ + --arg home "https://github.com/${{ github.repository }}" \ + --arg url "${BASE}/${SETUP}" \ + --arg hash "$SHA" \ + --arg shortcut "CopyPaste${SUFFIX}" \ + --arg uninstall "$UNINSTALL" \ + '{ + version: $version, + description: $desc, + homepage: $home, + license: "GPL-3.0-only", + architecture: {"64bit": {url: $url, hash: $hash}}, + innosetup: true, + shortcuts: [["CopyPaste.exe", $shortcut]], + pre_uninstall: [$uninstall] + }' > "bucket/${NAME}.json" + + git add "bucket/${NAME}.json" + if git diff --cached --quiet; then + echo "Scoop bucket already at ${VERSION}, nothing to push" + exit 0 + fi + git commit -m "${NAME} ${VERSION}" + + # Three repos share this bucket: a concurrent release can land + # between fetch and push. + for attempt in 1 2 3 4 5; do + if (( attempt > 1 )); then + sleep $(( (attempt - 1) * 5 )) + git fetch origin main + git rebase origin/main + fi + if git push origin HEAD:main; then + echo "Scoop bucket updated: ${NAME} → ${VERSION}" + exit 0 + fi + echo "Push rejected (attempt ${attempt})" + done + echo "::error::Could not push ${NAME} ${VERSION} to the Scoop bucket" + exit 1 diff --git a/README.md b/README.md index 79b88664..75c3177c 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/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 351fb571..786807ae 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 a6a4cef6..b223a9c1 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 d78099ee..379da34d 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 3c199d16..d673e60c 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 77f66e18..e6780a4d 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 d8518fcd..33b35a2f 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,14 @@ class BlockedVersionScreen extends StatelessWidget { onPressed: () => UrlHelper.open(url), ); } + + 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 45a125d9..2ba4ab1d 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,12 @@ class InstallChannelDetector { path.contains('/opt/homebrew/') || path.contains('/usr/local/Cellar/'); } + + // The Scoop root is relocatable, so the layout below it is the tell. + static bool _isScoopPath(String path) { + final lower = path.toLowerCase(); + return lower.contains('/scoop/apps/') || + lower.contains('/apps/copypaste/') || + lower.contains('/apps/copypaste-beta/'); + } } diff --git a/app/lib/shell/startup_helper.dart b/app/lib/shell/startup_helper.dart index ccde10f8..c00cfb72 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/screens/blocked_version_screen_test.dart b/app/test/screens/blocked_version_screen_test.dart index 9e5a0b7a..31b66b57 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 14940653..6423bc44 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/app/test/shell/startup_helper_windows_test.dart b/app/test/shell/startup_helper_windows_test.dart index 945602fe..822c3455 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 692fb976..5dc34004 100644 --- a/core/lib/services/clipboard_service.dart +++ b/core/lib/services/clipboard_service.dart @@ -155,12 +155,10 @@ class ClipboardService { return elapsed < pasteIgnoreWindowMs; } - /// Rebuilds 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 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, @@ -173,6 +171,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 d82c9d02..7e1a2de5 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 { diff --git a/release-manifest.json b/release-manifest.json index c9e90854..b282018f 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" },