Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 149 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -290,6 +331,8 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GIST_TOKEN }}
run: |
set -euo pipefail

TAG="${GITHUB_REF_NAME}"
VERSION="${TAG#v}"

Expand All @@ -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}"

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
2 changes: 1 addition & 1 deletion app/lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions app/lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down
4 changes: 3 additions & 1 deletion app/lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 3 additions & 1 deletion app/lib/l10n/app_localizations_es.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 11 additions & 2 deletions app/lib/screens/blocked_version_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
16 changes: 15 additions & 1 deletion app/lib/services/install_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const bool _isStoreBuild = bool.fromEnvironment(
enum InstallChannel {
msStore,
githubWindows,
scoop,
githubMacos,
homebrew,
githubLinux,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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:
Expand All @@ -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/');
}
}
24 changes: 19 additions & 5 deletions app/lib/shell/startup_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class StartupHelper {
);
} else {
if (runOnStartup) {
_setRegistryValue(Platform.resolvedExecutable);
_setRegistryValue(stableExecutablePath(Platform.resolvedExecutable));
} else {
_removeRegistryValue();
}
Expand Down Expand Up @@ -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()) {
Expand Down
Loading
Loading