├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug_report.yml ├── scripts │ └── macOS.sh └── workflows │ ├── build.yml │ └── release.yml ├── src-tauri ├── build.rs ├── src │ ├── db │ │ ├── mod.rs │ │ ├── migrations │ │ │ ├── v3.sql │ │ │ ├── v2.sql │ │ │ └── v1.sql │ │ ├── settings.rs │ │ ├── database.rs │ │ └── history.rs │ ├── api │ │ ├── mod.rs │ │ ├── tray.rs │ │ ├── updater.rs │ │ ├── hotkeys.rs │ │ └── clipboard.rs │ ├── utils │ │ ├── mod.rs │ │ ├── favicon.rs │ │ ├── logger.rs │ │ ├── types.rs │ │ ├── keys.rs │ │ └── commands.rs │ └── main.rs ├── icons │ ├── icon.ico │ ├── icon.png │ ├── 32x32.png │ ├── icon.icns │ ├── 128x128.png │ ├── StoreLogo.png │ ├── 128x128@2x.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── .gitignore ├── capabilities │ └── default.json ├── tauri.conf.json └── Cargo.toml ├── public ├── apple.png ├── linux.png ├── logo.png ├── noise.webp ├── windows.png └── back_arrow.svg ├── fonts ├── CommitMono.woff2 ├── SFRoundedMedium.otf ├── SFRoundedRegular.otf └── SFRoundedSemiBold.otf ├── tsconfig.json ├── .gitignore ├── plugins ├── settings.ts └── history.ts ├── nuxt.config.ts ├── components ├── Noise.vue ├── Icons │ ├── File.vue │ ├── Image.vue │ ├── Text.vue │ ├── Ctrl.vue │ ├── Code.vue │ ├── Enter.vue │ ├── Link.vue │ ├── K.vue │ └── Cmd.vue ├── TopBar.vue ├── BottomBar.vue └── Result.vue ├── package.json ├── GET_STARTED.md ├── lib └── selectedResult.ts ├── app.vue ├── types ├── types.ts └── keys.ts ├── styles ├── index.scss └── settings.scss ├── README.md ├── README_ru.md └── pages ├── settings.vue └── index.vue /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 0pandadev 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /public/apple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/public/apple.png -------------------------------------------------------------------------------- /public/linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/public/linux.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/public/logo.png -------------------------------------------------------------------------------- /public/noise.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/public/noise.webp -------------------------------------------------------------------------------- /public/windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/public/windows.png -------------------------------------------------------------------------------- /src-tauri/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod database; 2 | pub mod history; 3 | pub mod settings; 4 | -------------------------------------------------------------------------------- /fonts/CommitMono.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/fonts/CommitMono.woff2 -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /fonts/SFRoundedMedium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/fonts/SFRoundedMedium.otf -------------------------------------------------------------------------------- /fonts/SFRoundedRegular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/fonts/SFRoundedRegular.otf -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/src/db/migrations/v3.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO settings (key, value) VALUES ('autostart', 'true'); 2 | -------------------------------------------------------------------------------- /fonts/SFRoundedSemiBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/fonts/SFRoundedSemiBold.otf -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/src/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod clipboard; 2 | pub mod hotkeys; 3 | pub mod tray; 4 | pub mod updater; 5 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /gen/schemas 5 | -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Qopy/main/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod commands; 2 | pub mod favicon; 3 | pub mod types; 4 | pub mod logger; 5 | pub mod keys; 6 | -------------------------------------------------------------------------------- /src-tauri/src/db/migrations/v2.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE history ADD COLUMN source TEXT DEFAULT 'System' NOT NULL; 2 | ALTER TABLE history ADD COLUMN source_icon TEXT; 3 | ALTER TABLE history ADD COLUMN language TEXT; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | .gitignore 26 | .vscode -------------------------------------------------------------------------------- /src-tauri/src/db/migrations/v1.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS settings ( 2 | key TEXT PRIMARY KEY, 3 | value TEXT NOT NULL 4 | ); 5 | 6 | CREATE TABLE IF NOT EXISTS history ( 7 | id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), 8 | content_type TEXT NOT NULL, 9 | content TEXT NOT NULL, 10 | favicon TEXT, 11 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP 12 | ); -------------------------------------------------------------------------------- /plugins/settings.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/core"; 2 | 3 | export default defineNuxtPlugin(() => { 4 | return { 5 | provide: { 6 | settings: { 7 | async getSetting(key: string): Promise { 8 | return await invoke("get_setting", { key }); 9 | }, 10 | 11 | async saveSetting(key: string, value: string): Promise { 12 | await invoke("save_setting", { key, value }); 13 | }, 14 | }, 15 | }, 16 | }; 17 | }); 18 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | devtools: { enabled: false }, 4 | compatibilityDate: "2024-07-04", 5 | ssr: false, 6 | app: { 7 | head: { 8 | charset: "utf-8", 9 | viewport: 10 | "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0", 11 | }, 12 | }, 13 | vite: { 14 | css: { 15 | preprocessorOptions: { 16 | scss: { 17 | api: "modern-compiler", 18 | }, 19 | }, 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /components/Noise.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F4A1 Feature request" 2 | description: Suggest an idea for Qopy 3 | labels: [Feature] 4 | assignees: 5 | - 0PandaDEV 6 | body: 7 | # 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this feature request! 12 | # 13 | - type: textarea 14 | id: description 15 | attributes: 16 | label: Describe your requested feature 17 | description: Give as many details as possible about your feature idea. 18 | validations: 19 | required: true 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "tauri build", 7 | "dev": "tauri dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "dependencies": { 13 | "@tauri-apps/api": "2.5.0", 14 | "@tauri-apps/cli": "2.5.0", 15 | "@tauri-apps/plugin-autostart": "2.4.0", 16 | "@tauri-apps/plugin-os": "2.2.2", 17 | "nuxt": "3.17.5", 18 | "overlayscrollbars": "2.11.4", 19 | "overlayscrollbars-vue": "0.5.9", 20 | "sass-embedded": "1.89.2", 21 | "uuid": "11.1.0", 22 | "vue": "3.5.17", 23 | "@waradu/keyboard": "4.3.0" 24 | }, 25 | "overrides": { 26 | "chokidar": "^3.6.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src-tauri/src/utils/favicon.rs: -------------------------------------------------------------------------------- 1 | use base64::engine::general_purpose::STANDARD; 2 | use base64::Engine; 3 | use image::ImageFormat; 4 | use reqwest; 5 | use url::Url; 6 | 7 | pub async fn fetch_favicon_as_base64( 8 | url: Url 9 | ) -> Result, Box> { 10 | let client = reqwest::Client::new(); 11 | let favicon_url = format!("https://favicone.com/{}", url.host_str().unwrap()); 12 | let response = client.get(&favicon_url).send().await?; 13 | 14 | if response.status().is_success() { 15 | let bytes = response.bytes().await?; 16 | let img = image::load_from_memory(&bytes)?; 17 | let mut png_bytes: Vec = Vec::new(); 18 | img.write_to(&mut std::io::Cursor::new(&mut png_bytes), ImageFormat::Png)?; 19 | Ok(Some(STANDARD.encode(&png_bytes))) 20 | } else { 21 | Ok(None) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "enables the default permissions", 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:path:default", 10 | "core:event:default", 11 | "core:window:default", 12 | "core:webview:default", 13 | "core:app:default", 14 | "core:resources:default", 15 | "core:image:default", 16 | "core:menu:default", 17 | "core:tray:default", 18 | "sql:allow-load", 19 | "sql:allow-select", 20 | "sql:allow-execute", 21 | "autostart:allow-enable", 22 | "autostart:allow-disable", 23 | "autostart:allow-is-enabled", 24 | "os:allow-os-type", 25 | "core:app:allow-app-hide", 26 | "core:app:allow-app-show", 27 | "core:window:allow-hide", 28 | "core:window:allow-show", 29 | "core:window:allow-set-focus", 30 | "core:window:allow-is-focused", 31 | "core:window:allow-is-visible", 32 | "fs:allow-read" 33 | ] 34 | } -------------------------------------------------------------------------------- /components/Icons/File.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /.github/scripts/macOS.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -f .env ]; then 4 | export $(cat .env | grep -v '^#' | xargs) 5 | fi 6 | 7 | set -e 8 | 9 | required_vars=("APPLE_CERTIFICATE" "APPLE_CERTIFICATE_PASSWORD" "APPLE_ID" "APPLE_ID_PASSWORD" "KEYCHAIN_PASSWORD" "APP_BUNDLE_ID") 10 | for var in "${required_vars[@]}"; do 11 | if [ -z "${!var}" ]; then 12 | exit 1 13 | fi 14 | done 15 | 16 | bun run tauri build 17 | 18 | rm -f certificate.p12 19 | echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 2>/dev/null 20 | security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A 2>/dev/null 21 | 22 | SIGNING_IDENTITY=$(security find-identity -v -p codesigning | grep "Apple Development" | head -1 | awk -F '"' '{print $2}') 23 | 24 | if [ -z "$SIGNING_IDENTITY" ]; then 25 | exit 1 26 | fi 27 | 28 | codesign --force --options runtime --sign "$SIGNING_IDENTITY" src-tauri/target/release/bundle/macos/*.app 2>/dev/null 29 | 30 | rm -f certificate.p12 31 | 32 | hdiutil create -volname "Qopy" -srcfolder src-tauri/target/release/bundle/dmg -ov -format UDZO Qopy.dmg 33 | 34 | codesign --force --sign "$APPLE_CERTIFICATE" Qopy.dmg 2>/dev/null 35 | 36 | xcrun notarytool submit Qopy.dmg --apple-id "$APPLE_ID" --password "$APPLE_ID_PASSWORD" --team-id "$APPLE_CERTIFICATE" --wait 37 | 38 | xcrun stapler staple Qopy.dmg 39 | 40 | exit 0 41 | -------------------------------------------------------------------------------- /components/TopBar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 35 | 36 | 58 | -------------------------------------------------------------------------------- /components/Icons/Image.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /public/back_arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /components/Icons/Text.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /components/Icons/Ctrl.vue: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /GET_STARTED.md: -------------------------------------------------------------------------------- 1 | # Get Started 2 | 3 | The default hotkey for Qopy is Windows+V which is also the hotkey for the default clipboard manager to turn that off follow [this guide](https://github.com/0PandaDEV/Qopy/blob/main/GET_STARTED.md#disable-windowsv-for-default-clipboard-manager). 4 | 5 | All the data of Qopy is stored inside of a SQLite database. 6 | 7 | | Operating System | Path | 8 | |------------------|-----------------------------------------------------------------| 9 | | Windows | `C:\Users\USERNAME\AppData\Roaming\net.pandadev.qopy` | 10 | | macOS | `/Users/USERNAME/Library/Application Support/net.pandadev.qopy` | 11 | | Linux | `/home/USERNAME/.local/share/net.pandadev.qopy` | 12 | 13 | ## Disable Windows+V for default clipboard manager 14 | 15 | 16 | 17 | To disable the default clipboard manager popup from windows open Command prompt and run this command 18 | 19 | ```cmd 20 | reg add "HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\System" /v AllowClipboardHistory /t REG_DWORD /d 0 /f 21 | ``` 22 | 23 | After that a restart may be reqired. 24 | 25 | ## Install Fuse for Ubuntu 26 | 27 | `libfuse2` is required on Ubuntu. 28 | 29 | ```sh 30 | sudo apt install libfuse2 31 | ``` 32 | 33 | After that a restart may be reqired. 34 | 35 | -------------------------------------------------------------------------------- /components/Icons/Code.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "Qopy", 3 | "version": "0.4.0", 4 | "identifier": "net.pandadev.qopy", 5 | "build": { 6 | "frontendDist": "../dist", 7 | "devUrl": "http://localhost:3000", 8 | "beforeDevCommand": "pnpm nuxt dev", 9 | "beforeBuildCommand": "pnpm nuxt generate" 10 | }, 11 | "app": { 12 | "windows": [ 13 | { 14 | "title": "Qopy", 15 | "titleBarStyle": "Overlay", 16 | "fullscreen": false, 17 | "resizable": false, 18 | "height": 474, 19 | "width": 750, 20 | "minHeight": 474, 21 | "maxHeight": 474, 22 | "minWidth": 750, 23 | "maxWidth": 750, 24 | "decorations": false, 25 | "center": true, 26 | "shadow": false, 27 | "transparent": true, 28 | "skipTaskbar": true, 29 | "alwaysOnTop": true 30 | } 31 | ], 32 | "security": { 33 | "csp": null 34 | }, 35 | "withGlobalTauri": true, 36 | "macOSPrivateApi": true 37 | }, 38 | "bundle": { 39 | "createUpdaterArtifacts": true, 40 | "active": true, 41 | "targets": "all", 42 | "icon": [ 43 | "icons/32x32.png", 44 | "icons/128x128.png", 45 | "icons/128x128@2x.png", 46 | "icons/icon.icns", 47 | "icons/icon.ico" 48 | ], 49 | "category": "DeveloperTool" 50 | }, 51 | "plugins": { 52 | "updater": { 53 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDExNDIzNjA1QjE0NjU1OTkKUldTWlZVYXhCVFpDRWNvNmt0UE5lQmZkblEyZGZiZ2tHelJvT2YvNVpLU1RIM1RKZFQrb2tzWWwK", 54 | "endpoints": ["https://qopy.pandadev.net/"] 55 | } 56 | }, 57 | "$schema": "../node_modules/@tauri-apps/cli/schema.json" 58 | } 59 | -------------------------------------------------------------------------------- /components/Icons/Enter.vue: -------------------------------------------------------------------------------- 1 | 42 | -------------------------------------------------------------------------------- /components/Icons/Link.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "qopy" 3 | version = "0.4.0" 4 | description = "Qopy" 5 | authors = ["pandadev"] 6 | edition = "2021" 7 | rust-version = "1.80" 8 | 9 | [build-dependencies] 10 | tauri-build = { version = "2.2.0", features = [] } 11 | 12 | [dependencies] 13 | tauri = { version = "2.5.1", features = [ 14 | "macos-private-api", 15 | "tray-icon", 16 | "image-png", 17 | ] } 18 | tauri-plugin-sql = { version = "2.2.1", features = ["sqlite"] } 19 | tauri-plugin-autostart = "2.4.0" 20 | tauri-plugin-os = "2.2.2" 21 | tauri-plugin-updater = "2.8.1" 22 | tauri-plugin-dialog = "2.2.2" 23 | tauri-plugin-fs = "2.3.0" 24 | tauri-plugin-clipboard = "2.1.11" 25 | tauri-plugin-prevent-default = "2.1.1" 26 | tauri-plugin-global-shortcut = "2.2.1" 27 | tauri-plugin-aptabase = "1.0.0" 28 | sqlx = { version = "0.8.6", features = [ 29 | "runtime-tokio-native-tls", 30 | "sqlite", 31 | "chrono", 32 | ] } 33 | serde = { version = "1.0.219", features = ["derive"] } 34 | tokio = { version = "1.45.1", features = ["full"] } 35 | serde_json = "1.0.140" 36 | rdev = "0.5.3" 37 | rand = "0.9.1" 38 | base64 = "0.22.1" 39 | image = "0.25.6" 40 | reqwest = { version = "0.12.20", features = ["json", "blocking"] } 41 | url = "2.5.4" 42 | regex = "1.11.1" 43 | sha2 = "0.10.9" 44 | lazy_static = "1.5.0" 45 | time = "0.3.41" 46 | global-hotkey = "0.7.0" 47 | chrono = { version = "0.4.41", features = ["serde"] } 48 | log = { version = "0.4.27", features = ["std"] } 49 | uuid = { version = "1.17.0", features = ["v4"] } 50 | include_dir = "0.7.4" 51 | # hyperpolyglot = { git = "https://github.com/0pandadev/hyperpolyglot" } 52 | applications = { git = "https://github.com/HuakunShen/applications-rs", branch = "fix/win-app-detection" } 53 | glob = "0.3.2" 54 | meta_fetcher = "0.1.1" 55 | parking_lot = "0.12.4" 56 | 57 | [features] 58 | custom-protocol = ["tauri/custom-protocol"] 59 | -------------------------------------------------------------------------------- /lib/selectedResult.ts: -------------------------------------------------------------------------------- 1 | import type { HistoryItem } from '~/types/types' 2 | 3 | interface GroupedHistory { 4 | label: string 5 | items: HistoryItem[] 6 | } 7 | 8 | export const selectedGroupIndex = ref(0) 9 | export const selectedItemIndex = ref(0) 10 | export const selectedElement = ref(null) 11 | 12 | export const useSelectedResult = (groupedHistory: Ref) => { 13 | const selectedItem = computed(() => { 14 | const group = groupedHistory.value[selectedGroupIndex.value] 15 | return group?.items[selectedItemIndex.value] ?? null 16 | }) 17 | 18 | const isSelected = (groupIndex: number, itemIndex: number): boolean => { 19 | return selectedGroupIndex.value === groupIndex && selectedItemIndex.value === itemIndex 20 | } 21 | 22 | const selectNext = (): void => { 23 | const currentGroup = groupedHistory.value[selectedGroupIndex.value] 24 | if (selectedItemIndex.value < currentGroup.items.length - 1) { 25 | selectedItemIndex.value++ 26 | } else if (selectedGroupIndex.value < groupedHistory.value.length - 1) { 27 | selectedGroupIndex.value++ 28 | selectedItemIndex.value = 0 29 | } 30 | } 31 | 32 | const selectPrevious = (): void => { 33 | if (selectedItemIndex.value > 0) { 34 | selectedItemIndex.value-- 35 | } else if (selectedGroupIndex.value > 0) { 36 | selectedGroupIndex.value-- 37 | selectedItemIndex.value = groupedHistory.value[selectedGroupIndex.value].items.length - 1 38 | } 39 | } 40 | 41 | const selectItem = (groupIndex: number, itemIndex: number): void => { 42 | selectedGroupIndex.value = groupIndex 43 | selectedItemIndex.value = itemIndex 44 | } 45 | 46 | return { 47 | selectedItem, 48 | isSelected, 49 | selectNext, 50 | selectPrevious, 51 | selectItem, 52 | selectedElement 53 | } 54 | } -------------------------------------------------------------------------------- /components/Icons/K.vue: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /plugins/history.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/core"; 2 | import type { HistoryItem } from "~/types/types"; 3 | 4 | export default defineNuxtPlugin(() => { 5 | return { 6 | provide: { 7 | history: { 8 | async getHistory(): Promise { 9 | return await invoke("get_history"); 10 | }, 11 | 12 | async addHistoryItem(item: HistoryItem): Promise { 13 | await invoke("add_history_item", { item }); 14 | }, 15 | 16 | async searchHistory(query: string): Promise { 17 | try { 18 | return await invoke("search_history", { query }); 19 | } catch (error) { 20 | console.error("Error searching history:", error); 21 | return []; 22 | } 23 | }, 24 | 25 | async loadHistoryChunk( 26 | offset: number, 27 | limit: number 28 | ): Promise { 29 | try { 30 | return await invoke("load_history_chunk", { 31 | offset, 32 | limit, 33 | }); 34 | } catch (error) { 35 | console.error("Error loading history chunk:", error); 36 | return []; 37 | } 38 | }, 39 | 40 | async deleteHistoryItem(id: string): Promise { 41 | await invoke("delete_history_item", { id }); 42 | }, 43 | 44 | async clearHistory(): Promise { 45 | await invoke("clear_history"); 46 | }, 47 | 48 | async writeAndPaste(data: { 49 | content: string; 50 | contentType: string; 51 | }): Promise { 52 | await invoke("write_and_paste", data); 53 | }, 54 | 55 | async readImage(data: { filename: string }): Promise { 56 | return await invoke("read_image", data); 57 | }, 58 | }, 59 | }, 60 | }; 61 | }); 62 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41E Bug report" 2 | description: Create a report to help me improve Qopy 3 | labels: [Bug] 4 | assignees: 5 | - 0PandaDEV 6 | body: 7 | # 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this bug report! 12 | # 13 | - type: textarea 14 | id: description 15 | attributes: 16 | label: Describe the bug 17 | description: A clear and concise description of what the bug is. 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: reproduce 23 | attributes: 24 | label: Steps to reproduce 25 | description: Steps to reproduce the behavior 26 | value: | 27 | 1. Go to '...' 28 | 2. Click on '....' 29 | 3. Scroll down to '....' 30 | 4. See error 31 | validations: 32 | required: true 33 | 34 | - type: textarea 35 | id: expected 36 | attributes: 37 | label: Expected behavior 38 | description: A clear and concise description of what you expected to happen. 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: screenshots 44 | attributes: 45 | label: Screenshots 46 | description: If applicable, add screenshots to help explain your problem. 47 | validations: 48 | required: false 49 | 50 | - type: dropdown 51 | id: os 52 | attributes: 53 | label: Operating system 54 | options: 55 | - Windows 56 | - Linux 57 | - macOS 58 | validations: 59 | required: true 60 | 61 | - type: input 62 | id: version 63 | attributes: 64 | label: Version of Qopy 65 | placeholder: e.g. 0.1.0 66 | validations: 67 | required: true 68 | 69 | - type: textarea 70 | id: additional 71 | attributes: 72 | label: Additional context 73 | description: Add any other context about the problem here. 74 | validations: 75 | required: false 76 | -------------------------------------------------------------------------------- /src-tauri/src/api/tray.rs: -------------------------------------------------------------------------------- 1 | use tauri::{ menu::{ MenuBuilder, MenuItemBuilder }, tray::TrayIconBuilder, Emitter, Manager }; 2 | use tauri_plugin_aptabase::EventTracker; 3 | 4 | pub fn setup(app: &mut tauri::App) -> Result<(), Box> { 5 | let window = app.get_webview_window("main").unwrap(); 6 | let is_visible = window.is_visible().unwrap(); 7 | let _ = app.track_event( 8 | "tray_toggle", 9 | Some(serde_json::json!({ 10 | "action": if is_visible { "hide" } else { "show" } 11 | })) 12 | ); 13 | 14 | let icon_bytes = include_bytes!("../../icons/Square71x71Logo.png"); 15 | let icon = tauri::image::Image::from_bytes(icon_bytes).unwrap(); 16 | 17 | let _tray = TrayIconBuilder::new() 18 | .menu( 19 | &MenuBuilder::new(app) 20 | .items(&[&MenuItemBuilder::with_id("app_name", "Qopy").enabled(false).build(app)?]) 21 | .items(&[&MenuItemBuilder::with_id("show", "Show/Hide").build(app)?]) 22 | .items(&[&MenuItemBuilder::with_id("settings", "Settings").build(app)?]) 23 | .items(&[&MenuItemBuilder::with_id("quit", "Quit").build(app)?]) 24 | .build()? 25 | ) 26 | .on_menu_event(move |_app, event| { 27 | match event.id().as_ref() { 28 | "quit" => { 29 | let _ = _app.track_event("app_quit", None); 30 | std::process::exit(0); 31 | } 32 | "show" => { 33 | let _ = _app.track_event( 34 | "tray_toggle", 35 | Some( 36 | serde_json::json!({ 37 | "action": if is_visible { "hide" } else { "show" } 38 | }) 39 | ) 40 | ); 41 | let is_visible = window.is_visible().unwrap(); 42 | if is_visible { 43 | window.hide().unwrap(); 44 | } else { 45 | window.show().unwrap(); 46 | window.set_focus().unwrap(); 47 | } 48 | window.emit("main_route", ()).unwrap(); 49 | } 50 | "settings" => { 51 | let _ = _app.track_event("tray_settings", None); 52 | window.emit("settings", ()).unwrap(); 53 | } 54 | _ => (), 55 | } 56 | }) 57 | .icon(icon) 58 | .build(app)?; 59 | 60 | Ok(()) 61 | } 62 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 34 | 35 | 106 | -------------------------------------------------------------------------------- /types/types.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | 3 | export enum ContentType { 4 | Text = "text", 5 | Image = "image", 6 | File = "file", 7 | Link = "link", 8 | Color = "color", 9 | Code = "code", 10 | } 11 | 12 | export class HistoryItem { 13 | id: string; 14 | source: string; 15 | source_icon?: string; 16 | content_type: ContentType; 17 | content: string; 18 | favicon?: string; 19 | timestamp: Date; 20 | language?: string; 21 | 22 | constructor( 23 | source: string, 24 | content_type: ContentType, 25 | content: string, 26 | favicon?: string, 27 | source_icon?: string, 28 | language?: string 29 | ) { 30 | this.id = uuidv4(); 31 | this.source = source; 32 | this.source_icon = source_icon; 33 | this.content_type = content_type; 34 | this.content = content; 35 | this.favicon = favicon; 36 | this.timestamp = new Date(); 37 | this.language = language; 38 | } 39 | 40 | toRow(): [ 41 | string, 42 | string, 43 | string | undefined, 44 | string, 45 | string, 46 | string | undefined, 47 | Date, 48 | string | undefined 49 | ] { 50 | return [ 51 | this.id, 52 | this.source, 53 | this.source_icon, 54 | this.content_type, 55 | this.content, 56 | this.favicon, 57 | this.timestamp, 58 | this.language, 59 | ]; 60 | } 61 | } 62 | 63 | export interface Settings { 64 | key: string; 65 | value: string; 66 | } 67 | 68 | export interface InfoText { 69 | source: string; 70 | content_type: ContentType.Text; 71 | characters: number; 72 | words: number; 73 | copied: Date; 74 | } 75 | 76 | export interface InfoImage { 77 | source: string; 78 | content_type: ContentType.Image; 79 | dimensions: string; 80 | size: number; 81 | copied: Date; 82 | } 83 | 84 | export interface InfoFile { 85 | source: string; 86 | content_type: ContentType.File; 87 | path: string; 88 | filesize: number; 89 | copied: Date; 90 | } 91 | 92 | export interface InfoLink { 93 | source: string; 94 | content_type: ContentType.Link; 95 | title?: string; 96 | url: string; 97 | characters: number; 98 | copied: Date; 99 | } 100 | 101 | export interface InfoColor { 102 | source: string; 103 | content_type: ContentType.Color; 104 | hex: string; 105 | rgb: string; 106 | hsl: string; 107 | copied: Date; 108 | } 109 | 110 | export interface InfoCode { 111 | source: string; 112 | content_type: ContentType.Code; 113 | language: string; 114 | lines: number; 115 | copied: Date; 116 | } 117 | -------------------------------------------------------------------------------- /src-tauri/src/db/settings.rs: -------------------------------------------------------------------------------- 1 | use serde::{ Deserialize, Serialize }; 2 | use serde_json; 3 | use sqlx::Row; 4 | use sqlx::SqlitePool; 5 | use tauri::{ Emitter, Manager }; 6 | use tauri_plugin_aptabase::EventTracker; 7 | 8 | #[derive(Deserialize, Serialize)] 9 | struct KeybindSetting { 10 | keybind: Vec, 11 | } 12 | 13 | pub async fn initialize_settings(pool: &SqlitePool) -> Result<(), Box> { 14 | let default_keybind = KeybindSetting { 15 | keybind: vec!["Meta".to_string(), "V".to_string()], 16 | }; 17 | let json = serde_json::to_string(&default_keybind)?; 18 | 19 | sqlx 20 | ::query("INSERT INTO settings (key, value) VALUES ('keybind', ?)") 21 | .bind(json) 22 | .execute(pool).await?; 23 | 24 | Ok(()) 25 | } 26 | 27 | #[tauri::command] 28 | pub async fn get_setting( 29 | pool: tauri::State<'_, SqlitePool>, 30 | key: String 31 | ) -> Result { 32 | let row = sqlx 33 | ::query("SELECT value FROM settings WHERE key = ?") 34 | .bind(key) 35 | .fetch_optional(&*pool).await 36 | .map_err(|e| e.to_string())?; 37 | 38 | Ok(row.map(|r| r.get("value")).unwrap_or_default()) 39 | } 40 | 41 | #[tauri::command] 42 | pub async fn save_setting( 43 | app_handle: tauri::AppHandle, 44 | pool: tauri::State<'_, SqlitePool>, 45 | key: String, 46 | value: String 47 | ) -> Result<(), String> { 48 | sqlx 49 | ::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") 50 | .bind(key.clone()) 51 | .bind(value.clone()) 52 | .execute(&*pool).await 53 | .map_err(|e| e.to_string())?; 54 | 55 | let _ = app_handle.track_event( 56 | "setting_saved", 57 | Some(serde_json::json!({ 58 | "key": key 59 | })) 60 | ); 61 | 62 | if key == "keybind" { 63 | let _ = app_handle.emit("update-shortcut", &value).map_err(|e| e.to_string())?; 64 | } 65 | 66 | Ok(()) 67 | } 68 | 69 | #[tauri::command] 70 | pub async fn get_keybind(app_handle: tauri::AppHandle) -> Result, String> { 71 | let pool = app_handle.state::(); 72 | 73 | let row = sqlx 74 | ::query("SELECT value FROM settings WHERE key = 'keybind'") 75 | .fetch_optional(&*pool).await 76 | .map_err(|e| e.to_string())?; 77 | 78 | let json = row 79 | .map(|r| r.get::("value")) 80 | .unwrap_or_else(|| { 81 | serde_json 82 | ::to_string(&vec!["MetaLeft".to_string(), "KeyV".to_string()]) 83 | .expect("Failed to serialize default keybind") 84 | }); 85 | 86 | serde_json::from_str::>(&json).map_err(|e| e.to_string()) 87 | } 88 | -------------------------------------------------------------------------------- /src-tauri/src/utils/logger.rs: -------------------------------------------------------------------------------- 1 | use chrono; 2 | use log::{ LevelFilter, SetLoggerError }; 3 | use std::fs::{ File, OpenOptions }; 4 | use std::io::Write; 5 | use std::panic; 6 | 7 | pub struct FileLogger { 8 | file: File, 9 | } 10 | 11 | impl log::Log for FileLogger { 12 | fn enabled(&self, _metadata: &log::Metadata) -> bool { 13 | true 14 | } 15 | 16 | fn log(&self, record: &log::Record) { 17 | if self.enabled(record.metadata()) { 18 | let mut file = self.file.try_clone().expect("Failed to clone file handle"); 19 | 20 | writeln!( 21 | file, 22 | "{} [{:<5}] {}: {} ({}:{})", 23 | chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), 24 | record.level(), 25 | record.target(), 26 | record.args(), 27 | record.file().unwrap_or("unknown"), 28 | record.line().unwrap_or(0) 29 | ).expect("Failed to write to log file"); 30 | } 31 | } 32 | 33 | fn flush(&self) { 34 | self.file.sync_all().expect("Failed to flush log file"); 35 | } 36 | } 37 | 38 | pub fn init_logger(app_data_dir: &std::path::Path) -> Result<(), SetLoggerError> { 39 | let logs_dir = app_data_dir.join("logs"); 40 | std::fs::create_dir_all(&logs_dir).expect("Failed to create logs directory"); 41 | 42 | let log_path = logs_dir.join("app.log"); 43 | let file = OpenOptions::new() 44 | .create(true) 45 | .append(true) 46 | .open(&log_path) 47 | .expect("Failed to open log file"); 48 | 49 | let panic_file = file.try_clone().expect("Failed to clone file handle"); 50 | panic::set_hook( 51 | Box::new(move |panic_info| { 52 | let mut file = panic_file.try_clone().expect("Failed to clone file handle"); 53 | 54 | let location = panic_info 55 | .location() 56 | .map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column())) 57 | .unwrap_or_else(|| "unknown location".to_string()); 58 | 59 | let message = match panic_info.payload().downcast_ref::<&str>() { 60 | Some(s) => *s, 61 | None => 62 | match panic_info.payload().downcast_ref::() { 63 | Some(s) => s.as_str(), 64 | None => "Unknown panic message", 65 | } 66 | }; 67 | 68 | let _ = writeln!( 69 | file, 70 | "{} [PANIC] rust_panic: {} ({})", 71 | chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), 72 | message, 73 | location 74 | ); 75 | }) 76 | ); 77 | 78 | let logger = Box::new(FileLogger { file }); 79 | unsafe { 80 | log::set_logger_racy(Box::leak(logger))?; 81 | } 82 | log::set_max_level(LevelFilter::Debug); 83 | Ok(()) 84 | } 85 | -------------------------------------------------------------------------------- /components/BottomBar.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 46 | 47 | 125 | -------------------------------------------------------------------------------- /components/Result.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 93 | 94 | 123 | -------------------------------------------------------------------------------- /src-tauri/src/db/database.rs: -------------------------------------------------------------------------------- 1 | use include_dir::{ include_dir, Dir }; 2 | use sqlx::sqlite::{ SqlitePool, SqlitePoolOptions }; 3 | use std::fs; 4 | use tauri::Manager; 5 | use tokio::runtime::Runtime as TokioRuntime; 6 | 7 | static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/db/migrations"); 8 | 9 | pub fn setup(app: &mut tauri::App) -> Result<(), Box> { 10 | let rt = TokioRuntime::new().expect("Failed to create Tokio runtime"); 11 | app.manage(rt); 12 | 13 | let rt = app.state::(); 14 | 15 | let app_data_dir = app.path().app_data_dir().unwrap(); 16 | fs::create_dir_all(&app_data_dir).expect("Failed to create app data directory"); 17 | 18 | let db_path = app_data_dir.join("data.db"); 19 | let is_new_db = !db_path.exists(); 20 | if is_new_db { 21 | fs::File::create(&db_path).expect("Failed to create database file"); 22 | } 23 | 24 | let db_url = format!("sqlite:{}", db_path.to_str().unwrap()); 25 | let pool = rt.block_on(async { 26 | SqlitePoolOptions::new() 27 | .max_connections(5) 28 | .connect(&db_url).await 29 | .expect("Failed to create pool") 30 | }); 31 | 32 | app.manage(pool.clone()); 33 | 34 | rt.block_on(async { 35 | apply_migrations(&pool).await?; 36 | if is_new_db { 37 | if let Err(e) = super::history::initialize_history(&pool).await { 38 | eprintln!("Failed to initialize history: {}", e); 39 | } 40 | if let Err(e) = super::settings::initialize_settings(&pool).await { 41 | eprintln!("Failed to initialize settings: {}", e); 42 | } 43 | } 44 | Ok::<(), Box>(()) 45 | })?; 46 | 47 | Ok(()) 48 | } 49 | 50 | async fn apply_migrations(pool: &SqlitePool) -> Result<(), Box> { 51 | sqlx 52 | ::query( 53 | "CREATE TABLE IF NOT EXISTS schema_version ( 54 | version INTEGER PRIMARY KEY, 55 | applied_at DATETIME DEFAULT CURRENT_TIMESTAMP 56 | );" 57 | ) 58 | .execute(pool).await?; 59 | 60 | let current_version: Option = sqlx 61 | ::query_scalar("SELECT MAX(version) FROM schema_version") 62 | .fetch_one(pool).await?; 63 | 64 | let current_version = current_version.unwrap_or(0); 65 | 66 | let mut migration_files: Vec<(i64, &str)> = MIGRATIONS_DIR.files() 67 | .filter_map(|file| { 68 | let file_name = file.path().file_name()?.to_str()?; 69 | if file_name.ends_with(".sql") && file_name.starts_with("v") { 70 | let version: i64 = file_name 71 | .trim_start_matches("v") 72 | .trim_end_matches(".sql") 73 | .parse() 74 | .ok()?; 75 | Some((version, file.contents_utf8()?)) 76 | } else { 77 | None 78 | } 79 | }) 80 | .collect(); 81 | 82 | migration_files.sort_by_key(|(version, _)| *version); 83 | 84 | for (version, content) in migration_files { 85 | if version > current_version { 86 | let statements: Vec<&str> = content 87 | .split(';') 88 | .map(|s| s.trim()) 89 | .filter(|s| !s.is_empty()) 90 | .collect(); 91 | 92 | for statement in statements { 93 | sqlx 94 | ::query(statement) 95 | .execute(pool).await 96 | .map_err(|e| format!("Failed to execute migration {}: {}", version, e))?; 97 | } 98 | 99 | sqlx 100 | ::query("INSERT INTO schema_version (version) VALUES (?)") 101 | .bind(version) 102 | .execute(pool).await?; 103 | } 104 | } 105 | 106 | Ok(()) 107 | } 108 | -------------------------------------------------------------------------------- /components/Icons/Cmd.vue: -------------------------------------------------------------------------------- 1 | 40 | -------------------------------------------------------------------------------- /src-tauri/src/api/updater.rs: -------------------------------------------------------------------------------- 1 | use tauri::{ async_runtime, AppHandle, Manager }; 2 | use tauri_plugin_dialog::{ DialogExt, MessageDialogButtons, MessageDialogKind }; 3 | use tauri_plugin_updater::UpdaterExt; 4 | 5 | pub async fn check_for_updates(app: AppHandle, prompted: bool) { 6 | println!("Checking for updates..."); 7 | 8 | let updater = app.updater().unwrap(); 9 | let response = updater.check().await; 10 | 11 | match response { 12 | Ok(Some(update)) => { 13 | let cur_ver = &update.current_version; 14 | let new_ver = &update.version; 15 | let mut msg = String::new(); 16 | msg.extend([ 17 | &format!("{cur_ver} -> {new_ver}\n\n"), 18 | "Would you like to install it now?", 19 | ]); 20 | 21 | let window = app.get_webview_window("main").unwrap(); 22 | window.show().unwrap(); 23 | window.set_focus().unwrap(); 24 | 25 | app.dialog() 26 | .message(msg) 27 | .title("Qopy Update Available") 28 | .buttons( 29 | MessageDialogButtons::OkCancelCustom( 30 | String::from("Install"), 31 | String::from("Cancel") 32 | ) 33 | ) 34 | .show(move |response| { 35 | if !response { 36 | return; 37 | } 38 | async_runtime::spawn(async move { 39 | match 40 | update.download_and_install( 41 | |_, _| {}, 42 | || {} 43 | ).await 44 | { 45 | Ok(_) => { 46 | app.dialog() 47 | .message( 48 | "Update installed successfully. The application needs to restart to apply the changes." 49 | ) 50 | .title("Qopy Update Installed") 51 | .buttons( 52 | MessageDialogButtons::OkCancelCustom( 53 | String::from("Restart"), 54 | String::from("Cancel") 55 | ) 56 | ) 57 | .show(move |response| { 58 | if response { 59 | app.restart(); 60 | } 61 | }); 62 | } 63 | Err(e) => { 64 | println!("Error installing new update: {:?}", e); 65 | app.dialog() 66 | .message( 67 | "Failed to install new update. The new update can be downloaded from Github" 68 | ) 69 | .kind(MessageDialogKind::Error) 70 | .show(|_| {}); 71 | } 72 | } 73 | }); 74 | }); 75 | } 76 | Ok(None) => { 77 | println!("No updates available."); 78 | } 79 | Err(e) => { 80 | if prompted { 81 | let window = app.get_webview_window("main").unwrap(); 82 | window.show().unwrap(); 83 | window.set_focus().unwrap(); 84 | 85 | app.dialog() 86 | .message("No updates available.") 87 | .title("Qopy Update Check") 88 | .show(|_| {}); 89 | } 90 | 91 | println!("No updates available. {}", e.to_string()); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /styles/index.scss: -------------------------------------------------------------------------------- 1 | $primary: #2e2d2b; 2 | $accent: #feb453; 3 | $divider: #ffffff0d; 4 | 5 | $text: #e5dfd5; 6 | $text2: #ada9a1; 7 | $mutedtext: #78756f; 8 | 9 | $search-height: 56px; 10 | $sidebar-width: 286px; 11 | $bottom-bar-height: 39px; 12 | $info-panel-height: 160px; 13 | $content-view-height: calc( 14 | 100% - $search-height - $info-panel-height - $bottom-bar-height 15 | ); 16 | 17 | main { 18 | width: 100vw; 19 | height: 100vh; 20 | background-color: $primary; 21 | border: 1px solid $divider; 22 | display: flex; 23 | flex-direction: column; 24 | border-radius: 12px; 25 | justify-content: space-between; 26 | } 27 | 28 | .container { 29 | height: 376px; 30 | width: 100%; 31 | display: flex; 32 | } 33 | 34 | .results { 35 | display: flex; 36 | flex-direction: column; 37 | padding: 14px 8px; 38 | gap: 8px; 39 | min-width: 286px; 40 | border-right: 1px solid var(--border); 41 | 42 | .time-separator { 43 | font-size: 12px; 44 | color: $text2; 45 | font-family: SFRoundedSemiBold; 46 | padding-left: 8px; 47 | } 48 | 49 | .group { 50 | & + .group { 51 | margin-top: 16px; 52 | } 53 | 54 | .time-separator { 55 | margin-bottom: 8px; 56 | } 57 | 58 | .results-group { 59 | display: flex; 60 | flex-direction: column; 61 | } 62 | } 63 | 64 | .favicon, 65 | .image, 66 | .icon { 67 | width: 18px; 68 | height: 18px; 69 | } 70 | } 71 | 72 | .right { 73 | display: flex; 74 | flex-direction: column; 75 | width: 100%; 76 | } 77 | 78 | .content { 79 | height: 100%; 80 | font-family: CommitMono !important; 81 | font-size: 12px; 82 | letter-spacing: 1; 83 | border-radius: 10px; 84 | width: 462px; 85 | display: flex; 86 | flex-direction: column; 87 | align-items: flex-start; 88 | overflow: hidden; 89 | z-index: 2; 90 | color: $text; 91 | 92 | &:not(:has(.image)) { 93 | padding: 8px; 94 | } 95 | 96 | span.content-text { 97 | font-family: CommitMono !important; 98 | white-space: pre-wrap; 99 | word-wrap: break-word; 100 | word-break: break-word; 101 | max-width: 100%; 102 | } 103 | 104 | .image { 105 | width: 100%; 106 | height: 100%; 107 | object-fit: contain; 108 | object-position: center; 109 | } 110 | } 111 | 112 | .information { 113 | min-height: 160px; 114 | width: 462px; 115 | border-top: 1px solid $divider; 116 | padding: 14px; 117 | z-index: 1; 118 | display: flex; 119 | flex-direction: column; 120 | gap: 14px; 121 | 122 | .title { 123 | font-family: SFRoundedSemiBold; 124 | font-size: 12px; 125 | letter-spacing: 0.6px; 126 | color: $text; 127 | } 128 | 129 | .info-content { 130 | display: flex; 131 | gap: 0; 132 | flex-direction: column; 133 | 134 | .info-row { 135 | display: flex; 136 | width: 100%; 137 | font-size: 12px; 138 | justify-content: space-between; 139 | padding: 8px 0; 140 | border-bottom: 1px solid $divider; 141 | line-height: 1; 142 | 143 | &:last-child { 144 | border-bottom: none; 145 | padding-bottom: 0; 146 | } 147 | 148 | &:first-child { 149 | padding-top: 22px; 150 | } 151 | 152 | p { 153 | font-family: SFRoundedMedium; 154 | color: $text2; 155 | font-weight: 500; 156 | flex-shrink: 0; 157 | } 158 | 159 | span { 160 | font-family: CommitMono; 161 | color: $text; 162 | text-overflow: ellipsis; 163 | overflow: hidden; 164 | white-space: nowrap; 165 | margin-left: 32px; 166 | display: flex; 167 | align-items: center; 168 | gap: 4px; 169 | 170 | img { 171 | width: 13px; 172 | height: 13px; 173 | object-fit: contain; 174 | } 175 | } 176 | } 177 | } 178 | } 179 | 180 | .search-loading { 181 | display: flex; 182 | justify-content: center; 183 | padding: 20px; 184 | font-size: 16px; 185 | color: var(--text-secondary); 186 | } -------------------------------------------------------------------------------- /src-tauri/src/utils/types.rs: -------------------------------------------------------------------------------- 1 | use chrono::{ DateTime, Utc }; 2 | use serde::{ Deserialize, Serialize }; 3 | use std::fmt; 4 | use uuid::Uuid; 5 | 6 | #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] 7 | pub struct HistoryItem { 8 | pub id: String, 9 | pub source: String, 10 | pub source_icon: Option, 11 | pub content_type: ContentType, 12 | pub content: String, 13 | pub favicon: Option, 14 | pub timestamp: DateTime, 15 | pub language: Option, 16 | } 17 | 18 | #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] 19 | #[serde(rename_all = "lowercase")] 20 | pub enum ContentType { 21 | Text, 22 | Image, 23 | File, 24 | Link, 25 | Color, 26 | Code, 27 | } 28 | 29 | #[derive(Debug, Deserialize, Serialize)] 30 | pub struct InfoText { 31 | pub source: String, 32 | pub content_type: ContentType, 33 | pub characters: i32, 34 | pub words: i32, 35 | pub copied: DateTime, 36 | } 37 | 38 | #[derive(Debug, Deserialize, Serialize)] 39 | pub struct InfoImage { 40 | pub source: String, 41 | pub content_type: ContentType, 42 | pub dimensions: String, 43 | pub size: i64, 44 | pub copied: DateTime, 45 | } 46 | 47 | #[derive(Debug, Deserialize, Serialize)] 48 | pub struct InfoFile { 49 | pub source: String, 50 | pub content_type: ContentType, 51 | pub path: String, 52 | pub filesize: i64, 53 | pub copied: DateTime, 54 | } 55 | 56 | #[derive(Debug, Deserialize, Serialize)] 57 | pub struct InfoLink { 58 | pub source: String, 59 | pub content_type: ContentType, 60 | pub title: Option, 61 | pub url: String, 62 | pub characters: i32, 63 | pub copied: DateTime, 64 | } 65 | 66 | #[derive(Debug, Deserialize, Serialize)] 67 | pub struct InfoColor { 68 | pub source: String, 69 | pub content_type: ContentType, 70 | pub hex: String, 71 | pub rgb: String, 72 | pub copied: DateTime, 73 | } 74 | 75 | #[derive(Debug, Deserialize, Serialize)] 76 | pub struct InfoCode { 77 | pub source: String, 78 | pub content_type: ContentType, 79 | pub language: String, 80 | pub lines: i32, 81 | pub copied: DateTime, 82 | } 83 | 84 | impl fmt::Display for ContentType { 85 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 86 | match self { 87 | ContentType::Text => write!(f, "text"), 88 | ContentType::Image => write!(f, "image"), 89 | ContentType::File => write!(f, "file"), 90 | ContentType::Link => write!(f, "link"), 91 | ContentType::Color => write!(f, "color"), 92 | ContentType::Code => write!(f, "code"), 93 | } 94 | } 95 | } 96 | 97 | impl From for ContentType { 98 | fn from(s: String) -> Self { 99 | match s.to_lowercase().as_str() { 100 | "text" => ContentType::Text, 101 | "image" => ContentType::Image, 102 | "file" => ContentType::File, 103 | "link" => ContentType::Link, 104 | "color" => ContentType::Color, 105 | "code" => ContentType::Code, 106 | _ => ContentType::Text, 107 | } 108 | } 109 | } 110 | 111 | impl HistoryItem { 112 | pub fn new( 113 | source: String, 114 | content_type: ContentType, 115 | content: String, 116 | favicon: Option, 117 | source_icon: Option, 118 | language: Option 119 | ) -> Self { 120 | Self { 121 | id: Uuid::new_v4().to_string(), 122 | source, 123 | source_icon, 124 | content_type, 125 | content, 126 | favicon, 127 | timestamp: Utc::now(), 128 | language, 129 | } 130 | } 131 | 132 | pub fn to_row( 133 | &self 134 | ) -> ( 135 | String, 136 | String, 137 | Option, 138 | String, 139 | String, 140 | Option, 141 | DateTime, 142 | Option, 143 | ) { 144 | ( 145 | self.id.clone(), 146 | self.source.clone(), 147 | self.source_icon.clone(), 148 | self.content_type.to_string(), 149 | self.content.clone(), 150 | self.favicon.clone(), 151 | self.timestamp, 152 | self.language.clone(), 153 | ) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src-tauri/src/utils/keys.rs: -------------------------------------------------------------------------------- 1 | use global_hotkey::hotkey::Code; 2 | use std::str::FromStr; 3 | 4 | pub struct KeyCode(Code); 5 | 6 | impl FromStr for KeyCode { 7 | type Err = String; 8 | 9 | fn from_str(s: &str) -> Result { 10 | let code = match s { 11 | "Backquote" => Code::Backquote, 12 | "Backslash" => Code::Backslash, 13 | "BracketLeft" => Code::BracketLeft, 14 | "BracketRight" => Code::BracketRight, 15 | "Comma" => Code::Comma, 16 | "Digit0" => Code::Digit0, 17 | "Digit1" => Code::Digit1, 18 | "Digit2" => Code::Digit2, 19 | "Digit3" => Code::Digit3, 20 | "Digit4" => Code::Digit4, 21 | "Digit5" => Code::Digit5, 22 | "Digit6" => Code::Digit6, 23 | "Digit7" => Code::Digit7, 24 | "Digit8" => Code::Digit8, 25 | "Digit9" => Code::Digit9, 26 | "Equal" => Code::Equal, 27 | "KeyA" => Code::KeyA, 28 | "KeyB" => Code::KeyB, 29 | "KeyC" => Code::KeyC, 30 | "KeyD" => Code::KeyD, 31 | "KeyE" => Code::KeyE, 32 | "KeyF" => Code::KeyF, 33 | "KeyG" => Code::KeyG, 34 | "KeyH" => Code::KeyH, 35 | "KeyI" => Code::KeyI, 36 | "KeyJ" => Code::KeyJ, 37 | "KeyK" => Code::KeyK, 38 | "KeyL" => Code::KeyL, 39 | "KeyM" => Code::KeyM, 40 | "KeyN" => Code::KeyN, 41 | "KeyO" => Code::KeyO, 42 | "KeyP" => Code::KeyP, 43 | "KeyQ" => Code::KeyQ, 44 | "KeyR" => Code::KeyR, 45 | "KeyS" => Code::KeyS, 46 | "KeyT" => Code::KeyT, 47 | "KeyU" => Code::KeyU, 48 | "KeyV" => Code::KeyV, 49 | "KeyW" => Code::KeyW, 50 | "KeyX" => Code::KeyX, 51 | "KeyY" => Code::KeyY, 52 | "KeyZ" => Code::KeyZ, 53 | "Minus" => Code::Minus, 54 | "Period" => Code::Period, 55 | "Quote" => Code::Quote, 56 | "Semicolon" => Code::Semicolon, 57 | "Slash" => Code::Slash, 58 | "Backspace" => Code::Backspace, 59 | "CapsLock" => Code::CapsLock, 60 | "Delete" => Code::Delete, 61 | "Enter" => Code::Enter, 62 | "Space" => Code::Space, 63 | "Tab" => Code::Tab, 64 | "End" => Code::End, 65 | "Home" => Code::Home, 66 | "Insert" => Code::Insert, 67 | "PageDown" => Code::PageDown, 68 | "PageUp" => Code::PageUp, 69 | "ArrowDown" => Code::ArrowDown, 70 | "ArrowLeft" => Code::ArrowLeft, 71 | "ArrowRight" => Code::ArrowRight, 72 | "ArrowUp" => Code::ArrowUp, 73 | "NumLock" => Code::NumLock, 74 | "Numpad0" => Code::Numpad0, 75 | "Numpad1" => Code::Numpad1, 76 | "Numpad2" => Code::Numpad2, 77 | "Numpad3" => Code::Numpad3, 78 | "Numpad4" => Code::Numpad4, 79 | "Numpad5" => Code::Numpad5, 80 | "Numpad6" => Code::Numpad6, 81 | "Numpad7" => Code::Numpad7, 82 | "Numpad8" => Code::Numpad8, 83 | "Numpad9" => Code::Numpad9, 84 | "NumpadAdd" => Code::NumpadAdd, 85 | "NumpadDecimal" => Code::NumpadDecimal, 86 | "NumpadDivide" => Code::NumpadDivide, 87 | "NumpadMultiply" => Code::NumpadMultiply, 88 | "NumpadSubtract" => Code::NumpadSubtract, 89 | "Escape" => Code::Escape, 90 | "PrintScreen" => Code::PrintScreen, 91 | "ScrollLock" => Code::ScrollLock, 92 | "Pause" => Code::Pause, 93 | "AudioVolumeDown" => Code::AudioVolumeDown, 94 | "AudioVolumeMute" => Code::AudioVolumeMute, 95 | "AudioVolumeUp" => Code::AudioVolumeUp, 96 | "F1" => Code::F1, 97 | "F2" => Code::F2, 98 | "F3" => Code::F3, 99 | "F4" => Code::F4, 100 | "F5" => Code::F5, 101 | "F6" => Code::F6, 102 | "F7" => Code::F7, 103 | "F8" => Code::F8, 104 | "F9" => Code::F9, 105 | "F10" => Code::F10, 106 | "F11" => Code::F11, 107 | "F12" => Code::F12, 108 | _ => { 109 | return Err(format!("Unknown key code: {}", s)); 110 | } 111 | }; 112 | Ok(KeyCode(code)) 113 | } 114 | } 115 | 116 | impl From for Code { 117 | fn from(key_code: KeyCode) -> Self { 118 | key_code.0 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /styles/settings.scss: -------------------------------------------------------------------------------- 1 | $primary: #2e2d2b; 2 | $accent: #feb453; 3 | $divider: #ffffff0d; 4 | 5 | $text: #e5dfd5; 6 | $text2: #ada9a1; 7 | $mutedtext: #78756f; 8 | 9 | main { 10 | width: 100vw; 11 | height: 100vh; 12 | background-color: $primary; 13 | border: 1px solid $divider; 14 | display: flex; 15 | flex-direction: column; 16 | border-radius: 12px; 17 | justify-content: space-between; 18 | } 19 | 20 | .back { 21 | position: absolute; 22 | top: 16px; 23 | left: 16px; 24 | display: flex; 25 | gap: 8px; 26 | align-items: center; 27 | 28 | img { 29 | background-color: $divider; 30 | border-radius: 6px; 31 | padding: 8px 6px; 32 | } 33 | 34 | p { 35 | color: $text2; 36 | } 37 | } 38 | 39 | p { 40 | font-family: SFRoundedMedium; 41 | } 42 | 43 | .settings-container { 44 | width: 100%; 45 | height: 100%; 46 | margin-top: 26px; 47 | position: relative; 48 | font-size: 12px; 49 | font-family: SFRoundedMedium; 50 | 51 | .settings { 52 | position: absolute; 53 | left: 50%; 54 | transform: translateX(-50%); 55 | margin-left: -26px; 56 | display: flex; 57 | gap: 24px; 58 | 59 | .names { 60 | display: flex; 61 | flex-direction: column; 62 | gap: 16px; 63 | 64 | p { 65 | font-family: SFRoundedSemiBold; 66 | color: $text2; 67 | display: flex; 68 | justify-content: right; 69 | } 70 | } 71 | 72 | .actions { 73 | display: flex; 74 | flex-direction: column; 75 | gap: 16px; 76 | color: $mutedtext; 77 | } 78 | } 79 | } 80 | 81 | .launch { 82 | display: flex; 83 | align-items: center; 84 | gap: 6px; 85 | 86 | input[type="checkbox"] { 87 | appearance: none; 88 | width: 14px; 89 | height: 14px; 90 | background-color: transparent; 91 | border-radius: 5px; 92 | border: 1px solid $mutedtext; 93 | position: relative; 94 | cursor: pointer; 95 | transition: background-color 0.2s; 96 | 97 | &:checked { 98 | ~ .checkmark { 99 | opacity: 1; 100 | } 101 | } 102 | } 103 | 104 | .checkmark { 105 | height: 14px; 106 | width: 14px; 107 | position: absolute; 108 | opacity: 0; 109 | transition: opacity 0.2s; 110 | } 111 | 112 | p { 113 | color: $text2; 114 | } 115 | } 116 | 117 | .keybind-input { 118 | width: min-content; 119 | white-space: nowrap; 120 | padding: 6px; 121 | border: 1px solid $divider; 122 | color: $text2; 123 | display: flex; 124 | border-radius: 10px; 125 | outline: none; 126 | gap: 4px; 127 | 128 | .key { 129 | color: $text2; 130 | font-family: SFRoundedMedium; 131 | background-color: $divider; 132 | padding: 2px 6px; 133 | border-radius: 6px; 134 | font-size: 14px; 135 | } 136 | } 137 | 138 | .keybind-input:focus { 139 | border: 1px solid rgba(255, 255, 255, 0.2); 140 | } 141 | 142 | .empty-keybind { 143 | border-color: rgba(255, 82, 82, 0.298); 144 | } 145 | 146 | .top-bar { 147 | width: 100%; 148 | min-height: 56px; 149 | border-bottom: 1px solid $divider; 150 | } 151 | 152 | .bottom-bar { 153 | height: 40px; 154 | width: calc(100vw - 2px); 155 | backdrop-filter: blur(18px); 156 | background-color: hsla(40, 3%, 16%, 0.8); 157 | position: fixed; 158 | bottom: 1px; 159 | left: 1px; 160 | z-index: 100; 161 | border-radius: 0 0 12px 12px; 162 | display: flex; 163 | flex-direction: row; 164 | justify-content: space-between; 165 | padding-inline: 12px; 166 | padding-right: 6px; 167 | padding-top: 1px; 168 | align-items: center; 169 | font-size: 14px; 170 | border-top: 1px solid $divider; 171 | 172 | p { 173 | color: $text2; 174 | } 175 | 176 | .left { 177 | display: flex; 178 | align-items: center; 179 | gap: 8px; 180 | 181 | .logo { 182 | width: 18px; 183 | height: 18px; 184 | } 185 | } 186 | 187 | .right { 188 | display: flex; 189 | align-items: center; 190 | 191 | .actions div { 192 | display: flex; 193 | align-items: center; 194 | gap: 2px; 195 | } 196 | 197 | .divider { 198 | width: 2px; 199 | height: 12px; 200 | background-color: $divider; 201 | margin-left: 8px; 202 | margin-right: 4px; 203 | transition: all 0.2s; 204 | } 205 | 206 | .actions { 207 | padding: 4px; 208 | padding-left: 8px; 209 | display: flex; 210 | align-items: center; 211 | gap: 8px; 212 | border-radius: 7px; 213 | background-color: transparent; 214 | transition: all 0.2s; 215 | cursor: pointer; 216 | 217 | p { 218 | color: $text; 219 | } 220 | 221 | &.disabled { 222 | pointer-events: none; 223 | opacity: 0.5; 224 | } 225 | } 226 | 227 | .actions:hover { 228 | background-color: $divider; 229 | } 230 | 231 | &:hover .actions:hover ~ .divider { 232 | opacity: 0; 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |

Qopy

5 | 6 | The fixed and simple clipboard manager for both Windows and Linux. 7 | 8 | 9 | Windows (x64) 10 | 11 | • 12 | 13 | Windows (arm64) 14 | 15 |
16 | 17 | Linux (deb) 18 | 19 | • 20 | 21 | Linux (rpm) 22 | 23 | • 24 | 25 | Linux (AppImage) 26 | 27 |
28 | 29 | macOS (Silicon) 30 | 31 | • 32 | 33 | macOS (Intel) 34 | 35 |
36 |
37 | Nightly releases can be found here 38 | 39 |
40 | 41 | [discord »](https://discord.gg/invite/Y7SbYphVw9) 42 | 43 | > \[!IMPORTANT] 44 | > 45 | > **Star this project**, You will receive all release notifications from GitHub without any delay \~ ⭐️ 46 | 47 |
48 | Star History 49 | 50 | 51 | 52 | 53 | 54 |
55 | 56 | ![ziit](https://ziit.app/api/public/badge/cm98il90n0000o52c3my0bf5p/qopy) 57 | 58 | ## 📋 What is Qopy 59 | 60 | Qopy is a fixed clipboard manager designed as a simple alternative to the standard clipboard on Windows. It aims to provide a faster, more reliable experience while providing an extensive set of features compared to its Windows counterpart. 61 | 62 | ## 🚧 Roadmap 63 | - [x] [Setup guide](https://github.com/0PandaDEV/Qopy/blob/main/GET_STARTED.md) 64 | - [ ] Sync Clipboard across devices https://github.com/0PandaDEV/Qopy/issues/8 65 | - [x] Settings https://github.com/0PandaDEV/Qopy/issues/2 66 | - [x] Metadata for copied items https://github.com/0PandaDEV/Qopy/issues/5 67 | - [ ] Code highlighting https://github.com/0PandaDEV/Qopy/issues/7 68 | - [ ] Streamshare integration https://github.com/0PandaDEV/Qopy/issues/4 69 | - [ ] Content type filter https://github.com/0PandaDEV/Qopy/issues/16 70 | - [ ] Preview for copied files https://github.com/0PandaDEV/Qopy/issues/15 71 | - [ ] Convert files to other formats https://github.com/0PandaDEV/Qopy/issues/17 72 | - [x] Option for custom keybind https://github.com/0PandaDEV/Qopy/issues/3 73 | - [x] macOS Support https://github.com/0PandaDEV/Qopy/issues/13 74 | 75 | If you have ideas for features to include, please write a feature request [here](https://github.com/0pandadev/Qopy/issues). 76 | 77 | ## 📦 Concepts 78 | 79 | Here you can see a few concepts these might not be implemented: 80 | 81 | ![Clipboard](https://github.com/user-attachments/assets/45a44a13-6ebd-4f2d-84d2-55178e303a54) 82 | ![Settings](https://github.com/user-attachments/assets/bff5456a-f413-4e62-a43d-22c8e453aa87) 83 | 84 | 85 | ## ❤️ Donations & Support 86 | 87 | Qopy is open-source and free to use. I appreciate donations to support ongoing development and improvements. Your contributions are voluntary and help me enhance the app for everyone. 88 | 89 | 90 | 91 | ## ⌨️ Local development 92 | 93 | You can use GitHub Codespaces for online development: 94 | 95 | [![][codespaces-shield]][codespaces-link] 96 | 97 | Or to get Qopy set up on your machine, you'll need to have Rust and bun installed. Then, follow these steps: 98 | 99 | ```zsh 100 | git clone https://github.com/0pandadev/Qopy.git 101 | cd Qopy 102 | bun i 103 | bun dev 104 | ``` 105 | 106 | > \[!TIP] 107 | > 108 | > If you are interested in contributing code, feel free to check out the [Issues](https://github.com/0pandadev/Qopy/issues) section. 109 | 110 | ## 🔨 Building for production 111 | 112 | To build for production simply execute: 113 | 114 | ```zsh 115 | bun build 116 | ``` 117 | 118 | > \[!NOTE] 119 | > 120 | > Don't worry, it will fail at the end because it can not detect a Private key, but the installer files will be generated regardless of that. 121 | > 122 | > You can find them in `src-tauri/target/release/bundle`. 123 | 124 | ## 📝 License 125 | 126 | Qopy is licensed under AGPL-3. See the [LICENSE file](./LICENCE) for more information. 127 | 128 | [codespaces-link]: https://codespaces.new/0pandadev/Qopy 129 | [codespaces-shield]: https://github.com/codespaces/badge.svg 130 | -------------------------------------------------------------------------------- /src-tauri/src/utils/commands.rs: -------------------------------------------------------------------------------- 1 | use applications::{AppInfoContext, AppInfo, AppTrait, utils::image::RustImage}; 2 | use base64::{ engine::general_purpose::STANDARD, Engine }; 3 | use image::codecs::png::PngEncoder; 4 | use tauri::PhysicalPosition; 5 | use meta_fetcher; 6 | 7 | pub fn center_window_on_current_monitor(window: &tauri::WebviewWindow) { 8 | if 9 | let Some(monitor) = window 10 | .available_monitors() 11 | .unwrap() 12 | .iter() 13 | .find(|m| { 14 | let primary_monitor = window 15 | .primary_monitor() 16 | .unwrap() 17 | .expect("Failed to get primary monitor"); 18 | let mouse_position = primary_monitor.position(); 19 | let monitor_position = m.position(); 20 | let monitor_size = m.size(); 21 | mouse_position.x >= monitor_position.x && 22 | mouse_position.x < monitor_position.x + (monitor_size.width as i32) && 23 | mouse_position.y >= monitor_position.y && 24 | mouse_position.y < monitor_position.y + (monitor_size.height as i32) 25 | }) 26 | { 27 | let monitor_size = monitor.size(); 28 | let window_size = window.outer_size().unwrap(); 29 | 30 | let x = ((monitor_size.width as i32) - (window_size.width as i32)) / 2; 31 | let y = ((monitor_size.height as i32) - (window_size.height as i32)) / 2; 32 | 33 | window 34 | .set_position(PhysicalPosition::new(monitor.position().x + x, monitor.position().y + y)) 35 | .unwrap(); 36 | } 37 | } 38 | 39 | pub fn get_app_info() -> (String, Option) { 40 | println!("Getting app info"); 41 | let mut ctx = AppInfoContext::new(vec![]); 42 | println!("Created AppInfoContext"); 43 | ctx.refresh_apps().unwrap(); 44 | println!("Refreshed apps"); 45 | match ctx.get_frontmost_application() { 46 | Ok(window) => { 47 | println!("Found frontmost application: {}", window.name); 48 | let name = window.name.clone(); 49 | let icon = window 50 | .load_icon() 51 | .ok() 52 | .map(|i| { 53 | println!("Loading icon for {}", name); 54 | let png = i.to_png().unwrap(); 55 | let encoded = STANDARD.encode(png.get_bytes()); 56 | println!("Icon encoded successfully"); 57 | encoded 58 | }); 59 | println!("Returning app info: {} with icon: {}", name, icon.is_some()); 60 | (name, icon) 61 | } 62 | Err(e) => { 63 | println!("Failed to get frontmost application: {:?}", e); 64 | ("System".to_string(), None) 65 | } 66 | } 67 | } 68 | 69 | fn _process_icon_to_base64(path: &str) -> Result> { 70 | let img = image::open(path)?; 71 | let resized = img.resize(128, 128, image::imageops::FilterType::Lanczos3); 72 | let mut png_buffer = Vec::new(); 73 | resized.write_with_encoder(PngEncoder::new(&mut png_buffer))?; 74 | Ok(STANDARD.encode(png_buffer)) 75 | } 76 | 77 | pub fn detect_color(color: &str) -> bool { 78 | let color = color.trim().to_lowercase(); 79 | 80 | // hex 81 | if color.starts_with('#') && color.len() == color.trim_end_matches(char::is_whitespace).len() { 82 | let hex = &color[1..]; 83 | return match hex.len() { 84 | 3 | 6 | 8 => hex.chars().all(|c| c.is_ascii_hexdigit()), 85 | _ => false, 86 | }; 87 | } 88 | 89 | // rgb/rgba 90 | if 91 | (color.starts_with("rgb(") || color.starts_with("rgba(")) && 92 | color.ends_with(")") && 93 | !color[..color.len() - 1].contains(")") 94 | { 95 | let values = color 96 | .trim_start_matches("rgba(") 97 | .trim_start_matches("rgb(") 98 | .trim_end_matches(')') 99 | .split(',') 100 | .collect::>(); 101 | 102 | return match values.len() { 103 | 3 | 4 => values.iter().all(|v| v.trim().parse::().is_ok()), 104 | _ => false, 105 | }; 106 | } 107 | 108 | // hsl/hsla 109 | if 110 | (color.starts_with("hsl(") || color.starts_with("hsla(")) && 111 | color.ends_with(")") && 112 | !color[..color.len() - 1].contains(")") 113 | { 114 | let values = color 115 | .trim_start_matches("hsla(") 116 | .trim_start_matches("hsl(") 117 | .trim_end_matches(')') 118 | .split(',') 119 | .collect::>(); 120 | 121 | return match values.len() { 122 | 3 | 4 => values.iter().all(|v| v.trim().parse::().is_ok()), 123 | _ => false, 124 | }; 125 | } 126 | 127 | false 128 | } 129 | 130 | #[tauri::command] 131 | pub async fn fetch_page_meta(url: String) -> Result<(String, Option), String> { 132 | let metadata = meta_fetcher 133 | ::fetch_metadata(&url) 134 | .map_err(|e| format!("Failed to fetch metadata: {}", e))?; 135 | 136 | Ok((metadata.title.unwrap_or_else(|| "No title found".to_string()), metadata.image)) 137 | } 138 | -------------------------------------------------------------------------------- /types/keys.ts: -------------------------------------------------------------------------------- 1 | export enum KeyValues { 2 | Backquote = 'Backquote', 3 | Backslash = 'Backslash', 4 | BracketLeft = 'BracketLeft', 5 | BracketRight = 'BracketRight', 6 | Comma = 'Comma', 7 | Digit0 = 'Digit0', 8 | Digit1 = 'Digit1', 9 | Digit2 = 'Digit2', 10 | Digit3 = 'Digit3', 11 | Digit4 = 'Digit4', 12 | Digit5 = 'Digit5', 13 | Digit6 = 'Digit6', 14 | Digit7 = 'Digit7', 15 | Digit8 = 'Digit8', 16 | Digit9 = 'Digit9', 17 | Equal = 'Equal', 18 | KeyA = 'KeyA', 19 | KeyB = 'KeyB', 20 | KeyC = 'KeyC', 21 | KeyD = 'KeyD', 22 | KeyE = 'KeyE', 23 | KeyF = 'KeyF', 24 | KeyG = 'KeyG', 25 | KeyH = 'KeyH', 26 | KeyI = 'KeyI', 27 | KeyJ = 'KeyJ', 28 | KeyK = 'KeyK', 29 | KeyL = 'KeyL', 30 | KeyM = 'KeyM', 31 | KeyN = 'KeyN', 32 | KeyO = 'KeyO', 33 | KeyP = 'KeyP', 34 | KeyQ = 'KeyQ', 35 | KeyR = 'KeyR', 36 | KeyS = 'KeyS', 37 | KeyT = 'KeyT', 38 | KeyU = 'KeyU', 39 | KeyV = 'KeyV', 40 | KeyW = 'KeyW', 41 | KeyX = 'KeyX', 42 | KeyY = 'KeyY', 43 | KeyZ = 'KeyZ', 44 | Minus = 'Minus', 45 | Period = 'Period', 46 | Quote = 'Quote', 47 | Semicolon = 'Semicolon', 48 | Slash = 'Slash', 49 | AltLeft = 'AltLeft', 50 | AltRight = 'AltRight', 51 | Backspace = 'Backspace', 52 | CapsLock = 'CapsLock', 53 | ContextMenu = 'ContextMenu', 54 | ControlLeft = 'ControlLeft', 55 | ControlRight = 'ControlRight', 56 | Enter = 'Enter', 57 | MetaLeft = 'MetaLeft', 58 | MetaRight = 'MetaRight', 59 | ShiftLeft = 'ShiftLeft', 60 | ShiftRight = 'ShiftRight', 61 | Space = 'Space', 62 | Tab = 'Tab', 63 | Delete = 'Delete', 64 | End = 'End', 65 | Home = 'Home', 66 | Insert = 'Insert', 67 | PageDown = 'PageDown', 68 | PageUp = 'PageUp', 69 | ArrowDown = 'ArrowDown', 70 | ArrowLeft = 'ArrowLeft', 71 | ArrowRight = 'ArrowRight', 72 | ArrowUp = 'ArrowUp', 73 | NumLock = 'NumLock', 74 | Numpad0 = 'Numpad0', 75 | Numpad1 = 'Numpad1', 76 | Numpad2 = 'Numpad2', 77 | Numpad3 = 'Numpad3', 78 | Numpad4 = 'Numpad4', 79 | Numpad5 = 'Numpad5', 80 | Numpad6 = 'Numpad6', 81 | Numpad7 = 'Numpad7', 82 | Numpad8 = 'Numpad8', 83 | Numpad9 = 'Numpad9', 84 | NumpadAdd = 'NumpadAdd', 85 | NumpadDecimal = 'NumpadDecimal', 86 | NumpadDivide = 'NumpadDivide', 87 | NumpadMultiply = 'NumpadMultiply', 88 | NumpadSubtract = 'NumpadSubtract', 89 | Escape = 'Escape', 90 | PrintScreen = 'PrintScreen', 91 | ScrollLock = 'ScrollLock', 92 | Pause = 'Pause', 93 | AudioVolumeDown = 'AudioVolumeDown', 94 | AudioVolumeMute = 'AudioVolumeMute', 95 | AudioVolumeUp = 'AudioVolumeUp', 96 | F1 = 'F1', 97 | F2 = 'F2', 98 | F3 = 'F3', 99 | F4 = 'F4', 100 | F5 = 'F5', 101 | F6 = 'F6', 102 | F7 = 'F7', 103 | F8 = 'F8', 104 | F9 = 'F9', 105 | F10 = 'F10', 106 | F11 = 'F11', 107 | F12 = 'F12', 108 | } 109 | 110 | export enum KeyLabels { 111 | Backquote = '`', 112 | Backslash = '\\', 113 | BracketLeft = '[', 114 | BracketRight = ']', 115 | Comma = ',', 116 | Digit0 = '0', 117 | Digit1 = '1', 118 | Digit2 = '2', 119 | Digit3 = '3', 120 | Digit4 = '4', 121 | Digit5 = '5', 122 | Digit6 = '6', 123 | Digit7 = '7', 124 | Digit8 = '8', 125 | Digit9 = '9', 126 | Equal = '=', 127 | KeyA = 'A', 128 | KeyB = 'B', 129 | KeyC = 'C', 130 | KeyD = 'D', 131 | KeyE = 'E', 132 | KeyF = 'F', 133 | KeyG = 'G', 134 | KeyH = 'H', 135 | KeyI = 'I', 136 | KeyJ = 'J', 137 | KeyK = 'K', 138 | KeyL = 'L', 139 | KeyM = 'M', 140 | KeyN = 'N', 141 | KeyO = 'O', 142 | KeyP = 'P', 143 | KeyQ = 'Q', 144 | KeyR = 'R', 145 | KeyS = 'S', 146 | KeyT = 'T', 147 | KeyU = 'U', 148 | KeyV = 'V', 149 | KeyW = 'W', 150 | KeyX = 'X', 151 | KeyY = 'Y', 152 | KeyZ = 'Z', 153 | Minus = '-', 154 | Period = '.', 155 | Quote = "'", 156 | Semicolon = ';', 157 | Slash = '/', 158 | AltLeft = 'Alt', 159 | AltRight = 'Alt (Right)', 160 | Backspace = 'Backspace', 161 | CapsLock = 'Caps Lock', 162 | ContextMenu = 'Context Menu', 163 | ControlLeft = 'Ctrl', 164 | ControlRight = 'Ctrl (Right)', 165 | Enter = 'Enter', 166 | MetaLeft = 'Meta', 167 | MetaRight = 'Meta (Right)', 168 | ShiftLeft = 'Shift', 169 | ShiftRight = 'Shift (Right)', 170 | Space = 'Space', 171 | Tab = 'Tab', 172 | Delete = 'Delete', 173 | End = 'End', 174 | Home = 'Home', 175 | Insert = 'Insert', 176 | PageDown = 'Page Down', 177 | PageUp = 'Page Up', 178 | ArrowDown = '↓', 179 | ArrowLeft = '←', 180 | ArrowRight = '→', 181 | ArrowUp = '↑', 182 | NumLock = 'Num Lock', 183 | Numpad0 = 'Numpad 0', 184 | Numpad1 = 'Numpad 1', 185 | Numpad2 = 'Numpad 2', 186 | Numpad3 = 'Numpad 3', 187 | Numpad4 = 'Numpad 4', 188 | Numpad5 = 'Numpad 5', 189 | Numpad6 = 'Numpad 6', 190 | Numpad7 = 'Numpad 7', 191 | Numpad8 = 'Numpad 8', 192 | Numpad9 = 'Numpad 9', 193 | NumpadAdd = 'Numpad +', 194 | NumpadDecimal = 'Numpad .', 195 | NumpadDivide = 'Numpad /', 196 | NumpadMultiply = 'Numpad *', 197 | NumpadSubtract = 'Numpad -', 198 | Escape = 'Esc', 199 | PrintScreen = 'Print Screen', 200 | ScrollLock = 'Scroll Lock', 201 | Pause = 'Pause', 202 | AudioVolumeDown = 'Volume Down', 203 | AudioVolumeMute = 'Volume Mute', 204 | AudioVolumeUp = 'Volume Up', 205 | F1 = 'F1', 206 | F2 = 'F2', 207 | F3 = 'F3', 208 | F4 = 'F4', 209 | F5 = 'F5', 210 | F6 = 'F6', 211 | F7 = 'F7', 212 | F8 = 'F8', 213 | F9 = 'F9', 214 | F10 = 'F10', 215 | F11 = 'F11', 216 | F12 = 'F12', 217 | } -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | mod api; 7 | mod db; 8 | mod utils; 9 | 10 | use sqlx::sqlite::SqlitePoolOptions; 11 | use std::fs; 12 | use tauri::Manager; 13 | use tauri_plugin_aptabase::{EventTracker, InitOptions}; 14 | use tauri_plugin_autostart::MacosLauncher; 15 | use tauri_plugin_prevent_default::Flags; 16 | 17 | fn main() { 18 | let runtime = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); 19 | let _guard = runtime.enter(); 20 | 21 | #[cfg(target_os = "linux")] 22 | unsafe { 23 | std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); 24 | } 25 | 26 | tauri::Builder::default() 27 | .plugin(tauri_plugin_clipboard::init()) 28 | .plugin(tauri_plugin_os::init()) 29 | .plugin(tauri_plugin_sql::Builder::default().build()) 30 | .plugin(tauri_plugin_dialog::init()) 31 | .plugin(tauri_plugin_fs::init()) 32 | .plugin(tauri_plugin_updater::Builder::default().build()) 33 | .plugin( 34 | tauri_plugin_aptabase::Builder::new("A-SH-8937252746") 35 | .with_options(InitOptions { 36 | host: Some("https://aptabase.pandadev.net".to_string()), 37 | flush_interval: None, 38 | }) 39 | .with_panic_hook(Box::new(|client, info, msg| { 40 | let location = info 41 | .location() 42 | .map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column())) 43 | .unwrap_or_else(|| "".to_string()); 44 | 45 | let _ = client.track_event( 46 | "panic", 47 | Some(serde_json::json!({ 48 | "info": format!("{} ({})", msg, location), 49 | })), 50 | ); 51 | })) 52 | .build(), 53 | ) 54 | .plugin(tauri_plugin_autostart::init( 55 | MacosLauncher::LaunchAgent, 56 | Some(vec![]), 57 | )) 58 | .plugin( 59 | tauri_plugin_prevent_default::Builder::new() 60 | .with_flags(Flags::all().difference(Flags::CONTEXT_MENU)) 61 | .build(), 62 | ) 63 | .setup(|app| { 64 | #[cfg(target_os = "macos")] 65 | app.set_activation_policy(tauri::ActivationPolicy::Accessory); 66 | 67 | let app_data_dir = app.path().app_data_dir().unwrap(); 68 | utils::logger::init_logger(&app_data_dir).expect("Failed to initialize logger"); 69 | 70 | fs::create_dir_all(&app_data_dir).expect("Failed to create app data directory"); 71 | 72 | let db_path = app_data_dir.join("data.db"); 73 | let is_new_db = !db_path.exists(); 74 | if is_new_db { 75 | fs::File::create(&db_path).expect("Failed to create database file"); 76 | } 77 | 78 | let db_url = format!("sqlite:{}", db_path.to_str().unwrap()); 79 | 80 | let app_handle = app.handle().clone(); 81 | 82 | let app_handle_clone = app_handle.clone(); 83 | tauri::async_runtime::spawn(async move { 84 | let pool = SqlitePoolOptions::new() 85 | .max_connections(5) 86 | .connect(&db_url) 87 | .await 88 | .expect("Failed to create pool"); 89 | 90 | app_handle_clone.manage(pool); 91 | }); 92 | 93 | let main_window = app.get_webview_window("main"); 94 | 95 | let _ = db::database::setup(app); 96 | api::hotkeys::setup(app_handle.clone()); 97 | api::tray::setup(app)?; 98 | api::clipboard::setup(app.handle()); 99 | let _ = api::clipboard::start_monitor(app_handle.clone()); 100 | 101 | utils::commands::center_window_on_current_monitor(main_window.as_ref().unwrap()); 102 | main_window.as_ref().map(|w| w.hide()).unwrap_or(Ok(()))?; 103 | 104 | let _ = app.track_event("app_started", None); 105 | 106 | tauri::async_runtime::spawn(async move { 107 | api::updater::check_for_updates(app_handle, false).await; 108 | }); 109 | 110 | Ok(()) 111 | }) 112 | .on_window_event(|_app, _event| { 113 | #[cfg(not(dev))] 114 | if let tauri::WindowEvent::Focused(false) = _event { 115 | if let Some(window) = _app.get_webview_window("main") { 116 | let _ = window.hide(); 117 | } 118 | } 119 | }) 120 | .invoke_handler(tauri::generate_handler![ 121 | api::clipboard::write_and_paste, 122 | db::history::get_history, 123 | db::history::add_history_item, 124 | db::history::search_history, 125 | db::history::load_history_chunk, 126 | db::history::delete_history_item, 127 | db::history::clear_history, 128 | db::history::read_image, 129 | db::settings::get_setting, 130 | db::settings::save_setting, 131 | utils::commands::fetch_page_meta 132 | ]) 133 | .run(tauri::generate_context!()) 134 | .expect("error while running tauri application"); 135 | } 136 | -------------------------------------------------------------------------------- /README_ru.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |

Qopy

5 | 6 | Простой и исправленный менеджер буфера обмена как для Windows, так и для Linux. 7 | 8 | 9 | Windows (x64) 10 | 11 | • 12 | 13 | Windows (arm64) 14 | 15 |
16 | 17 | Linux (deb) 18 | 19 | • 20 | 21 | Linux (rpm) 22 | 23 | • 24 | 25 | Linux (AppImage) 26 | 27 |
28 | 29 | macOS (Silicon) 30 | 31 | • 32 | 33 | macOS (Intel) 34 | 35 |
36 |
37 | Тестовые версии можно найти тут 38 | 39 |
40 | 41 | [discord »](https://discord.gg/invite/Y7SbYphVw9) 42 | 43 | > \[!IMPORTANT] 44 | > 45 | > **Нажав на звезду**, Вы будете получать все уведомления от Github о новых версиях без задержек \~ ⭐️ 46 | 47 |
48 | Star History 49 | 50 | 51 | 52 | 53 | 54 | 55 |
56 | 57 | [![wakatime](https://wakatime.com/badge/user/018ce503-097f-4057-9599-db20b190920c/project/fe76359d-56c2-4a13-8413-55207b6ad298.svg?style=flat_square)](https://wakatime.com/badge/user/018ce503-097f-4057-9599-db20b190920c/project/fe76359d-56c2-4a13-8413-55207b6ad298) 58 | 59 | ## 📋 Что такое Qopy 60 | 61 | Qopy представляет собой исправленный менеджер буфера обмена, разработанный как простая альтернатива стандартному буферу обмена в Windows. Его цель - обеспечить более быструю и надежную работу, предоставляя при этом обширный набор функций по сравнению со своим аналогом в Windows. 62 | 63 | ## 🚧 Дорожная карта 64 | - [ ] [Руководство по установке](https://github.com/0PandaDEV/Qopy/blob/main/GET_STARTED.md) 65 | - [ ] Синхронизация буфера обмена между устройствами https://github.com/0PandaDEV/Qopy/issues/8 66 | - [ ] Настройки https://github.com/0PandaDEV/Qopy/issues/2 67 | - [x] Метаданные для скопированных элементов https://github.com/0PandaDEV/Qopy/issues/5 68 | - [ ] Выделение кода https://github.com/0PandaDEV/Qopy/issues/7 69 | - [ ] Интеграция Streamshare https://github.com/0PandaDEV/Qopy/issues/4 70 | - [ ] Фильтр типов контента https://github.com/0PandaDEV/Qopy/issues/16 71 | - [ ] Превью для скопированных файлов https://github.com/0PandaDEV/Qopy/issues/15 72 | - [ ] Конвертация файлов в другие форматы https://github.com/0PandaDEV/Qopy/issues/17 73 | - [x] Опция для пользовательской привязки клавиш https://github.com/0PandaDEV/Qopy/issues/3 74 | - [x] Поддержка macOS https://github.com/0PandaDEV/Qopy/issues/13 75 | 76 | Если у вас есть идеи для функций, которые можно добавить в будущем, пожалуйста, напишите об этом [здесь](https://github.com/0pandadev/Qopy/issues). 77 | 78 | ## 📦 Концепты 79 | 80 | Здесь вы можете увидеть несколько концепцов, которые могут быть не реализованы: 81 | 82 | ![Clipboard](https://github.com/user-attachments/assets/45a44a13-6ebd-4f2d-84d2-55178e303a54) 83 | ![Settings](https://github.com/user-attachments/assets/bff5456a-f413-4e62-a43d-22c8e453aa87) 84 | 85 | 86 | ## ❤️ Пожертвования и Поддержка 87 | 88 | Qopy имеет открытый исходный код и бесплатен для использования. Я ценю пожертвования в поддержку постоянной разработки и улучшений. Ваши взносы являются добровольными и помогают мне улучшить приложение для всех. 89 | 90 | 91 | 92 | ## ⌨️ Локальная разработка 93 | 94 | Вы можете использовать GitHub Codespaces для онлайн-разработки: 95 | 96 | [![][codespaces-shield]][codespaces-link] 97 | 98 | Или, чтобы настроить Qopy на вашем компьютере, вам необходимо установить Rust и bun. Затем выполните следующие действия: 99 | 100 | ```zsh 101 | git clone https://github.com/0pandadev/Qopy.git 102 | cd Qopy 103 | bun i 104 | bun dev 105 | ``` 106 | 107 | > \[!Tip] 108 | > 109 | > Если вы заинтересованы во внесении кода, не стесняйтесь смотреть здесь [Issues](https://github.com/0pandadev/Qopy/issues). 110 | 111 | ## 🔨 Сборка для продакшена 112 | 113 | Чтобы собрать для продакшена,просто выполните: 114 | 115 | ```zsh 116 | bun build 117 | ``` 118 | 119 | > \[!NOTE] 120 | > 121 | > Не волнуйтесь, в конце произойдет сбой, потому что он не сможет обнаружить Приватный ключ, но установочные файлы будут сгенерированы независимо от этого. 122 | > 123 | > Вы можете найти его в `src-tauri/target/release/bundle`. 124 | 125 | ## 📝 Лицензия 126 | 127 | Qopy лицензирован под GPL-3. Смотрите [LICENSE file](./LICENCE) для дополнительной информации. 128 | 129 | [codespaces-link]: https://codespaces.new/0pandadev/Qopy 130 | [codespaces-shield]: https://github.com/codespaces/badge.svg 131 | -------------------------------------------------------------------------------- /src-tauri/src/api/hotkeys.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::commands::center_window_on_current_monitor; 2 | use crate::utils::keys::KeyCode; 3 | use global_hotkey::{ 4 | hotkey::{ Code, HotKey, Modifiers }, 5 | GlobalHotKeyEvent, 6 | GlobalHotKeyManager, 7 | HotKeyState, 8 | }; 9 | use parking_lot::Mutex; 10 | use std::str::FromStr; 11 | use std::sync::Arc; 12 | use tauri::{ AppHandle, Manager, Listener }; 13 | use tauri_plugin_aptabase::EventTracker; 14 | 15 | #[derive(Default)] 16 | struct HotkeyState { 17 | manager: Option, 18 | registered_hotkey: Option, 19 | } 20 | 21 | unsafe impl Send for HotkeyState {} 22 | 23 | pub fn setup(app_handle: tauri::AppHandle) { 24 | let state = Arc::new(Mutex::new(HotkeyState::default())); 25 | let manager = match GlobalHotKeyManager::new() { 26 | Ok(manager) => manager, 27 | Err(err) => { 28 | eprintln!("Failed to initialize hotkey manager: {:?}", err); 29 | return; 30 | } 31 | }; 32 | 33 | { 34 | let mut hotkey_state = state.lock(); 35 | hotkey_state.manager = Some(manager); 36 | } 37 | 38 | let rt = app_handle.state::(); 39 | let initial_keybind = rt 40 | .block_on(crate::db::settings::get_keybind(app_handle.clone())) 41 | .expect("Failed to get initial keybind"); 42 | 43 | if let Err(e) = register_shortcut(&state, &initial_keybind) { 44 | eprintln!("Error registering initial shortcut: {:?}", e); 45 | } 46 | 47 | let state_clone = Arc::clone(&state); 48 | app_handle.listen("update-shortcut", move |event| { 49 | let payload_str = event.payload().replace("\\\"", "\""); 50 | let trimmed_str = payload_str.trim_matches('"'); 51 | unregister_current_hotkey(&state_clone); 52 | 53 | let payload: Vec = serde_json::from_str(trimmed_str).unwrap_or_default(); 54 | if let Err(e) = register_shortcut(&state_clone, &payload) { 55 | eprintln!("Error re-registering shortcut: {:?}", e); 56 | } 57 | }); 58 | 59 | let state_clone = Arc::clone(&state); 60 | app_handle.listen("save_keybind", move |event| { 61 | let payload_str = event.payload().to_string(); 62 | unregister_current_hotkey(&state_clone); 63 | 64 | let payload: Vec = serde_json::from_str(&payload_str).unwrap_or_default(); 65 | if let Err(e) = register_shortcut(&state_clone, &payload) { 66 | eprintln!("Error registering saved shortcut: {:?}", e); 67 | } 68 | }); 69 | 70 | setup_hotkey_receiver(app_handle); 71 | } 72 | 73 | fn setup_hotkey_receiver(app_handle: AppHandle) { 74 | std::thread::spawn(move || { 75 | loop { 76 | match GlobalHotKeyEvent::receiver().recv() { 77 | Ok(event) => { 78 | if event.state == HotKeyState::Released { 79 | continue; 80 | } 81 | handle_hotkey_event(&app_handle); 82 | } 83 | Err(e) => eprintln!("Error receiving hotkey event: {:?}", e), 84 | } 85 | } 86 | }); 87 | } 88 | 89 | fn unregister_current_hotkey(state: &Arc>) { 90 | let mut hotkey_state = state.lock(); 91 | if let Some(old_hotkey) = hotkey_state.registered_hotkey.take() { 92 | if let Some(manager) = &hotkey_state.manager { 93 | let _ = manager.unregister(old_hotkey); 94 | } 95 | } 96 | } 97 | 98 | fn register_shortcut(state: &Arc>, shortcut: &[String]) -> Result<(), Box> { 99 | let hotkey = parse_hotkey(shortcut)?; 100 | let mut hotkey_state = state.lock(); 101 | 102 | if let Some(manager) = &hotkey_state.manager { 103 | manager.register(hotkey.clone())?; 104 | hotkey_state.registered_hotkey = Some(hotkey); 105 | Ok(()) 106 | } else { 107 | Err("Hotkey manager not initialized".into()) 108 | } 109 | } 110 | 111 | fn parse_hotkey(shortcut: &[String]) -> Result> { 112 | let mut modifiers = Modifiers::empty(); 113 | let mut code = None; 114 | 115 | for part in shortcut { 116 | match part.as_str() { 117 | "ControlLeft" => modifiers |= Modifiers::CONTROL, 118 | "AltLeft" => modifiers |= Modifiers::ALT, 119 | "ShiftLeft" => modifiers |= Modifiers::SHIFT, 120 | "MetaLeft" => modifiers |= Modifiers::META, 121 | key => code = Some(Code::from(KeyCode::from_str(key)?)), 122 | } 123 | } 124 | 125 | let key_code = code.ok_or_else(|| "No valid key code found".to_string())?; 126 | Ok(HotKey::new(Some(modifiers), key_code)) 127 | } 128 | 129 | fn handle_hotkey_event(app_handle: &AppHandle) { 130 | let window = app_handle.get_webview_window("main").unwrap(); 131 | if window.is_visible().unwrap() { 132 | window.hide().unwrap(); 133 | } else { 134 | window.set_always_on_top(true).unwrap(); 135 | window.show().unwrap(); 136 | window.set_focus().unwrap(); 137 | 138 | let window_clone = window.clone(); 139 | std::thread::spawn(move || { 140 | std::thread::sleep(std::time::Duration::from_millis(100)); 141 | window_clone.set_always_on_top(false).unwrap(); 142 | }); 143 | 144 | center_window_on_current_monitor(&window); 145 | } 146 | 147 | let _ = app_handle.track_event( 148 | "hotkey_triggered", 149 | Some( 150 | serde_json::json!({ 151 | "action": if window.is_visible().unwrap() { "hide" } else { "show" } 152 | }) 153 | ) 154 | ); 155 | } -------------------------------------------------------------------------------- /pages/settings.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 230 | 231 | 234 | -------------------------------------------------------------------------------- /src-tauri/src/db/history.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::types::{ ContentType, HistoryItem }; 2 | use base64::{ engine::general_purpose::STANDARD, Engine }; 3 | use rand::{ rng, Rng }; 4 | use rand::distr::Alphanumeric; 5 | use sqlx::{ Row, SqlitePool }; 6 | use std::fs; 7 | use tauri_plugin_aptabase::EventTracker; 8 | 9 | pub async fn initialize_history(pool: &SqlitePool) -> Result<(), Box> { 10 | let id: String = rng() 11 | .sample_iter(&Alphanumeric) 12 | .take(16) 13 | .map(char::from) 14 | .collect(); 15 | 16 | sqlx::query( 17 | "INSERT INTO history (id, source, content_type, content, timestamp) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)" 18 | ) 19 | .bind(id) 20 | .bind("System") 21 | .bind("text") 22 | .bind("Welcome to your clipboard history!") 23 | .execute(pool).await?; 24 | 25 | Ok(()) 26 | } 27 | 28 | #[tauri::command] 29 | pub async fn get_history(pool: tauri::State<'_, SqlitePool>) -> Result, String> { 30 | let rows = sqlx 31 | ::query( 32 | "SELECT id, source, source_icon, content_type, content, favicon, timestamp, language FROM history ORDER BY timestamp DESC" 33 | ) 34 | .fetch_all(&*pool).await 35 | .map_err(|e| e.to_string())?; 36 | 37 | let items = rows 38 | .iter() 39 | .map(|row| HistoryItem { 40 | id: row.get("id"), 41 | source: row.get("source"), 42 | source_icon: row.get("source_icon"), 43 | content_type: ContentType::from(row.get::("content_type")), 44 | content: row.get("content"), 45 | favicon: row.get("favicon"), 46 | timestamp: row.get("timestamp"), 47 | language: row.get("language"), 48 | }) 49 | .collect(); 50 | 51 | Ok(items) 52 | } 53 | 54 | #[tauri::command] 55 | pub async fn add_history_item( 56 | app_handle: tauri::AppHandle, 57 | pool: tauri::State<'_, SqlitePool>, 58 | item: HistoryItem 59 | ) -> Result<(), String> { 60 | let (id, source, source_icon, content_type, content, favicon, timestamp, language) = 61 | item.to_row(); 62 | 63 | let existing = sqlx 64 | ::query("SELECT id FROM history WHERE content = ? AND content_type = ?") 65 | .bind(&content) 66 | .bind(&content_type) 67 | .fetch_optional(&*pool).await 68 | .map_err(|e| e.to_string())?; 69 | 70 | match existing { 71 | Some(_) => { 72 | sqlx 73 | ::query( 74 | "UPDATE history SET timestamp = strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now') WHERE content = ? AND content_type = ?" 75 | ) 76 | .bind(&content) 77 | .bind(&content_type) 78 | .execute(&*pool).await 79 | .map_err(|e| e.to_string())?; 80 | } 81 | None => { 82 | sqlx 83 | ::query( 84 | "INSERT INTO history (id, source, source_icon, content_type, content, favicon, timestamp, language) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" 85 | ) 86 | .bind(id) 87 | .bind(source) 88 | .bind(source_icon) 89 | .bind(content_type) 90 | .bind(content) 91 | .bind(favicon) 92 | .bind(timestamp) 93 | .bind(language) 94 | .execute(&*pool).await 95 | .map_err(|e| e.to_string())?; 96 | } 97 | } 98 | 99 | let _ = app_handle.track_event( 100 | "history_item_added", 101 | Some(serde_json::json!({ 102 | "content_type": item.content_type.to_string() 103 | })) 104 | ); 105 | 106 | Ok(()) 107 | } 108 | 109 | #[tauri::command] 110 | pub async fn search_history( 111 | pool: tauri::State<'_, SqlitePool>, 112 | query: String 113 | ) -> Result, String> { 114 | if query.trim().is_empty() { 115 | return Ok(Vec::new()); 116 | } 117 | 118 | let query = format!("%{}%", query); 119 | 120 | let rows = sqlx 121 | ::query( 122 | "SELECT id, source, source_icon, content_type, content, favicon, timestamp, language 123 | FROM history 124 | WHERE content LIKE ? 125 | ORDER BY timestamp DESC 126 | LIMIT 100" 127 | ) 128 | .bind(query) 129 | .fetch_all(&*pool).await 130 | .map_err(|e| e.to_string())?; 131 | 132 | let mut items = Vec::with_capacity(rows.len()); 133 | for row in rows.iter() { 134 | items.push(HistoryItem { 135 | id: row.get("id"), 136 | source: row.get("source"), 137 | source_icon: row.get("source_icon"), 138 | content_type: ContentType::from(row.get::("content_type")), 139 | content: row.get("content"), 140 | favicon: row.get("favicon"), 141 | timestamp: row.get("timestamp"), 142 | language: row.get("language"), 143 | }); 144 | } 145 | 146 | Ok(items) 147 | } 148 | 149 | #[tauri::command] 150 | pub async fn load_history_chunk( 151 | pool: tauri::State<'_, SqlitePool>, 152 | offset: i64, 153 | limit: i64 154 | ) -> Result, String> { 155 | let rows = sqlx 156 | ::query( 157 | "SELECT id, source, source_icon, content_type, content, favicon, timestamp, language FROM history ORDER BY timestamp DESC LIMIT ? OFFSET ?" 158 | ) 159 | .bind(limit) 160 | .bind(offset) 161 | .fetch_all(&*pool).await 162 | .map_err(|e| e.to_string())?; 163 | 164 | let items = rows 165 | .iter() 166 | .map(|row| HistoryItem { 167 | id: row.get("id"), 168 | source: row.get("source"), 169 | source_icon: row.get("source_icon"), 170 | content_type: ContentType::from(row.get::("content_type")), 171 | content: row.get("content"), 172 | favicon: row.get("favicon"), 173 | timestamp: row.get("timestamp"), 174 | language: row.get("language"), 175 | }) 176 | .collect(); 177 | 178 | Ok(items) 179 | } 180 | 181 | #[tauri::command] 182 | pub async fn delete_history_item( 183 | app_handle: tauri::AppHandle, 184 | pool: tauri::State<'_, SqlitePool>, 185 | id: String 186 | ) -> Result<(), String> { 187 | sqlx 188 | ::query("DELETE FROM history WHERE id = ?") 189 | .bind(id) 190 | .execute(&*pool).await 191 | .map_err(|e| e.to_string())?; 192 | 193 | let _ = app_handle.track_event("history_item_deleted", None); 194 | 195 | Ok(()) 196 | } 197 | 198 | #[tauri::command] 199 | pub async fn clear_history( 200 | app_handle: tauri::AppHandle, 201 | pool: tauri::State<'_, SqlitePool> 202 | ) -> Result<(), String> { 203 | sqlx 204 | ::query("DELETE FROM history") 205 | .execute(&*pool).await 206 | .map_err(|e| e.to_string())?; 207 | 208 | let _ = app_handle.track_event("history_cleared", None); 209 | 210 | Ok(()) 211 | } 212 | 213 | #[tauri::command] 214 | pub async fn read_image(filename: String) -> Result { 215 | let bytes = fs::read(filename).map_err(|e| e.to_string())?; 216 | Ok(STANDARD.encode(bytes)) 217 | } 218 | -------------------------------------------------------------------------------- /src-tauri/src/api/clipboard.rs: -------------------------------------------------------------------------------- 1 | use tauri_plugin_aptabase::EventTracker; 2 | use base64::{ engine::general_purpose::STANDARD, Engine }; 3 | // use hyperpolyglot; 4 | use lazy_static::lazy_static; 5 | use rdev::{ simulate, EventType, Key }; 6 | use regex::Regex; 7 | use sqlx::SqlitePool; 8 | use std::fs; 9 | use std::sync::atomic::{ AtomicBool, Ordering }; 10 | use std::{ thread, time::Duration }; 11 | use tauri::{ AppHandle, Emitter, Listener, Manager }; 12 | use tauri_plugin_clipboard::Clipboard; 13 | use tokio::runtime::Runtime as TokioRuntime; 14 | use url::Url; 15 | use uuid::Uuid; 16 | 17 | use crate::db; 18 | use crate::utils::commands::get_app_info; 19 | use crate::utils::favicon::fetch_favicon_as_base64; 20 | use crate::utils::types::{ ContentType, HistoryItem }; 21 | 22 | lazy_static! { 23 | static ref IS_PROGRAMMATIC_PASTE: AtomicBool = AtomicBool::new(false); 24 | } 25 | 26 | #[tauri::command] 27 | pub async fn write_and_paste( 28 | app_handle: AppHandle, 29 | content: String, 30 | content_type: String 31 | ) -> Result<(), String> { 32 | let clipboard = app_handle.state::(); 33 | 34 | match content_type.as_str() { 35 | "text" => clipboard.write_text(content).map_err(|e| e.to_string())?, 36 | "link" => clipboard.write_text(content).map_err(|e| e.to_string())?, 37 | "color" => clipboard.write_text(content).map_err(|e| e.to_string())?, 38 | "image" => { 39 | clipboard.write_image_base64(content).map_err(|e| e.to_string())?; 40 | } 41 | "files" => { 42 | clipboard 43 | .write_files_uris( 44 | content 45 | .split(", ") 46 | .map(|file| file.to_string()) 47 | .collect::>() 48 | ) 49 | .map_err(|e| e.to_string())?; 50 | } 51 | _ => { 52 | return Err("Unsupported content type".to_string()); 53 | } 54 | } 55 | 56 | IS_PROGRAMMATIC_PASTE.store(true, Ordering::SeqCst); 57 | 58 | thread::spawn(|| { 59 | thread::sleep(Duration::from_millis(100)); 60 | 61 | #[cfg(target_os = "macos")] 62 | let modifier_key = Key::MetaLeft; 63 | #[cfg(not(target_os = "macos"))] 64 | let modifier_key = Key::ControlLeft; 65 | 66 | let events = vec![ 67 | EventType::KeyPress(modifier_key), 68 | EventType::KeyPress(Key::KeyV), 69 | EventType::KeyRelease(Key::KeyV), 70 | EventType::KeyRelease(modifier_key) 71 | ]; 72 | 73 | for event in events { 74 | if let Err(e) = simulate(&event) { 75 | println!("Simulation error: {:?}", e); 76 | } 77 | thread::sleep(Duration::from_millis(20)); 78 | } 79 | }); 80 | 81 | tokio::spawn(async { 82 | tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; 83 | IS_PROGRAMMATIC_PASTE.store(false, Ordering::SeqCst); 84 | }); 85 | 86 | let _ = app_handle.track_event( 87 | "clipboard_paste", 88 | Some(serde_json::json!({ 89 | "content_type": content_type 90 | })) 91 | ); 92 | 93 | Ok(()) 94 | } 95 | 96 | pub fn setup(app: &AppHandle) { 97 | let app_handle = app.clone(); 98 | let runtime = TokioRuntime::new().expect("Failed to create Tokio runtime"); 99 | 100 | app_handle.clone().listen("plugin:clipboard://clipboard-monitor/update", move |_event| { 101 | let app_handle = app_handle.clone(); 102 | runtime.block_on(async move { 103 | if IS_PROGRAMMATIC_PASTE.load(Ordering::SeqCst) { 104 | return; 105 | } 106 | 107 | let clipboard = app_handle.state::(); 108 | let available_types = clipboard.available_types().unwrap(); 109 | 110 | let (app_name, app_icon) = get_app_info(); 111 | 112 | match get_pool(&app_handle).await { 113 | Ok(pool) => { 114 | if available_types.image { 115 | println!("Handling image change"); 116 | if let Ok(image_data) = clipboard.read_image_base64() { 117 | let file_path = save_image_to_file(&app_handle, &image_data).await 118 | .map_err(|e| e.to_string()) 119 | .unwrap_or_else(|e| e); 120 | let _ = db::history::add_history_item( 121 | app_handle.clone(), 122 | pool, 123 | HistoryItem::new( 124 | app_name, 125 | ContentType::Image, 126 | file_path, 127 | None, 128 | app_icon, 129 | None 130 | ) 131 | ).await; 132 | } 133 | } else if available_types.files { 134 | println!("Handling files change"); 135 | if let Ok(files) = clipboard.read_files() { 136 | for file in files { 137 | let _ = db::history::add_history_item( 138 | app_handle.clone(), 139 | pool.clone(), 140 | HistoryItem::new( 141 | app_name.clone(), 142 | ContentType::File, 143 | file, 144 | None, 145 | app_icon.clone(), 146 | None 147 | ) 148 | ).await; 149 | } 150 | } 151 | } else if available_types.text { 152 | println!("Handling text change"); 153 | if let Ok(text) = clipboard.read_text() { 154 | let text = text.to_string(); 155 | let url_regex = Regex::new( 156 | r"^https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)$" 157 | ).unwrap(); 158 | 159 | if url_regex.is_match(&text) { 160 | if let Ok(url) = Url::parse(&text) { 161 | let favicon = match fetch_favicon_as_base64(url).await { 162 | Ok(Some(f)) => Some(f), 163 | _ => None, 164 | }; 165 | 166 | let _ = db::history::add_history_item( 167 | app_handle.clone(), 168 | pool, 169 | HistoryItem::new( 170 | app_name, 171 | ContentType::Link, 172 | text, 173 | favicon, 174 | app_icon, 175 | None 176 | ) 177 | ).await; 178 | } 179 | } else { 180 | if text.is_empty() { 181 | return; 182 | } 183 | 184 | // Temporarily disabled code detection 185 | /*if let Some(detection) = hyperpolyglot::detect_from_text(&text) { 186 | let language = match detection { 187 | hyperpolyglot::Detection::Heuristics(lang) => lang.to_string(), 188 | _ => detection.language().to_string(), 189 | }; 190 | 191 | let _ = db::history::add_history_item( 192 | pool, 193 | HistoryItem::new(app_name, ContentType::Code, text, None, app_icon, Some(language)) 194 | ).await; 195 | } else*/ if crate::utils::commands::detect_color(&text) { 196 | let _ = db::history::add_history_item( 197 | app_handle.clone(), 198 | pool, 199 | HistoryItem::new( 200 | app_name, 201 | ContentType::Color, 202 | text, 203 | None, 204 | app_icon, 205 | None 206 | ) 207 | ).await; 208 | } else { 209 | let _ = db::history::add_history_item( 210 | app_handle.clone(), 211 | pool, 212 | HistoryItem::new( 213 | app_name, 214 | ContentType::Text, 215 | text.clone(), 216 | None, 217 | app_icon, 218 | None 219 | ) 220 | ).await; 221 | } 222 | } 223 | } 224 | } else { 225 | println!("Unknown clipboard content type"); 226 | } 227 | } 228 | Err(e) => { 229 | println!("Failed to get database pool: {}", e); 230 | } 231 | } 232 | 233 | let _ = app_handle.emit("clipboard-content-updated", ()); 234 | let _ = app_handle.track_event( 235 | "clipboard_copied", 236 | Some( 237 | serde_json::json!({ 238 | "content_type": if available_types.image { "image" } 239 | else if available_types.files { "files" } 240 | else if available_types.text { "text" } 241 | else { "unknown" } 242 | }) 243 | ) 244 | ); 245 | }); 246 | }); 247 | } 248 | 249 | async fn get_pool( 250 | app_handle: &AppHandle 251 | ) -> Result, Box> { 252 | Ok(app_handle.state::()) 253 | } 254 | 255 | #[tauri::command] 256 | pub fn start_monitor(app_handle: AppHandle) -> Result<(), String> { 257 | let clipboard = app_handle.state::(); 258 | clipboard.start_monitor(app_handle.clone()).map_err(|e| e.to_string())?; 259 | app_handle 260 | .emit("plugin:clipboard://clipboard-monitor/status", true) 261 | .map_err(|e| e.to_string())?; 262 | Ok(()) 263 | } 264 | 265 | async fn save_image_to_file( 266 | app_handle: &AppHandle, 267 | base64_data: &str 268 | ) -> Result> { 269 | let app_data_dir = app_handle.path().app_data_dir().unwrap(); 270 | let images_dir = app_data_dir.join("images"); 271 | fs::create_dir_all(&images_dir)?; 272 | 273 | let file_name = format!("{}.png", Uuid::new_v4()); 274 | let file_path = images_dir.join(&file_name); 275 | 276 | let bytes = STANDARD.decode(base64_data)?; 277 | fs::write(&file_path, bytes)?; 278 | 279 | Ok(file_path.to_string_lossy().into_owned()) 280 | } 281 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "Nightly Builds" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | prepare: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | version: ${{ steps.get_version.outputs.VERSION }} 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Get version 20 | id: get_version 21 | run: echo "VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version")" >> $GITHUB_OUTPUT 22 | 23 | build-macos: 24 | needs: prepare 25 | runs-on: macos-latest 26 | timeout-minutes: 30 27 | strategy: 28 | matrix: 29 | include: 30 | - args: "--target aarch64-apple-darwin" 31 | arch: "arm64" 32 | - args: "--target x86_64-apple-darwin" 33 | arch: "x64" 34 | env: 35 | APPLE_ID: ${{ secrets.APPLE_ID }} 36 | APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} 37 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Redact Sensitive Information 41 | run: | 42 | function redact_output { 43 | sed -e "s/${{ secrets.APPLE_ID }}/REDACTED/g;s/${{ secrets.APPLE_ID_PASSWORD }}/REDACTED/g;s/${{ secrets.APPLE_CERTIFICATE }}/REDACTED/g;s/${{ secrets.APPLE_CERTIFICATE_PASSWORD }}/REDACTED/g;s/${{ secrets.KEYCHAIN_PASSWORD }}/REDACTED/g;s/${{ secrets.PAT }}/REDACTED/g;s/${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}/REDACTED/g" 44 | } 45 | exec > >(redact_output) 2>&1 46 | - uses: actions/setup-node@v4 47 | with: 48 | node-version: 20 49 | - uses: dtolnay/rust-toolchain@stable 50 | with: 51 | targets: aarch64-apple-darwin,x86_64-apple-darwin 52 | - uses: swatinem/rust-cache@v2 53 | with: 54 | workspaces: "src-tauri -> target" 55 | cache-directories: "~/.cargo/registry/index/,~/.cargo/registry/cache/,~/.cargo/git/db/" 56 | shared-key: "macos-rust-cache" 57 | save-if: "true" 58 | - uses: actions/cache@v4 59 | with: 60 | path: ~/.pnpm-store 61 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 62 | restore-keys: | 63 | ${{ runner.os }}-pnpm- 64 | - run: npm install -g pnpm && pnpm install 65 | - name: Import Apple Developer Certificate 66 | env: 67 | APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} 68 | APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 69 | KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} 70 | run: | 71 | echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 72 | security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain 73 | security default-keychain -s build.keychain 74 | security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain 75 | security set-keychain-settings -lut 7200 build.keychain 76 | security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign 77 | security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain 78 | security find-identity -v -p codesigning build.keychain 79 | - name: Verify Certificate 80 | run: | 81 | CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Apple Development") 82 | CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') 83 | echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV 84 | echo "Certificate imported." 85 | - uses: tauri-apps/tauri-action@v0 86 | env: 87 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 88 | APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} 89 | APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 90 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} 91 | TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} 92 | APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} 93 | with: 94 | args: ${{ matrix.args }} 95 | - name: Debug Signing Process 96 | if: failure() 97 | run: | 98 | echo "Attempting manual signing:" 99 | timeout 300 codesign --force --options runtime --sign "$CERT_ID" --entitlements src-tauri/entitlements.plist src-tauri/target/${{ matrix.args == '--target aarch64-apple-darwin' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin' }}/release/bundle/macos/Qopy.app 100 | echo "Verifying signature:" 101 | codesign -dv --verbose=4 "src-tauri/target/${{ matrix.args == '--target aarch64-apple-darwin' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin' }}/release/bundle/macos/Qopy.app" | sed 's/.*Authority=.*/Authority=REDACTED/' 102 | - name: Set architecture label 103 | run: | 104 | if [[ "${{ matrix.args }}" == "--target aarch64-apple-darwin" ]]; then 105 | echo "ARCH_LABEL=aarch64-apple-darwin" >> $GITHUB_ENV 106 | else 107 | echo "ARCH_LABEL=x86_64-apple-darwin" >> $GITHUB_ENV 108 | fi 109 | - name: Rename and Publish macOS Artifacts 110 | run: | 111 | mv src-tauri/target/${{ env.ARCH_LABEL }}/release/bundle/dmg/*.dmg src-tauri/target/${{ env.ARCH_LABEL }}/release/bundle/dmg/Qopy-${{ needs.prepare.outputs.version }}_${{ matrix.arch }}.dmg 112 | mv src-tauri/target/${{ env.ARCH_LABEL }}/release/bundle/macos/*.app.tar.gz src-tauri/target/${{ env.ARCH_LABEL }}/release/bundle/macos/Qopy-${{ needs.prepare.outputs.version }}_${{ matrix.arch }}.app.tar.gz 113 | mv src-tauri/target/${{ env.ARCH_LABEL }}/release/bundle/macos/*.app.tar.gz.sig src-tauri/target/${{ env.ARCH_LABEL }}/release/bundle/macos/Qopy-${{ needs.prepare.outputs.version }}_${{ matrix.arch }}.app.tar.gz.sig 114 | - uses: actions/upload-artifact@v4 115 | with: 116 | name: macos-dmg-${{ matrix.arch }} 117 | path: "src-tauri/target/${{ env.ARCH_LABEL }}/release/bundle/dmg/*.dmg" 118 | - uses: actions/upload-artifact@v4 119 | with: 120 | name: updater-macos-${{ matrix.arch }} 121 | path: | 122 | src-tauri/target/${{ env.ARCH_LABEL }}/release/bundle/macos/*.app.tar.gz 123 | src-tauri/target/${{ env.ARCH_LABEL }}/release/bundle/macos/*.app.tar.gz.sig 124 | 125 | build-windows: 126 | needs: prepare 127 | strategy: 128 | matrix: 129 | include: 130 | - args: "--target x86_64-pc-windows-msvc" 131 | arch: "x64" 132 | target: "x86_64-pc-windows-msvc" 133 | - args: "--target aarch64-pc-windows-msvc" 134 | arch: "arm64" 135 | target: "aarch64-pc-windows-msvc" 136 | runs-on: windows-latest 137 | env: 138 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} 139 | steps: 140 | - uses: actions/checkout@v4 141 | - uses: actions/setup-node@v4 142 | with: 143 | node-version: 20 144 | - uses: dtolnay/rust-toolchain@stable 145 | with: 146 | targets: x86_64-pc-windows-msvc,aarch64-pc-windows-msvc 147 | - uses: swatinem/rust-cache@v2 148 | with: 149 | workspaces: "src-tauri -> target" 150 | cache-directories: "~/.cargo/registry/index/,~/.cargo/registry/cache/,~/.cargo/git/db/" 151 | shared-key: "windows-rust-cache" 152 | save-if: "true" 153 | - uses: actions/cache@v4 154 | with: 155 | path: ~/.pnpm-store 156 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 157 | restore-keys: | 158 | ${{ runner.os }}-pnpm- 159 | - run: npm install -g pnpm && pnpm install 160 | - uses: tauri-apps/tauri-action@v0 161 | env: 162 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 163 | with: 164 | args: ${{ matrix.args }} 165 | - name: List Bundle Directory 166 | shell: pwsh 167 | run: | 168 | Write-Output "Checking build directories..." 169 | Get-ChildItem -Path "src-tauri/target" -Recurse -Directory | Where-Object { $_.Name -eq "msi" } | ForEach-Object { 170 | Write-Output "Found MSI directory: $($_.FullName)" 171 | Get-ChildItem -Path $_.FullName -Filter "*.msi" | ForEach-Object { 172 | Write-Output "Found MSI file: $($_.FullName)" 173 | } 174 | } 175 | - name: Rename and Publish Windows Artifacts 176 | run: | 177 | mv src-tauri/target/${{ matrix.target }}/release/bundle/msi/*.msi src-tauri/target/${{ matrix.target }}/release/bundle/msi/Qopy-${{ needs.prepare.outputs.version }}_${{ matrix.arch }}.msi 178 | mv src-tauri/target/${{ matrix.target }}/release/bundle/msi/*.msi.sig src-tauri/target/${{ matrix.target }}/release/bundle/msi/Qopy-${{ needs.prepare.outputs.version }}_${{ matrix.arch }}.msi.sig 179 | - uses: actions/upload-artifact@v4 180 | with: 181 | name: windows-${{ matrix.arch }} 182 | path: src-tauri/target/${{ matrix.target }}/release/bundle/msi/*.msi 183 | - uses: actions/upload-artifact@v4 184 | with: 185 | name: updater-windows-${{ matrix.arch }} 186 | path: | 187 | src-tauri/target/${{ matrix.target }}/release/bundle/msi/*.msi 188 | src-tauri/target/${{ matrix.target }}/release/bundle/msi/*.msi.sig 189 | 190 | build-ubuntu: 191 | needs: prepare 192 | runs-on: ubuntu-latest 193 | env: 194 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} 195 | steps: 196 | - uses: actions/checkout@v4 197 | - uses: actions/setup-node@v4 198 | with: 199 | node-version: 20 200 | - uses: dtolnay/rust-toolchain@stable 201 | with: 202 | targets: x86_64-unknown-linux-gnu 203 | - uses: swatinem/rust-cache@v2 204 | with: 205 | workspaces: "src-tauri -> target" 206 | cache-directories: "~/.cargo/registry/index/,~/.cargo/registry/cache/,~/.cargo/git/db/" 207 | shared-key: "ubuntu-rust-cache" 208 | save-if: "true" 209 | - uses: actions/cache@v4 210 | with: 211 | path: ~/.pnpm-store 212 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 213 | restore-keys: | 214 | ${{ runner.os }}-pnpm- 215 | - name: Install dependencies 216 | run: | 217 | sudo apt update 218 | sudo apt install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libayatana-appindicator3-dev librsvg2-dev libasound2-dev rpm 219 | echo "PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig" >> $GITHUB_ENV 220 | - run: npm install -g pnpm && pnpm install 221 | - uses: tauri-apps/tauri-action@v0 222 | env: 223 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 224 | with: 225 | args: --target x86_64-unknown-linux-gnu 226 | - name: Rename Linux Artifacts 227 | run: | 228 | mv src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/Qopy-${{ needs.prepare.outputs.version }}.deb 229 | mv src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Qopy-${{ needs.prepare.outputs.version }}.AppImage 230 | mv src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Qopy-${{ needs.prepare.outputs.version }}.AppImage.sig 231 | mv src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/Qopy-${{ needs.prepare.outputs.version }}.rpm 232 | - uses: actions/upload-artifact@v4 233 | with: 234 | name: ubuntu-deb 235 | path: src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb 236 | - uses: actions/upload-artifact@v4 237 | with: 238 | name: ubuntu-appimage 239 | path: src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage 240 | - uses: actions/upload-artifact@v4 241 | with: 242 | name: ubuntu-rpm 243 | path: src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm 244 | - uses: actions/upload-artifact@v4 245 | with: 246 | name: updater-ubuntu 247 | path: | 248 | src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage 249 | src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | prepare: 11 | permissions: write-all 12 | runs-on: ubuntu-latest 13 | outputs: 14 | version: ${{ steps.get_version.outputs.VERSION }} 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Get version 18 | id: get_version 19 | run: | 20 | VERSION=$(node -p 'require("./src-tauri/tauri.conf.json").version') 21 | echo "VERSION=$VERSION" >> $GITHUB_OUTPUT 22 | 23 | build-macos: 24 | permissions: write-all 25 | needs: prepare 26 | strategy: 27 | matrix: 28 | include: 29 | - args: "--target aarch64-apple-darwin" 30 | arch: "silicon" 31 | - args: "--target x86_64-apple-darwin" 32 | arch: "intel" 33 | runs-on: macos-latest 34 | env: 35 | APPLE_ID: ${{ secrets.APPLE_ID }} 36 | APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} 37 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Redact Sensitive Information 41 | run: | 42 | function redact_output { 43 | sed -e "s/${{ secrets.REDACT_PATTERN }}/REDACTED/g" 44 | } 45 | exec > >(redact_output) 2>&1 46 | - uses: actions/setup-node@v4 47 | with: 48 | node-version: 20 49 | - uses: dtolnay/rust-toolchain@stable 50 | with: 51 | targets: aarch64-apple-darwin,x86_64-apple-darwin 52 | - uses: swatinem/rust-cache@v2 53 | with: 54 | workspaces: "src-tauri -> target" 55 | cache-directories: "~/.cargo/registry/index/,~/.cargo/registry/cache/,~/.cargo/git/db/" 56 | shared-key: "macos-rust-cache" 57 | save-if: "true" 58 | - uses: actions/cache@v4 59 | with: 60 | path: ~/.pnpm-store 61 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 62 | restore-keys: | 63 | ${{ runner.os }}-pnpm- 64 | - run: npm install -g pnpm && pnpm install 65 | - name: Import Apple Developer Certificate 66 | env: 67 | APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} 68 | APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 69 | KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} 70 | run: | 71 | echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 72 | security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain 73 | security default-keychain -s build.keychain 74 | security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain 75 | security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign 76 | security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain 77 | - uses: tauri-apps/tauri-action@v0 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} 81 | APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 82 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} 83 | TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} 84 | with: 85 | args: ${{ matrix.args }} 86 | 87 | - name: Rename macOS Artifacts 88 | run: | 89 | mv src-tauri/target/${{ matrix.args == '--target aarch64-apple-darwin' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin' }}/release/bundle/dmg/*.dmg src-tauri/target/${{ matrix.args == '--target aarch64-apple-darwin' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin' }}/release/bundle/dmg/Qopy-${{ needs.prepare.outputs.version }}_${{ matrix.arch }}.dmg 90 | - name: Upload artifacts 91 | uses: actions/upload-artifact@v4 92 | with: 93 | name: macos-${{ matrix.arch }}-binaries 94 | path: | 95 | src-tauri/target/**/release/bundle/dmg/*.dmg 96 | 97 | build-windows: 98 | permissions: write-all 99 | needs: prepare 100 | strategy: 101 | matrix: 102 | include: 103 | - args: "--target x86_64-pc-windows-msvc" 104 | arch: "x64" 105 | target: "x86_64-pc-windows-msvc" 106 | - args: "--target aarch64-pc-windows-msvc" 107 | arch: "arm64" 108 | target: "aarch64-pc-windows-msvc" 109 | runs-on: windows-latest 110 | env: 111 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} 112 | steps: 113 | - uses: actions/checkout@v4 114 | - uses: actions/setup-node@v4 115 | with: 116 | node-version: 20 117 | - uses: dtolnay/rust-toolchain@stable 118 | with: 119 | targets: x86_64-pc-windows-msvc,aarch64-pc-windows-msvc 120 | - uses: swatinem/rust-cache@v2 121 | with: 122 | workspaces: "src-tauri -> target" 123 | cache-directories: "~/.cargo/registry/index/,~/.cargo/registry/cache/,~/.cargo/git/db/" 124 | shared-key: "windows-rust-cache" 125 | save-if: "true" 126 | - uses: actions/cache@v4 127 | with: 128 | path: ~/.pnpm-store 129 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 130 | restore-keys: | 131 | ${{ runner.os }}-pnpm- 132 | - run: npm install -g pnpm && pnpm install 133 | - uses: tauri-apps/tauri-action@v0 134 | env: 135 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 136 | with: 137 | args: ${{ matrix.args }} 138 | - name: List Bundle Directory 139 | shell: pwsh 140 | run: | 141 | $bundlePath = "src-tauri/target/${{ matrix.target }}/release/bundle/msi" 142 | if (Test-Path $bundlePath) { 143 | Write-Output "Contents of ${bundlePath}:" 144 | Get-ChildItem -Path $bundlePath 145 | } else { 146 | Write-Output "Path ${bundlePath} does not exist." 147 | } 148 | - name: Rename Windows Artifacts 149 | shell: pwsh 150 | run: | 151 | $bundlePath = "src-tauri/target/${{ matrix.target }}/release/bundle/msi" 152 | $version = "${{ needs.prepare.outputs.version }}" 153 | $arch = "${{ matrix.arch }}" 154 | if (Test-Path $bundlePath) { 155 | $msiFiles = Get-ChildItem -Path "$bundlePath/*.msi" 156 | foreach ($file in $msiFiles) { 157 | $newName = "Qopy-$version`_$arch.msi" 158 | Rename-Item -Path $file.FullName -NewName $newName 159 | } 160 | } else { 161 | Write-Error "Path ${bundlePath} does not exist." 162 | exit 1 163 | } 164 | - name: Upload artifacts 165 | uses: actions/upload-artifact@v4 166 | with: 167 | name: windows-${{ matrix.arch }}-binaries 168 | path: src-tauri/target/${{ matrix.target }}/release/bundle/msi/*.msi 169 | 170 | build-linux: 171 | permissions: write-all 172 | needs: prepare 173 | runs-on: ubuntu-latest 174 | env: 175 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} 176 | steps: 177 | - uses: actions/checkout@v4 178 | with: 179 | fetch-depth: 0 180 | - uses: actions/setup-node@v4 181 | with: 182 | node-version: 20 183 | - uses: dtolnay/rust-toolchain@stable 184 | - uses: swatinem/rust-cache@v2 185 | with: 186 | workspaces: "src-tauri -> target" 187 | cache-directories: "~/.cargo/registry/index/,~/.cargo/registry/cache/,~/.cargo/git/db/" 188 | shared-key: "linux-rust-cache" 189 | save-if: "true" 190 | - uses: actions/cache@v4 191 | with: 192 | path: ~/.pnpm-store 193 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 194 | restore-keys: | 195 | ${{ runner.os }}-pnpm- 196 | - name: Install dependencies 197 | run: | 198 | sudo apt update 199 | sudo apt install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libayatana-appindicator3-dev librsvg2-dev libasound2-dev rpm 200 | echo "PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig" >> $GITHUB_ENV 201 | - run: npm install -g pnpm && pnpm install 202 | - name: Generate Changelog 203 | id: changelog 204 | run: | 205 | CHANGELOG=$(git log $(git describe --tags --abbrev=0)..HEAD --pretty=format:"- %s") 206 | echo "CHANGELOG<> $GITHUB_ENV 207 | echo "$CHANGELOG" >> $GITHUB_ENV 208 | echo "EOF" >> $GITHUB_ENV 209 | - uses: tauri-apps/tauri-action@v0 210 | env: 211 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 212 | with: 213 | args: --target x86_64-unknown-linux-gnu 214 | - name: Rename Linux Artifacts 215 | run: | 216 | mv src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/Qopy-${{ needs.prepare.outputs.version }}.deb 217 | mv src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Qopy-${{ needs.prepare.outputs.version }}.AppImage 218 | mv src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/Qopy-${{ needs.prepare.outputs.version }}.rpm 219 | - name: Upload artifacts 220 | uses: actions/upload-artifact@v4 221 | with: 222 | name: linux-binaries 223 | path: | 224 | src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb 225 | src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage 226 | src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm 227 | 228 | create-release: 229 | permissions: write-all 230 | needs: [prepare, build-macos, build-windows, build-linux] 231 | runs-on: ubuntu-latest 232 | steps: 233 | - uses: actions/checkout@v4 234 | with: 235 | fetch-depth: 0 236 | token: ${{ secrets.PAT }} 237 | 238 | - name: Check if release already exists 239 | id: check_release 240 | run: | 241 | VERSION="${{ needs.prepare.outputs.version }}" 242 | RELEASE_EXISTS=$(gh release view v$VERSION --json id --jq '.id' 2>/dev/null || echo "") 243 | if [ -n "$RELEASE_EXISTS" ]; then 244 | echo "SKIP_RELEASE=true" >> $GITHUB_ENV 245 | else 246 | echo "SKIP_RELEASE=false" >> $GITHUB_ENV 247 | fi 248 | env: 249 | GITHUB_TOKEN: ${{ secrets.PAT }} 250 | 251 | - name: Download all artifacts 252 | if: env.SKIP_RELEASE == 'false' 253 | uses: actions/download-artifact@v4 254 | with: 255 | path: artifacts 256 | 257 | - name: Update CHANGELOG 258 | if: env.SKIP_RELEASE == 'false' 259 | id: changelog 260 | uses: requarks/changelog-action@v1 261 | with: 262 | token: ${{ github.token }} 263 | tag: ${{ github.ref_name }} 264 | 265 | - name: Generate Release Body 266 | if: env.SKIP_RELEASE == 'false' 267 | id: release_body 268 | run: | 269 | VERSION="${{ needs.prepare.outputs.version }}" 270 | 271 | # Calculate hashes with corrected paths 272 | WINDOWS_ARM_HASH=$(sha256sum "artifacts/windows-arm64-binaries/Qopy-${VERSION}_arm64.msi" | awk '{ print $1 }') 273 | WINDOWS_64_HASH=$(sha256sum "artifacts/windows-x64-binaries/Qopy-${VERSION}_x64.msi" | awk '{ print $1 }') 274 | MAC_SILICON_HASH=$(sha256sum "artifacts/macos-silicon-binaries/aarch64-apple-darwin/release/bundle/dmg/Qopy-${VERSION}_silicon.dmg" | awk '{ print $1 }') 275 | MAC_INTEL_HASH=$(sha256sum "artifacts/macos-intel-binaries/x86_64-apple-darwin/release/bundle/dmg/Qopy-${VERSION}_intel.dmg" | awk '{ print $1 }') 276 | DEBIAN_HASH=$(sha256sum "artifacts/linux-binaries/deb/Qopy-${VERSION}.deb" | awk '{ print $1 }') 277 | APPIMAGE_HASH=$(sha256sum "artifacts/linux-binaries/appimage/Qopy-${VERSION}.AppImage" | awk '{ print $1 }') 278 | REDHAT_HASH=$(sha256sum "artifacts/linux-binaries/rpm/Qopy-${VERSION}.rpm" | awk '{ print $1 }') 279 | 280 | # Debug output 281 | echo "Calculated hashes:" 282 | echo "Windows ARM: $WINDOWS_ARM_HASH" 283 | echo "Windows x64: $WINDOWS_64_HASH" 284 | echo "Mac Silicon: $MAC_SILICON_HASH" 285 | echo "Mac Intel: $MAC_INTEL_HASH" 286 | echo "Debian: $DEBIAN_HASH" 287 | echo "AppImage: $APPIMAGE_HASH" 288 | echo "Red Hat: $REDHAT_HASH" 289 | 290 | RELEASE_BODY=$(cat <<-EOF 291 | 292 | ${{ needs.create-release.outputs.changelog }} 293 | 294 | ## ⬇️ Downloads 295 | 296 | - [Windows (x64)](https://github.com/${{ github.repository }}/releases/download/v${VERSION}/Qopy-${VERSION}_x64.msi) - ${WINDOWS_64_HASH} 297 | - [Windows (ARM64)](https://github.com/${{ github.repository }}/releases/download/v${VERSION}/Qopy-${VERSION}_arm64.msi) - ${WINDOWS_ARM_HASH} 298 | - [macOS (Silicon)](https://github.com/${{ github.repository }}/releases/download/v${VERSION}/Qopy-${VERSION}_silicon.dmg) - ${MAC_SILICON_HASH} 299 | - [macOS (Intel)](https://github.com/${{ github.repository }}/releases/download/v${VERSION}/Qopy-${VERSION}_intel.dmg) - ${MAC_INTEL_HASH} 300 | - [Debian](https://github.com/${{ github.repository }}/releases/download/v${VERSION}/Qopy-${VERSION}.deb) - ${DEBIAN_HASH} 301 | - [AppImage](https://github.com/${{ github.repository }}/releases/download/v${VERSION}/Qopy-${VERSION}.AppImage) - ${APPIMAGE_HASH} 302 | - [Red Hat](https://github.com/${{ github.repository }}/releases/download/v${VERSION}/Qopy-${VERSION}.rpm) - ${REDHAT_HASH} 303 | EOF 304 | ) 305 | 306 | echo "RELEASE_BODY<> $GITHUB_ENV 307 | echo "$RELEASE_BODY" >> $GITHUB_ENV 308 | echo "EOF" >> $GITHUB_ENV 309 | 310 | - name: Create Release 311 | if: env.SKIP_RELEASE == 'false' 312 | uses: softprops/action-gh-release@v2 313 | env: 314 | GITHUB_TOKEN: ${{ secrets.PAT }} 315 | with: 316 | draft: true 317 | tag_name: v${{ needs.prepare.outputs.version }} 318 | name: v${{ needs.prepare.outputs.version }} 319 | files: | 320 | artifacts/**/*.dmg 321 | artifacts/**/*.msi 322 | artifacts/**/*.deb 323 | artifacts/**/*.AppImage 324 | artifacts/**/*.rpm 325 | body: ${{ env.RELEASE_BODY }} -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 847 | 848 | 851 | --------------------------------------------------------------------------------