Merge pull request #29 from 0PandaDEV/issue/settings

This commit is contained in:
PandaDEV 2025-01-10 22:21:54 +10:00 committed by GitHub
commit c0b50fcc80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1446 additions and 704 deletions

View file

@ -234,30 +234,41 @@ jobs:
with:
fetch-depth: 0
- name: Check if release already exists
id: check_release
run: |
VERSION="${{ needs.prepare.outputs.version }}"
RELEASE_EXISTS=$(gh release view v$VERSION --json id --jq '.id' 2>/dev/null || echo "")
if [ -n "$RELEASE_EXISTS" ]; then
echo "Release v$VERSION already exists. Skipping release creation."
echo "SKIP_RELEASE=true" >> $GITHUB_ENV
else
echo "Release v$VERSION does not exist. Proceeding with release creation."
echo "SKIP_RELEASE=false" >> $GITHUB_ENV
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download all artifacts
if: env.SKIP_RELEASE == 'false'
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Update CHANGELOG
if: env.SKIP_RELEASE == 'false'
id: changelog
uses: requarks/changelog-action@v1
with:
token: ${{ github.token }}
tag: ${{ github.ref_name }}
- name: Generate Release Body
if: env.SKIP_RELEASE == 'false'
id: release_body
run: |
VERSION="${{ needs.prepare.outputs.version }}"
# Get the most recent release tag (v* tags only)
LAST_TAG=$(git describe --match "v*" --abbrev=0 --tags `git rev-list --tags --skip=1 --max-count=1` 2>/dev/null || echo "")
if [ -n "$LAST_TAG" ]; then
echo "Debug: Found last release tag: $LAST_TAG"
CHANGES=$(git log ${LAST_TAG}..HEAD --pretty=format:"- %s")
else
echo "Debug: No previous release tag found, using first commit"
CHANGES=$(git log --pretty=format:"- %s")
fi
echo "Debug: Changelog content:"
echo "$CHANGES"
# Calculate hashes with corrected paths
WINDOWS_ARM_HASH=$(sha256sum "artifacts/windows-arm64-binaries/Qopy-${VERSION}_arm64.msi" | awk '{ print $1 }')
WINDOWS_64_HASH=$(sha256sum "artifacts/windows-x64-binaries/Qopy-${VERSION}_x64.msi" | awk '{ print $1 }')
@ -278,9 +289,8 @@ jobs:
echo "Red Hat: $REDHAT_HASH"
RELEASE_BODY=$(cat <<-EOF
## ♻️ Changelog
$CHANGES
${{ needs.create-release.outputs.changelog }}
## ⬇️ Downloads
@ -299,6 +309,7 @@ jobs:
echo "EOF" >> $GITHUB_ENV
- name: Create Release
if: env.SKIP_RELEASE == 'false'
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -12,7 +12,7 @@ All the data of Qopy is stored inside of a SQLite database.
## Disable Windows+V for default clipboard manager
https://github.com/user-attachments/assets/723f9e07-3190-46ec-9bb7-15dfc112f620
<video src="https://github.com/user-attachments/assets/723f9e07-3190-46ec-9bb7-15dfc112f620" controls title="Disable Windows+V for default clipboard manager"></video>
To disable the default clipboard manager popup from windows open Command prompt and run this command

42
app.vue
View file

@ -1,27 +1,36 @@
<template>
<div style="pointer-events: auto;">
<div style="pointer-events: auto">
<NuxtPage />
</div>
</template>
<script setup lang="ts">
import { listen } from '@tauri-apps/api/event'
import { app, window } from '@tauri-apps/api';
import { onMounted } from 'vue'
import { listen } from "@tauri-apps/api/event";
import { app, window } from "@tauri-apps/api";
import { disable, enable } from "@tauri-apps/plugin-autostart";
import { onMounted } from "vue";
const keyboard = useKeyboard();
const { $settings } = useNuxtApp();
onMounted(async () => {
await listen('change_keybind', async () => {
console.log("change_keybind");
await navigateTo('/settings')
await listen("settings", async () => {
keyboard.unregisterAll();
await navigateTo("/settings");
await app.show();
await window.getCurrentWindow().show();
})
});
await listen('main_route', async () => {
console.log("main_route");
await navigateTo('/')
})
})
if ((await $settings.getSetting("autostart")) === "true") {
await enable();
} else {
await disable();
}
await listen("main_route", async () => {
await navigateTo("/");
});
});
</script>
<style lang="scss">
@ -53,7 +62,6 @@ onMounted(async () => {
margin: 0;
padding: 0;
box-sizing: border-box;
color: #E5DFD5;
text-decoration: none;
font-family: SFRoundedRegular;
scroll-behavior: smooth;
@ -62,9 +70,9 @@ onMounted(async () => {
position: relative;
z-index: 1;
--os-handle-bg: #ADA9A1;
--os-handle-bg-hover: #78756F;
--os-handle-bg-active: #78756F;
--os-handle-bg: #ada9a1;
--os-handle-bg-hover: #78756f;
--os-handle-bg-active: #78756f;
}
html,

View file

@ -22,7 +22,7 @@ $mutedtext: #78756f;
position: fixed;
top: 0;
left: 0;
height: 54px;
height: 56px;
background-color: transparent;
outline: none;
border: none;
@ -35,10 +35,10 @@ $mutedtext: #78756f;
.results {
position: absolute;
width: 284px;
top: 53px;
width: 286px;
top: 55px;
left: 0;
height: calc(100vh - 95px);
height: 417px;
border-right: 1px solid $divider;
display: flex;
flex-direction: column;
@ -46,6 +46,7 @@ $mutedtext: #78756f;
padding-bottom: 8px;
overflow-y: auto;
overflow-x: hidden;
z-index: 3;
.result {
height: 40px;
@ -59,6 +60,7 @@ $mutedtext: #78756f;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
color: $text;
}
.result {
@ -96,20 +98,22 @@ $mutedtext: #78756f;
.content {
position: absolute;
top: 53px;
left: 284px;
height: calc(100vh - 254px);
top: 55px;
left: 285px;
height: 220px;
font-family: CommitMono !important;
font-size: 12px;
letter-spacing: 1;
border-radius: 10px;
width: calc(100vw - 286px);
width: 465px;
white-space: pre-wrap;
word-wrap: break-word;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
z-index: 2;
color: $text;
&:not(:has(.image)) {
padding: 8px;
@ -128,7 +132,7 @@ $mutedtext: #78756f;
}
.bottom-bar {
height: 40px;
height: 39px;
width: calc(100vw - 2px);
backdrop-filter: blur(18px);
background-color: hsla(40, 3%, 16%, 0.8);
@ -215,18 +219,20 @@ $mutedtext: #78756f;
display: flex;
flex-direction: column;
gap: 14px;
bottom: 40px;
left: 284px;
bottom: 39px;
left: 285px;
height: 160px;
width: calc(100vw - 286px);
width: 465px;
border-top: 1px solid $divider;
background-color: $primary;
padding: 14px;
z-index: 1;
.title {
font-family: SFRoundedSemiBold;
font-size: 12px;
letter-spacing: 0.6px;
color: $text;
}
.info-content {

View file

@ -36,40 +36,116 @@ $mutedtext: #78756f;
}
}
.keybind-container {
p {
font-family: SFRoundedMedium;
}
.settings-container {
width: 100%;
margin-top: 26px;
position: relative;
font-size: 12px;
font-family: SFRoundedMedium;
.settings {
position: absolute;
left: 50%;
transform: translateX(-50%);
margin-left: -26px;
display: flex;
gap: 24px;
.names {
display: flex;
flex-direction: column;
gap: 16px;
p {
font-family: SFRoundedSemiBold;
color: $text2;
display: flex;
justify-content: right;
}
}
.actions {
display: flex;
flex-direction: column;
gap: 16px;
color: $mutedtext;
}
}
}
.launch {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 6px;
.title {
font-size: 20px;
font-weight: 800;
}
input[type="checkbox"] {
appearance: none;
width: 14px;
height: 14px;
background-color: transparent;
border-radius: 5px;
border: 1px solid $mutedtext;
position: relative;
cursor: pointer;
transition: background-color 0.2s;
.keybind-input {
padding: 6px;
border: 1px solid $divider;
color: $text2;
display: flex;
border-radius: 13px;
outline: none;
gap: 6px;
.key {
color: $text2;
font-family: SFRoundedMedium;
background-color: $divider;
padding: 6px 8px;
border-radius: 8px;
&:checked {
~ .checkmark {
opacity: 1;
}
}
}
.keybind-input:focus {
border: 1px solid rgba(255, 255, 255, 0.2);
.checkmark {
height: 14px;
width: 14px;
position: absolute;
opacity: 0;
transition: opacity 0.2s;
}
p {
color: $text2;
}
}
.keybind-input {
width: min-content;
white-space: nowrap;
padding: 6px;
border: 1px solid $divider;
color: $text2;
display: flex;
border-radius: 10px;
outline: none;
gap: 4px;
.key {
color: $text2;
font-family: SFRoundedMedium;
background-color: $divider;
padding: 2px 6px;
border-radius: 6px;
font-size: 14px;
}
}
.keybind-input:focus {
border: 1px solid rgba(255, 255, 255, 0.2);
}
.empty-keybind {
border-color: rgba(255, 82, 82, 0.298);
}
.top-bar {
width: 100%;
height: 56px;
border-bottom: 1px solid $divider;
}
.bottom-bar {
@ -136,6 +212,15 @@ $mutedtext: #78756f;
background-color: transparent;
transition: all 0.2s;
cursor: pointer;
p {
color: $text;
}
&.disabled {
pointer-events: none;
opacity: 0.5;
}
}
.actions:hover {

View file

@ -20,9 +20,12 @@
"sass-embedded": "1.83.0",
"uuid": "11.0.3",
"vue": "3.5.13",
"wrdu-keyboard": "1.1.1"
"wrdu-keyboard": "3.0.0"
},
"overrides": {
"chokidar": "^3.6.0"
},
"patchedDependencies": {
"wrdu-keyboard@3.0.0": "patches/wrdu-keyboard@3.0.0.patch"
}
}

View file

@ -65,10 +65,17 @@
</template>
<template v-else-if="hasFavicon(item.favicon ?? '')">
<img
:src="item.favicon ? getFaviconFromDb(item.favicon) : '../public/icons/Link.svg'"
:src="
item.favicon
? getFaviconFromDb(item.favicon)
: '../public/icons/Link.svg'
"
alt="Favicon"
class="favicon"
@error="($event.target as HTMLImageElement).src = '../public/icons/Link.svg'" />
@error="
($event.target as HTMLImageElement).src =
'../public/icons/Link.svg'
" />
</template>
<img
src="../public/icons/File.svg"
@ -121,8 +128,12 @@
:src="getYoutubeThumbnail(selectedItem.content)"
alt="YouTube Thumbnail" />
</div>
<div class="content" v-else-if="selectedItem?.content_type === ContentType.Link && pageOgImage">
<img :src="pageOgImage" alt="Image" class="image">
<div
class="content"
v-else-if="
selectedItem?.content_type === ContentType.Link && pageOgImage
">
<img :src="pageOgImage" alt="Image" class="image" />
</div>
<OverlayScrollbarsComponent v-else class="content">
<span>{{ selectedItem?.content || "" }}</span>
@ -135,9 +146,7 @@
<div class="info-content" v-if="selectedItem && getInfo">
<div class="info-row" v-for="(row, index) in infoRows" :key="index">
<p class="label">{{ row.label }}</p>
<span
:class="{ 'url-truncate': row.isUrl }"
:data-text="row.value">
<span :class="{ 'url-truncate': row.isUrl }" :data-text="row.value">
{{ row.value }}
</span>
</div>
@ -153,12 +162,19 @@ import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import "overlayscrollbars/overlayscrollbars.css";
import { app, window } from "@tauri-apps/api";
import { platform } from "@tauri-apps/plugin-os";
import { enable, isEnabled } from "@tauri-apps/plugin-autostart";
import { listen } from "@tauri-apps/api/event";
import { useNuxtApp } from "#app";
import { invoke } from "@tauri-apps/api/core";
import { HistoryItem, ContentType } from "~/types/types";
import type { InfoText, InfoImage, InfoFile, InfoLink, InfoColor, InfoCode } from "~/types/types";
import type {
InfoText,
InfoImage,
InfoFile,
InfoLink,
InfoColor,
InfoCode,
} from "~/types/types";
import { Key } from "wrdu-keyboard/key";
interface GroupedHistory {
label: string;
@ -188,8 +204,8 @@ const imageSizes = shallowRef<Record<string, string>>({});
const lastUpdateTime = ref<number>(Date.now());
const imageLoadError = ref<boolean>(false);
const imageLoading = ref<boolean>(false);
const pageTitle = ref<string>('');
const pageOgImage = ref<string>('');
const pageTitle = ref<string>("");
const pageOgImage = ref<string>("");
const keyboard = useKeyboard();
@ -583,41 +599,35 @@ const setupEventListeners = async (): Promise<void> => {
searchInput.value?.blur();
});
keyboard.down("ArrowDown", (event) => {
event.preventDefault();
keyboard.prevent.down([Key.DownArrow], (event) => {
selectNext();
});
keyboard.down("ArrowUp", (event) => {
event.preventDefault();
keyboard.prevent.down([Key.UpArrow], (event) => {
selectPrevious();
});
keyboard.down("Enter", (event) => {
event.preventDefault();
keyboard.prevent.down([Key.Enter], (event) => {
pasteSelectedItem();
});
keyboard.down("Escape", (event) => {
event.preventDefault();
keyboard.prevent.down([Key.Escape], (event) => {
hideApp();
});
keyboard.down("all", (event) => {
const isMacActionCombo =
os.value === "macos" &&
(event.code === "MetaLeft" || event.code === "MetaRight") &&
event.key === "k";
switch (os.value) {
case "macos":
keyboard.prevent.down([Key.LeftMeta, Key.K], (event) => {});
const isOtherOsActionCombo =
os.value !== "macos" &&
(event.code === "ControlLeft" || event.code === "ControlRight") &&
event.key === "k";
keyboard.prevent.down([Key.RightMeta, Key.K], (event) => {});
break;
if (isMacActionCombo || isOtherOsActionCombo) {
event.preventDefault();
}
});
case "linux" || "windows":
keyboard.prevent.down([Key.LeftControl, Key.K], (event) => {});
keyboard.prevent.down([Key.RightControl, Key.K], (event) => {});
break;
}
};
const hideApp = async (): Promise<void> => {
@ -646,7 +656,7 @@ watch(searchQuery, () => {
onMounted(async () => {
try {
os.value = await platform();
os.value = platform();
await loadHistoryChunk();
resultsContainer.value
@ -655,10 +665,6 @@ onMounted(async () => {
?.viewport?.addEventListener("scroll", handleScroll);
await setupEventListeners();
if (!(await isEnabled())) {
await enable();
}
} catch (error) {
console.error("Error during onMounted:", error);
}
@ -686,27 +692,33 @@ const formatFileSize = (bytes: number): string => {
const fetchPageMeta = async (url: string) => {
try {
const [title, ogImage] = await invoke('fetch_page_meta', { url }) as [string, string | null];
const [title, ogImage] = (await invoke("fetch_page_meta", { url })) as [
string,
string | null
];
pageTitle.value = title;
if (ogImage) {
pageOgImage.value = ogImage;
}
} catch (error) {
console.error('Error fetching page meta:', error);
pageTitle.value = 'Error loading title';
console.error("Error fetching page meta:", error);
pageTitle.value = "Error loading title";
}
};
watch(() => selectedItem.value, (newItem) => {
if (newItem?.content_type === ContentType.Link) {
pageTitle.value = 'Loading...';
pageOgImage.value = '';
fetchPageMeta(newItem.content);
} else {
pageTitle.value = '';
pageOgImage.value = '';
watch(
() => selectedItem.value,
(newItem) => {
if (newItem?.content_type === ContentType.Link) {
pageTitle.value = "Loading...";
pageOgImage.value = "";
fetchPageMeta(newItem.content);
} else {
pageTitle.value = "";
pageOgImage.value = "";
}
}
});
);
const getInfo = computed(() => {
if (!selectedItem.value) return null;
@ -716,7 +728,10 @@ const getInfo = computed(() => {
copied: selectedItem.value.timestamp,
};
const infoMap: Record<ContentType, () => InfoText | InfoImage | InfoFile | InfoLink | InfoColor | InfoCode> = {
const infoMap: Record<
ContentType,
() => InfoText | InfoImage | InfoFile | InfoLink | InfoColor | InfoCode
> = {
[ContentType.Text]: () => ({
...baseInfo,
content_type: ContentType.Text,
@ -754,7 +769,8 @@ const getInfo = computed(() => {
const max = Math.max(rNorm, gNorm, bNorm);
const min = Math.min(rNorm, gNorm, bNorm);
let h = 0, s = 0;
let h = 0,
s = 0;
const l = (max + min) / 2;
if (max !== min) {
@ -780,14 +796,16 @@ const getInfo = computed(() => {
content_type: ContentType.Color,
hex: hex,
rgb: `rgb(${r}, ${g}, ${b})`,
hsl: `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`,
hsl: `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(
l * 100
)}%)`,
};
},
[ContentType.Code]: () => ({
...baseInfo,
content_type: ContentType.Code,
language: selectedItem.value!.language ?? "Unknown",
lines: selectedItem.value!.content.split('\n').length,
lines: selectedItem.value!.content.split("\n").length,
}),
};
@ -799,24 +817,37 @@ const infoRows = computed(() => {
const commonRows = [
{ label: "Source", value: getInfo.value.source, isUrl: false },
{ label: "Content Type", value: getInfo.value.content_type.charAt(0).toUpperCase() + getInfo.value.content_type.slice(1), isUrl: false },
{
label: "Content Type",
value:
getInfo.value.content_type.charAt(0).toUpperCase() +
getInfo.value.content_type.slice(1),
isUrl: false,
},
];
const typeSpecificRows: Record<ContentType, Array<{ label: string; value: string | number; isUrl?: boolean }>> = {
const typeSpecificRows: Record<
ContentType,
Array<{ label: string; value: string | number; isUrl?: boolean }>
> = {
[ContentType.Text]: [
{ label: "Characters", value: (getInfo.value as InfoText).characters },
{ label: "Words", value: (getInfo.value as InfoText).words },
],
[ContentType.Image]: [
{ label: "Dimensions", value: (getInfo.value as InfoImage).dimensions },
{ label: "Image size", value: formatFileSize((getInfo.value as InfoImage).size) },
{
label: "Image size",
value: formatFileSize((getInfo.value as InfoImage).size),
},
],
[ContentType.File]: [
{ label: "Path", value: (getInfo.value as InfoFile).path },
],
[ContentType.Link]: [
...((getInfo.value as InfoLink).title && (getInfo.value as InfoLink).title !== 'Loading...'
? [{ label: "Title", value: (getInfo.value as InfoLink).title || '' }]
...((getInfo.value as InfoLink).title &&
(getInfo.value as InfoLink).title !== "Loading..."
? [{ label: "Title", value: (getInfo.value as InfoLink).title || "" }]
: []),
{ label: "URL", value: (getInfo.value as InfoLink).url, isUrl: true },
{ label: "Characters", value: (getInfo.value as InfoLink).characters },
@ -832,8 +863,9 @@ const infoRows = computed(() => {
],
};
const specificRows = typeSpecificRows[getInfo.value.content_type]
.filter(row => row.value !== "");
const specificRows = typeSpecificRows[getInfo.value.content_type].filter(
(row) => row.value !== ""
);
return [
...commonRows,

View file

@ -1,8 +1,10 @@
<template>
<div class="bg">
<div class="back">
<img @click="router.push('/')" src="../public/back_arrow.svg" />
<p>Back</p>
<div class="top-bar">
<NuxtLink to="/" class="back">
<img src="../public/back_arrow.svg" />
<p>Back</p>
</NuxtLink>
</div>
<div class="bottom-bar">
<div class="left">
@ -10,7 +12,10 @@
<p>Qopy</p>
</div>
<div class="right">
<div @click="saveKeybind" class="actions">
<div
@click="saveKeybind"
class="actions"
:class="{ disabled: keybind.length === 0 }">
<p>Save</p>
<div>
<img alt="" src="../public/cmd.svg" v-if="os === 'macos'" />
@ -23,25 +28,61 @@
</div>
</div>
</div>
<div class="keybind-container">
<h2 class="title">Record a new Hotkey</h2>
<div
@blur="onBlur"
@focus="onFocus"
@keydown="onKeyDown"
class="keybind-input"
ref="keybindInput"
tabindex="0">
<span class="key" v-if="keybind.length === 0">Click here</span>
<template v-else>
<span
:key="index"
class="key"
:class="{ modifier: isModifier(key) }"
v-for="(key, index) in keybind">
{{ keyToDisplay(key) }}
</span>
</template>
<div class="settings-container">
<div class="settings">
<div class="names">
<p style="line-height: 14px">Startup</p>
<p style="line-height: 34px">Qopy Hotkey</p>
</div>
<div class="actions">
<div class="launch">
<input
type="checkbox"
id="launch"
v-model="autostart"
@change="toggleAutostart" />
<label for="launch" class="checkmark">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<g>
<rect width="14" height="14" />
<path
id="Path"
d="M0 2.00696L2.25015 4.25L6 0"
fill="none"
stroke-width="1.5"
stroke="#E5DFD5"
stroke-linecap="round"
stroke-linejoin="round"
transform="translate(4 5)" />
</g>
</svg>
</label>
<p for="launch">Launch Qopy at login</p>
</div>
<div
@blur="onBlur"
@focus="onFocus"
class="keybind-input"
ref="keybindInput"
tabindex="0"
:class="{ 'empty-keybind': showEmptyKeybindError }">
<span class="key" v-if="keybind.length === 0">Click here</span>
<template v-else>
<span
:key="index"
class="key"
:class="{ modifier: isModifier(key) }"
v-for="(key, index) in keybind">
{{ keyToLabel(key) }}
</span>
</template>
</div>
</div>
</div>
</div>
</div>
@ -52,62 +93,43 @@ import { invoke } from "@tauri-apps/api/core";
import { onMounted, onUnmounted, reactive, ref } from "vue";
import { platform } from "@tauri-apps/plugin-os";
import { useRouter } from "vue-router";
import { Key } from "wrdu-keyboard/key";
import { KeyValues, KeyLabels } from "../types/keys";
import { disable, enable } from "@tauri-apps/plugin-autostart";
const activeModifiers = reactive<Set<string>>(new Set());
const activeModifiers = reactive<Set<KeyValues>>(new Set());
const isKeybindInputFocused = ref(false);
const keybind = ref<string[]>([]);
const keybind = ref<KeyValues[]>([]);
const keybindInput = ref<HTMLElement | null>(null);
const lastBlurTime = ref(0);
const os = ref("");
const router = useRouter();
const keyboard = useKeyboard();
const keyToDisplayMap: Record<string, string> = {
" ": "Space",
Alt: "Alt",
AltLeft: "Alt L",
AltRight: "Alt R",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
Control: "Ctrl",
ControlLeft: "Ctrl L",
ControlRight: "Ctrl R",
Enter: "↵",
Meta: "Meta",
MetaLeft: "Meta L",
MetaRight: "Meta R",
Shift: "⇧",
ShiftLeft: "⇧ L",
ShiftRight: "⇧ R",
};
const showEmptyKeybindError = ref(false);
const autostart = ref(false);
const { $settings } = useNuxtApp();
const modifierKeySet = new Set([
"Alt",
"AltLeft",
"AltRight",
"Control",
"ControlLeft",
"ControlRight",
"Meta",
"MetaLeft",
"MetaRight",
"Shift",
"ShiftLeft",
"ShiftRight",
KeyValues.AltLeft,
KeyValues.AltRight,
KeyValues.ControlLeft,
KeyValues.ControlRight,
KeyValues.MetaLeft,
KeyValues.MetaRight,
KeyValues.ShiftLeft,
KeyValues.ShiftRight,
]);
const isModifier = (key: string): boolean => {
const isModifier = (key: KeyValues): boolean => {
return modifierKeySet.has(key);
};
const keyToDisplay = (key: string): string => {
return keyToDisplayMap[key] || key;
const keyToLabel = (key: KeyValues): string => {
return KeyLabels[key] || key;
};
const updateKeybind = () => {
const modifiers = Array.from(activeModifiers).sort();
const modifiers = Array.from(activeModifiers);
const nonModifiers = keybind.value.filter((key) => !isModifier(key));
keybind.value = [...modifiers, ...nonModifiers];
};
@ -115,19 +137,20 @@ const updateKeybind = () => {
const onBlur = () => {
isKeybindInputFocused.value = false;
lastBlurTime.value = Date.now();
showEmptyKeybindError.value = false;
};
const onFocus = () => {
isKeybindInputFocused.value = true;
activeModifiers.clear();
keybind.value = [];
showEmptyKeybindError.value = false;
};
const onKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
const key = event.code;
const key = event.code as KeyValues;
if (key === "Escape") {
if (key === KeyValues.Escape) {
if (keybindInput.value) {
keybindInput.value.blur();
}
@ -142,45 +165,79 @@ const onKeyDown = (event: KeyboardEvent) => {
}
updateKeybind();
showEmptyKeybindError.value = false;
};
const saveKeybind = async () => {
console.log("New:", keybind.value);
const oldKeybind = await invoke<string[]>("get_keybind");
console.log("Old:", oldKeybind);
await invoke("save_keybind", { keybind: keybind.value });
if (keybind.value.length > 0) {
await $settings.saveSetting("keybind", JSON.stringify(keybind.value));
router.push("/");
} else {
showEmptyKeybindError.value = true;
}
};
onMounted(() => {
os.value = platform();
const toggleAutostart = async () => {
if (autostart.value === true) {
await enable();
} else {
await disable();
}
await $settings.saveSetting("autostart", autostart.value ? "true" : "false");
};
keyboard.down("all", (event) => {
const isMacSaveCombo =
os.value === "macos" &&
(event.code === "MetaLeft" || event.code === "MetaRight") &&
event.key === "Enter";
os.value = platform();
const isOtherOsSaveCombo =
os.value !== "macos" &&
(event.code === "ControlLeft" || event.code === "ControlRight") &&
event.key === "Enter";
if (
(isMacSaveCombo || isOtherOsSaveCombo) &&
!isKeybindInputFocused.value
) {
event.preventDefault();
saveKeybind();
onMounted(async () => {
keyboard.down([Key.All], (event) => {
if (isKeybindInputFocused.value) {
onKeyDown(event);
}
});
keyboard.down("Escape", (event) => {
const now = Date.now();
if (!isKeybindInputFocused.value && now - lastBlurTime.value > 100) {
event.preventDefault();
keyboard.down([Key.Escape], (event) => {
if (isKeybindInputFocused.value) {
keybindInput.value?.blur();
} else {
router.push("/");
}
});
switch (os.value) {
case "macos":
keyboard.down([Key.LeftMeta, Key.Enter], (event) => {
if (!isKeybindInputFocused.value) {
saveKeybind();
}
});
keyboard.down([Key.RightMeta, Key.Enter], (event) => {
if (!isKeybindInputFocused.value) {
saveKeybind();
}
});
break;
case "linux" || "windows":
keyboard.down([Key.LeftControl, Key.Enter], (event) => {
if (!isKeybindInputFocused.value) {
saveKeybind();
}
});
keyboard.down([Key.RightControl, Key.Enter], (event) => {
if (!isKeybindInputFocused.value) {
saveKeybind();
}
});
break;
}
autostart.value = (await $settings.getSetting("autostart")) === "true";
});
onUnmounted(() => {
keyboard.unregisterAll();
});
</script>

View file

@ -0,0 +1,131 @@
diff --git a/node_modules/wrdu-keyboard/.DS_Store b/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..4b7e9446f3580fab3e4feaba097bcdaf98c5833c
Binary files /dev/null and b/.DS_Store differ
diff --git a/dist/runtime/keyboard.d.ts b/dist/runtime/keyboard.d.ts
index aeae40f3d2bc3efd459cce04c29c21c43884154d..6131bab4895ebb3048a5225f366430d23c5f1f13 100644
--- a/dist/runtime/keyboard.d.ts
+++ b/dist/runtime/keyboard.d.ts
@@ -1,15 +1,16 @@
-import { Key } from './types/keys.js';
-import { type Plugin } from '#app';
+import { Key } from "./types/keys.js";
+import { type Plugin } from "#app";
type Handler = (event: KeyboardEvent) => void;
type Config = {
once?: boolean;
prevent?: boolean;
};
-type PublicConfig = Omit<Config, 'prevent'>;
+type PublicConfig = Omit<Config, "prevent">;
type New = (keys: Key[], handler: Handler, config?: PublicConfig) => void;
export interface Keyboard {
init: () => void;
stop: () => void;
+ unregisterAll: () => void;
down: New;
up: New;
prevent: {
diff --git a/dist/runtime/keyboard.js b/dist/runtime/keyboard.js
index e16f600258cee90d185ffc52777bed95c14bd93e..5ddec447a5dc66ffe063eb9f9dd765c9045bdaf7 100644
--- a/dist/runtime/keyboard.js
+++ b/dist/runtime/keyboard.js
@@ -1,45 +1,54 @@
import { Key } from "./types/keys.js";
import { defineNuxtPlugin } from "#app";
-const getKeyString = (keys) => keys[0] == Key.All ? keys.sort().join("+") : "All";
+const getKeyString = (keys) => keys.includes(Key.All) ? "All" : keys.sort().join("+");
const handlers = {
down: {},
up: {}
};
const pressedKeys = /* @__PURE__ */ new Set();
const onKeydown = (event) => {
- pressedKeys.add(event.code);
+ const key = event.code;
+ pressedKeys.add(key);
const pressedArray = Array.from(pressedKeys);
- const keyString = getKeyString(pressedArray);
- if (handlers.down[keyString]) {
- handlers.down[keyString].forEach((eventHandler) => {
- if (eventHandler.prevent) {
- event.preventDefault();
- }
- eventHandler.handler(event);
- if (eventHandler.once) {
- handlers.down[keyString] = handlers.down[keyString].filter((h) => h !== eventHandler);
- }
- });
+ for (const keyString of [getKeyString(pressedArray), "All"]) {
+ if (handlers.down[keyString]) {
+ handlers.down[keyString].forEach((eventHandler) => {
+ if (eventHandler.prevent) {
+ event.preventDefault();
+ }
+ eventHandler.handler(event);
+ if (eventHandler.once) {
+ handlers.down[keyString] = handlers.down[keyString].filter(
+ (h) => h !== eventHandler
+ );
+ }
+ });
+ }
}
};
const onKeyup = (event) => {
- pressedKeys.delete(event.code);
+ const key = event.code;
+ pressedKeys.delete(key);
const releasedArray = Array.from(pressedKeys);
- const keyString = getKeyString(releasedArray);
- if (handlers.up[keyString]) {
- handlers.up[keyString].forEach((eventHandler) => {
- if (eventHandler.prevent) {
- event.preventDefault();
- }
- eventHandler.handler(event);
- if (eventHandler.once) {
- handlers.up[keyString] = handlers.up[keyString].filter((h) => h !== eventHandler);
- }
- });
+ for (const keyString of [getKeyString(releasedArray), "All"]) {
+ if (handlers.up[keyString]) {
+ handlers.up[keyString].forEach((eventHandler) => {
+ if (eventHandler.prevent) {
+ event.preventDefault();
+ }
+ eventHandler.handler(event);
+ if (eventHandler.once) {
+ handlers.up[keyString] = handlers.up[keyString].filter(
+ (h) => h !== eventHandler
+ );
+ }
+ });
+ }
}
};
const init = () => {
stop();
+ pressedKeys.clear();
window.addEventListener("keydown", onKeydown);
window.addEventListener("keyup", onKeyup);
};
@@ -47,6 +56,10 @@ const stop = () => {
window.removeEventListener("keydown", onKeydown);
window.removeEventListener("keyup", onKeyup);
};
+const unregisterAll = () => {
+ handlers.down = {};
+ handlers.up = {};
+};
const down = (keys, handler, config = {}) => {
if (keys.includes(Key.All)) {
keys = [Key.All];
@@ -84,6 +97,7 @@ const keyboard = defineNuxtPlugin((nuxtApp) => {
keyboard: {
init,
stop,
+ unregisterAll,
down: (keys, handler, config = {}) => down(keys, handler, config),
up: (keys, handler, config = {}) => up(keys, handler, config),
prevent: {

View file

@ -12,14 +12,6 @@ export default defineNuxtPlugin(() => {
async saveSetting(key: string, value: string): Promise<void> {
await invoke<void>("save_setting", { key, value });
},
async getKeybind(): Promise<string[]> {
return await invoke<string[]>("get_keybind");
},
async saveKeybind(keybind: string[]): Promise<void> {
await invoke<void>("save_keybind", { keybind });
},
},
},
};

2
src-tauri/Cargo.lock generated
View file

@ -4054,7 +4054,7 @@ dependencies = [
[[package]]
name = "qopy"
version = "0.3.3"
version = "0.3.4"
dependencies = [
"active-win-pos-rs",
"applications",

View file

@ -1,6 +1,6 @@
[package]
name = "qopy"
version = "0.3.3"
version = "0.3.4"
description = "Qopy"
authors = ["pandadev"]
edition = "2021"

View file

@ -1,14 +1,14 @@
use tauri_plugin_aptabase::EventTracker;
use base64::{engine::general_purpose::STANDARD, Engine};
use base64::{ engine::general_purpose::STANDARD, Engine };
// use hyperpolyglot;
use lazy_static::lazy_static;
use rdev::{simulate, EventType, Key};
use rdev::{ simulate, EventType, Key };
use regex::Regex;
use sqlx::SqlitePool;
use std::fs;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{thread, time::Duration};
use tauri::{AppHandle, Emitter, Listener, Manager};
use std::sync::atomic::{ AtomicBool, Ordering };
use std::{ thread, time::Duration };
use tauri::{ AppHandle, Emitter, Listener, Manager };
use tauri_plugin_clipboard::Clipboard;
use tokio::runtime::Runtime as TokioRuntime;
use url::Url;
@ -17,7 +17,7 @@ use uuid::Uuid;
use crate::db;
use crate::utils::commands::get_app_info;
use crate::utils::favicon::fetch_favicon_as_base64;
use crate::utils::types::{ContentType, HistoryItem};
use crate::utils::types::{ ContentType, HistoryItem };
lazy_static! {
static ref IS_PROGRAMMATIC_PASTE: AtomicBool = AtomicBool::new(false);
@ -27,16 +27,14 @@ lazy_static! {
pub async fn write_and_paste(
app_handle: AppHandle,
content: String,
content_type: String,
content_type: String
) -> Result<(), String> {
let clipboard = app_handle.state::<Clipboard>();
match content_type.as_str() {
"text" => clipboard.write_text(content).map_err(|e| e.to_string())?,
"image" => {
clipboard
.write_image_base64(content)
.map_err(|e| e.to_string())?;
clipboard.write_image_base64(content).map_err(|e| e.to_string())?;
}
"files" => {
clipboard
@ -44,11 +42,13 @@ pub async fn write_and_paste(
content
.split(", ")
.map(|file| file.to_string())
.collect::<Vec<String>>(),
.collect::<Vec<String>>()
)
.map_err(|e| e.to_string())?;
}
_ => return Err("Unsupported content type".to_string()),
_ => {
return Err("Unsupported content type".to_string());
}
}
IS_PROGRAMMATIC_PASTE.store(true, Ordering::SeqCst);
@ -65,7 +65,7 @@ pub async fn write_and_paste(
EventType::KeyPress(modifier_key),
EventType::KeyPress(Key::KeyV),
EventType::KeyRelease(Key::KeyV),
EventType::KeyRelease(modifier_key),
EventType::KeyRelease(modifier_key)
];
for event in events {
@ -81,9 +81,12 @@ pub async fn write_and_paste(
IS_PROGRAMMATIC_PASTE.store(false, Ordering::SeqCst);
});
let _ = app_handle.track_event("clipboard_paste", Some(serde_json::json!({
let _ = app_handle.track_event(
"clipboard_paste",
Some(serde_json::json!({
"content_type": content_type
})));
}))
);
Ok(())
}
@ -92,79 +95,92 @@ pub fn setup(app: &AppHandle) {
let app_handle = app.clone();
let runtime = TokioRuntime::new().expect("Failed to create Tokio runtime");
app_handle.clone().listen(
"plugin:clipboard://clipboard-monitor/update",
move |_event| {
let app_handle = app_handle.clone();
runtime.block_on(async move {
if IS_PROGRAMMATIC_PASTE.load(Ordering::SeqCst) {
return;
}
app_handle.clone().listen("plugin:clipboard://clipboard-monitor/update", move |_event| {
let app_handle = app_handle.clone();
runtime.block_on(async move {
if IS_PROGRAMMATIC_PASTE.load(Ordering::SeqCst) {
return;
}
let clipboard = app_handle.state::<Clipboard>();
let available_types = clipboard.available_types().unwrap();
let clipboard = app_handle.state::<Clipboard>();
let available_types = clipboard.available_types().unwrap();
let (app_name, app_icon) = get_app_info();
let (app_name, app_icon) = get_app_info();
match get_pool(&app_handle).await {
Ok(pool) => {
if available_types.image {
println!("Handling image change");
if let Ok(image_data) = clipboard.read_image_base64() {
let file_path = save_image_to_file(&app_handle, &image_data)
.await
.map_err(|e| e.to_string())
.unwrap_or_else(|e| e);
match get_pool(&app_handle).await {
Ok(pool) => {
if available_types.image {
println!("Handling image change");
if let Ok(image_data) = clipboard.read_image_base64() {
let file_path = save_image_to_file(&app_handle, &image_data).await
.map_err(|e| e.to_string())
.unwrap_or_else(|e| e);
let _ = db::history::add_history_item(
app_handle.clone(),
pool,
HistoryItem::new(
app_name,
ContentType::Image,
file_path,
None,
app_icon,
None
)
).await;
}
} else if available_types.files {
println!("Handling files change");
if let Ok(files) = clipboard.read_files() {
for file in files {
let _ = db::history::add_history_item(
app_handle.clone(),
pool,
HistoryItem::new(app_name, ContentType::Image, file_path, None, app_icon, None)
pool.clone(),
HistoryItem::new(
app_name.clone(),
ContentType::File,
file,
None,
app_icon.clone(),
None
)
).await;
}
} else if available_types.files {
println!("Handling files change");
if let Ok(files) = clipboard.read_files() {
for file in files {
}
} else if available_types.text {
println!("Handling text change");
if let Ok(text) = clipboard.read_text() {
let text = text.to_string();
let url_regex = Regex::new(
r"^https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)$"
).unwrap();
if url_regex.is_match(&text) {
if let Ok(url) = Url::parse(&text) {
let favicon = match fetch_favicon_as_base64(url).await {
Ok(Some(f)) => Some(f),
_ => None,
};
let _ = db::history::add_history_item(
app_handle.clone(),
pool.clone(),
pool,
HistoryItem::new(
app_name.clone(),
ContentType::File,
file,
None,
app_icon.clone(),
app_name,
ContentType::Link,
text,
favicon,
app_icon,
None
),
)
).await;
}
}
} else if available_types.text {
println!("Handling text change");
if let Ok(text) = clipboard.read_text() {
let text = text.to_string();
let url_regex = Regex::new(r"^https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)$").unwrap();
} else {
if text.is_empty() {
return;
}
if url_regex.is_match(&text) {
if let Ok(url) = Url::parse(&text) {
let favicon = match fetch_favicon_as_base64(url).await {
Ok(Some(f)) => Some(f),
_ => None,
};
let _ = db::history::add_history_item(
app_handle.clone(),
pool,
HistoryItem::new(app_name, ContentType::Link, text, favicon, app_icon, None)
).await;
}
} else {
if text.is_empty() {
return;
}
// Temporarily disabled code detection
/*if let Some(detection) = hyperpolyglot::detect_from_text(&text) {
// Temporarily disabled code detection
/*if let Some(detection) = hyperpolyglot::detect_from_text(&text) {
let language = match detection {
hyperpolyglot::Detection::Heuristics(lang) => lang.to_string(),
_ => detection.language().to_string(),
@ -175,43 +191,61 @@ pub fn setup(app: &AppHandle) {
HistoryItem::new(app_name, ContentType::Code, text, None, app_icon, Some(language))
).await;
} else*/ if crate::utils::commands::detect_color(&text) {
let _ = db::history::add_history_item(
app_handle.clone(),
pool,
HistoryItem::new(app_name, ContentType::Color, text, None, app_icon, None)
).await;
} else {
let _ = db::history::add_history_item(
app_handle.clone(),
pool,
HistoryItem::new(app_name, ContentType::Text, text.clone(), None, app_icon, None)
).await;
}
let _ = db::history::add_history_item(
app_handle.clone(),
pool,
HistoryItem::new(
app_name,
ContentType::Color,
text,
None,
app_icon,
None
)
).await;
} else {
let _ = db::history::add_history_item(
app_handle.clone(),
pool,
HistoryItem::new(
app_name,
ContentType::Text,
text.clone(),
None,
app_icon,
None
)
).await;
}
}
} else {
println!("Unknown clipboard content type");
}
}
Err(e) => {
println!("Failed to get database pool: {}", e);
} else {
println!("Unknown clipboard content type");
}
}
Err(e) => {
println!("Failed to get database pool: {}", e);
}
}
let _ = app_handle.emit("clipboard-content-updated", ());
let _ = app_handle.track_event("clipboard_copied", Some(serde_json::json!({
let _ = app_handle.emit("clipboard-content-updated", ());
let _ = app_handle.track_event(
"clipboard_copied",
Some(
serde_json::json!({
"content_type": if available_types.image { "image" }
else if available_types.files { "files" }
else if available_types.text { "text" }
else { "unknown" }
})));
});
},
);
})
)
);
});
});
}
async fn get_pool(
app_handle: &AppHandle,
app_handle: &AppHandle
) -> Result<tauri::State<'_, SqlitePool>, Box<dyn std::error::Error + Send + Sync>> {
Ok(app_handle.state::<SqlitePool>())
}
@ -219,9 +253,7 @@ async fn get_pool(
#[tauri::command]
pub fn start_monitor(app_handle: AppHandle) -> Result<(), String> {
let clipboard = app_handle.state::<Clipboard>();
clipboard
.start_monitor(app_handle.clone())
.map_err(|e| e.to_string())?;
clipboard.start_monitor(app_handle.clone()).map_err(|e| e.to_string())?;
app_handle
.emit("plugin:clipboard://clipboard-monitor/status", true)
.map_err(|e| e.to_string())?;
@ -230,7 +262,7 @@ pub fn start_monitor(app_handle: AppHandle) -> Result<(), String> {
async fn save_image_to_file(
app_handle: &AppHandle,
base64_data: &str,
base64_data: &str
) -> Result<String, Box<dyn std::error::Error>> {
let app_data_dir = app_handle.path().app_data_dir().unwrap();
let images_dir = app_data_dir.join("images");

View file

@ -1,48 +1,60 @@
use tauri_plugin_aptabase::EventTracker;
use crate::utils::commands::center_window_on_current_monitor;
use crate::utils::keys::KeyCode;
use global_hotkey::{
hotkey::{Code, HotKey, Modifiers},
GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState,
hotkey::{ Code, HotKey, Modifiers },
GlobalHotKeyEvent,
GlobalHotKeyManager,
HotKeyState,
};
use std::cell::RefCell;
use lazy_static::lazy_static;
use std::str::FromStr;
use tauri::{AppHandle, Listener, Manager};
use std::sync::Mutex;
use tauri::{ AppHandle, Listener, Manager };
use tauri_plugin_aptabase::EventTracker;
thread_local! {
static HOTKEY_MANAGER: RefCell<Option<GlobalHotKeyManager>> = RefCell::new(None);
lazy_static! {
static ref HOTKEY_MANAGER: Mutex<Option<GlobalHotKeyManager>> = Mutex::new(None);
static ref REGISTERED_HOTKEY: Mutex<Option<HotKey>> = Mutex::new(None);
}
pub fn setup(app_handle: tauri::AppHandle) {
let app_handle_clone = app_handle.clone();
let manager = GlobalHotKeyManager::new().expect("Failed to initialize hotkey manager");
HOTKEY_MANAGER.with(|m| *m.borrow_mut() = Some(manager));
let manager = match GlobalHotKeyManager::new() {
Ok(manager) => manager,
Err(err) => {
eprintln!("Failed to initialize hotkey manager: {:?}", err);
return;
}
};
{
let mut manager_guard = HOTKEY_MANAGER.lock().unwrap();
*manager_guard = Some(manager);
}
let rt = app_handle.state::<tokio::runtime::Runtime>();
let initial_keybind = rt
.block_on(crate::db::settings::get_keybind(app_handle_clone.clone()))
.expect("Failed to get initial keybind");
let initial_shortcut = initial_keybind.join("+");
let initial_shortcut_for_update = initial_shortcut.clone();
let initial_shortcut_for_save = initial_shortcut.clone();
if let Err(e) = register_shortcut(&initial_shortcut) {
if let Err(e) = register_shortcut(&initial_keybind) {
eprintln!("Error registering initial shortcut: {:?}", e);
}
app_handle.listen("update-shortcut", move |event| {
let payload_str = event.payload().to_string();
let payload_str = event.payload();
if let Ok(old_hotkey) = parse_hotkey(&initial_shortcut_for_update) {
HOTKEY_MANAGER.with(|manager| {
if let Some(manager) = manager.borrow().as_ref() {
let _ = manager.unregister(old_hotkey);
}
});
if let Some(old_hotkey) = REGISTERED_HOTKEY.lock().unwrap().take() {
let manager_guard = HOTKEY_MANAGER.lock().unwrap();
if let Some(manager) = manager_guard.as_ref() {
let _ = manager.unregister(old_hotkey);
}
}
if let Err(e) = register_shortcut(&payload_str) {
let payload: Vec<String> = serde_json::from_str(payload_str).unwrap_or_default();
if let Err(e) = register_shortcut(&payload) {
eprintln!("Error re-registering shortcut: {:?}", e);
}
});
@ -50,15 +62,15 @@ pub fn setup(app_handle: tauri::AppHandle) {
app_handle.listen("save_keybind", move |event| {
let payload_str = event.payload().to_string();
if let Ok(old_hotkey) = parse_hotkey(&initial_shortcut_for_save) {
HOTKEY_MANAGER.with(|manager| {
if let Some(manager) = manager.borrow().as_ref() {
let _ = manager.unregister(old_hotkey);
}
});
if let Some(old_hotkey) = REGISTERED_HOTKEY.lock().unwrap().take() {
let manager_guard = HOTKEY_MANAGER.lock().unwrap();
if let Some(manager) = manager_guard.as_ref() {
let _ = manager.unregister(old_hotkey);
}
}
if let Err(e) = register_shortcut(&payload_str) {
let payload: Vec<String> = serde_json::from_str(&payload_str).unwrap_or_default();
if let Err(e) = register_shortcut(&payload) {
eprintln!("Error registering saved shortcut: {:?}", e);
}
});
@ -81,48 +93,44 @@ pub fn setup(app_handle: tauri::AppHandle) {
});
}
fn register_shortcut(shortcut: &str) -> Result<(), Box<dyn std::error::Error>> {
fn register_shortcut(shortcut: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let hotkey = parse_hotkey(shortcut)?;
HOTKEY_MANAGER.with(|manager| {
if let Some(manager) = manager.borrow().as_ref() {
manager.register(hotkey)?;
}
let manager_guard = HOTKEY_MANAGER.lock().unwrap();
if let Some(manager) = manager_guard.as_ref() {
manager.register(hotkey.clone())?;
*REGISTERED_HOTKEY.lock().unwrap() = Some(hotkey);
Ok(())
})
} else {
Err("Hotkey manager not initialized".into())
}
}
fn parse_hotkey(shortcut: &str) -> Result<HotKey, Box<dyn std::error::Error>> {
fn parse_hotkey(shortcut: &[String]) -> Result<HotKey, Box<dyn std::error::Error>> {
let mut modifiers = Modifiers::empty();
let mut code = None;
let shortcut = shortcut.replace("\"", "");
for part in shortcut.split('+') {
let part = part.trim().to_lowercase();
for part in shortcut {
match part.as_str() {
"ctrl" | "control" | "controlleft" => modifiers |= Modifiers::CONTROL,
"alt" | "altleft" | "optionleft" => modifiers |= Modifiers::ALT,
"shift" | "shiftleft" => modifiers |= Modifiers::SHIFT,
"super" | "meta" | "cmd" | "metaleft" => modifiers |= Modifiers::META,
"ControlLeft" => {
modifiers |= Modifiers::CONTROL;
}
"AltLeft" => {
modifiers |= Modifiers::ALT;
}
"ShiftLeft" => {
modifiers |= Modifiers::SHIFT;
}
"MetaLeft" => {
modifiers |= Modifiers::META;
}
key => {
let key_code = if key.starts_with("key") {
"Key".to_string() + &key[3..].to_uppercase()
} else if key.len() == 1 && key.chars().next().unwrap().is_alphabetic() {
"Key".to_string() + &key.to_uppercase()
} else {
key.to_string()
};
code = Some(
Code::from_str(&key_code)
.map_err(|_| format!("Invalid key code: {}", key_code))?,
);
code = Some(Code::from(KeyCode::from_str(key)?));
}
}
}
let key_code =
code.ok_or_else(|| format!("No valid key code found in shortcut: {}", shortcut))?;
let key_code = code.ok_or_else(|| "No valid key code found".to_string())?;
Ok(HotKey::new(Some(modifiers), key_code))
}
@ -144,7 +152,12 @@ fn handle_hotkey_event(app_handle: &AppHandle) {
center_window_on_current_monitor(&window);
}
let _ = app_handle.track_event("hotkey_triggered", Some(serde_json::json!({
"action": if window.is_visible().unwrap() { "hide" } else { "show" }
})));
let _ = app_handle.track_event(
"hotkey_triggered",
Some(
serde_json::json!({
"action": if window.is_visible().unwrap() { "hide" } else { "show" }
})
)
);
}

View file

@ -1,16 +1,15 @@
use tauri::{
menu::{MenuBuilder, MenuItemBuilder},
tray::TrayIconBuilder,
Emitter, Manager,
};
use tauri::{ menu::{ MenuBuilder, MenuItemBuilder }, tray::TrayIconBuilder, Emitter, Manager };
use tauri_plugin_aptabase::EventTracker;
pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let window = app.get_webview_window("main").unwrap();
let is_visible = window.is_visible().unwrap();
let _ = app.track_event("tray_toggle", Some(serde_json::json!({
let _ = app.track_event(
"tray_toggle",
Some(serde_json::json!({
"action": if is_visible { "hide" } else { "show" }
})));
}))
);
let icon_bytes = include_bytes!("../../icons/Square71x71Logo.png");
let icon = tauri::image::Image::from_bytes(icon_bytes).unwrap();
@ -18,45 +17,42 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let _tray = TrayIconBuilder::new()
.menu(
&MenuBuilder::new(app)
.items(&[&MenuItemBuilder::with_id("app_name", "Qopy")
.enabled(false)
.build(app)?])
.items(&[&MenuItemBuilder::with_id("app_name", "Qopy").enabled(false).build(app)?])
.items(&[&MenuItemBuilder::with_id("show", "Show/Hide").build(app)?])
.items(&[&MenuItemBuilder::with_id("keybind", "Change keybind").build(app)?])
.items(&[&MenuItemBuilder::with_id("check_updates", "Check for updates").build(app)?])
.items(&[&MenuItemBuilder::with_id("settings", "Settings").build(app)?])
.items(&[&MenuItemBuilder::with_id("quit", "Quit").build(app)?])
.build()?,
.build()?
)
.on_menu_event(move |_app, event| match event.id().as_ref() {
"quit" => {
let _ = _app.track_event("app_quit", None);
std::process::exit(0);
}
"show" => {
let _ = _app.track_event("tray_toggle", Some(serde_json::json!({
"action": if is_visible { "hide" } else { "show" }
})));
let is_visible = window.is_visible().unwrap();
if is_visible {
window.hide().unwrap();
} else {
window.show().unwrap();
window.set_focus().unwrap();
.on_menu_event(move |_app, event| {
match event.id().as_ref() {
"quit" => {
let _ = _app.track_event("app_quit", None);
std::process::exit(0);
}
window.emit("main_route", ()).unwrap();
"show" => {
let _ = _app.track_event(
"tray_toggle",
Some(
serde_json::json!({
"action": if is_visible { "hide" } else { "show" }
})
)
);
let is_visible = window.is_visible().unwrap();
if is_visible {
window.hide().unwrap();
} else {
window.show().unwrap();
window.set_focus().unwrap();
}
window.emit("main_route", ()).unwrap();
}
"settings" => {
let _ = _app.track_event("tray_settings", None);
window.emit("settings", ()).unwrap();
}
_ => (),
}
"keybind" => {
let _ = _app.track_event("tray_keybind_change", None);
window.emit("change_keybind", ()).unwrap();
}
"check_updates" => {
let _ = _app.track_event("tray_check_updates", None);
let app_handle = _app.app_handle().clone();
tauri::async_runtime::spawn(async move {
crate::api::updater::check_for_updates(app_handle, true).await;
});
}
_ => (),
})
.icon(icon)
.build(app)?;

View file

@ -1,6 +1,5 @@
use tauri::Manager;
use tauri::{async_runtime, AppHandle};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use tauri::{ async_runtime, AppHandle };
use tauri_plugin_dialog::{ DialogExt, MessageDialogButtons, MessageDialogKind };
use tauri_plugin_updater::UpdaterExt;
pub async fn check_for_updates(app: AppHandle, prompted: bool) {
@ -26,18 +25,35 @@ pub async fn check_for_updates(app: AppHandle, prompted: bool) {
app.dialog()
.message(msg)
.title("Qopy Update Available")
.buttons(MessageDialogButtons::OkCancelCustom(String::from("Install"), String::from("Cancel")))
.buttons(
MessageDialogButtons::OkCancelCustom(
String::from("Install"),
String::from("Cancel")
)
)
.show(move |response| {
if !response {
return;
}
async_runtime::spawn(async move {
match update.download_and_install(|_, _| {}, || {}).await {
match
update.download_and_install(
|_, _| {},
|| {}
).await
{
Ok(_) => {
app.dialog()
.message("Update installed successfully. The application needs to restart to apply the changes.")
.title("Qopy Needs to Restart")
.buttons(MessageDialogButtons::OkCancelCustom(String::from("Restart"), String::from("Cancel")))
.message(
"Update installed successfully. The application needs to restart to apply the changes."
)
.title("Qopy Update Installed")
.buttons(
MessageDialogButtons::OkCancelCustom(
String::from("Restart"),
String::from("Cancel")
)
)
.show(move |response| {
if response {
app.restart();
@ -47,7 +63,9 @@ pub async fn check_for_updates(app: AppHandle, prompted: bool) {
Err(e) => {
println!("Error installing new update: {:?}", e);
app.dialog()
.message("Failed to install new update. The new update can be downloaded from Github")
.message(
"Failed to install new update. The new update can be downloaded from Github"
)
.kind(MessageDialogKind::Error)
.show(|_| {});
}

View file

@ -1,5 +1,5 @@
use include_dir::{include_dir, Dir};
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use include_dir::{ include_dir, Dir };
use sqlx::sqlite::{ SqlitePool, SqlitePoolOptions };
use std::fs;
use tauri::Manager;
use tokio::runtime::Runtime as TokioRuntime;
@ -25,8 +25,7 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let pool = rt.block_on(async {
SqlitePoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await
.connect(&db_url).await
.expect("Failed to create pool")
});
@ -49,29 +48,27 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
}
async fn apply_migrations(pool: &SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS schema_version (
sqlx
::query(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);",
)
.execute(pool)
.await?;
);"
)
.execute(pool).await?;
let current_version: Option<i64> =
sqlx::query_scalar("SELECT MAX(version) FROM schema_version")
.fetch_one(pool)
.await?;
let current_version: Option<i64> = sqlx
::query_scalar("SELECT MAX(version) FROM schema_version")
.fetch_one(pool).await?;
let current_version = current_version.unwrap_or(0);
let mut migration_files: Vec<(i64, &str)> = MIGRATIONS_DIR
.files()
let mut migration_files: Vec<(i64, &str)> = MIGRATIONS_DIR.files()
.filter_map(|file| {
let file_name = file.path().file_name()?.to_str()?;
if file_name.ends_with(".sql") && file_name.starts_with("migration") {
if file_name.ends_with(".sql") && file_name.starts_with("v") {
let version: i64 = file_name
.trim_start_matches("migration")
.trim_start_matches("v")
.trim_end_matches(".sql")
.parse()
.ok()?;
@ -93,16 +90,16 @@ async fn apply_migrations(pool: &SqlitePool) -> Result<(), Box<dyn std::error::E
.collect();
for statement in statements {
sqlx::query(statement)
.execute(pool)
.await
sqlx
::query(statement)
.execute(pool).await
.map_err(|e| format!("Failed to execute migration {}: {}", version, e))?;
}
sqlx::query("INSERT INTO schema_version (version) VALUES (?)")
sqlx
::query("INSERT INTO schema_version (version) VALUES (?)")
.bind(version)
.execute(pool)
.await?;
.execute(pool).await?;
}
}

View file

@ -1,39 +1,35 @@
use crate::utils::types::{ContentType, HistoryItem};
use base64::{engine::general_purpose::STANDARD, Engine};
use crate::utils::types::{ ContentType, HistoryItem };
use base64::{ engine::general_purpose::STANDARD, Engine };
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};
use sqlx::{Row, SqlitePool};
use rand::{ thread_rng, Rng };
use sqlx::{ Row, SqlitePool };
use std::fs;
use tauri_plugin_aptabase::EventTracker;
pub async fn initialize_history(pool: &SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
let id: String = thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
let id: String = thread_rng().sample_iter(&Alphanumeric).take(16).map(char::from).collect();
sqlx::query(
"INSERT INTO history (id, source, content_type, content, timestamp) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)"
)
.bind(id)
.bind("System")
.bind("text")
.bind("Welcome to your clipboard history!")
.execute(pool)
.await?;
sqlx
::query(
"INSERT INTO history (id, source, content_type, content, timestamp) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)"
)
.bind(id)
.bind("System")
.bind("text")
.bind("Welcome to your clipboard history!")
.execute(pool).await?;
Ok(())
}
#[tauri::command]
pub async fn get_history(pool: tauri::State<'_, SqlitePool>) -> Result<Vec<HistoryItem>, String> {
let rows = sqlx::query(
"SELECT id, source, source_icon, content_type, content, favicon, timestamp, language FROM history ORDER BY timestamp DESC",
)
.fetch_all(&*pool)
.await
.map_err(|e| e.to_string())?;
let rows = sqlx
::query(
"SELECT id, source, source_icon, content_type, content, favicon, timestamp, language FROM history ORDER BY timestamp DESC"
)
.fetch_all(&*pool).await
.map_err(|e| e.to_string())?;
let items = rows
.iter()
@ -56,50 +52,53 @@ pub async fn get_history(pool: tauri::State<'_, SqlitePool>) -> Result<Vec<Histo
pub async fn add_history_item(
app_handle: tauri::AppHandle,
pool: tauri::State<'_, SqlitePool>,
item: HistoryItem,
item: HistoryItem
) -> Result<(), String> {
let (id, source, source_icon, content_type, content, favicon, timestamp, language) =
item.to_row();
let existing = sqlx::query("SELECT id FROM history WHERE content = ? AND content_type = ?")
let existing = sqlx
::query("SELECT id FROM history WHERE content = ? AND content_type = ?")
.bind(&content)
.bind(&content_type)
.fetch_optional(&*pool)
.await
.fetch_optional(&*pool).await
.map_err(|e| e.to_string())?;
match existing {
Some(_) => {
sqlx::query(
"UPDATE history SET timestamp = strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now') WHERE content = ? AND content_type = ?"
)
.bind(&content)
.bind(&content_type)
.execute(&*pool)
.await
.map_err(|e| e.to_string())?;
sqlx
::query(
"UPDATE history SET timestamp = strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now') WHERE content = ? AND content_type = ?"
)
.bind(&content)
.bind(&content_type)
.execute(&*pool).await
.map_err(|e| e.to_string())?;
}
None => {
sqlx::query(
"INSERT INTO history (id, source, source_icon, content_type, content, favicon, timestamp, language) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(id)
.bind(source)
.bind(source_icon)
.bind(content_type)
.bind(content)
.bind(favicon)
.bind(timestamp)
.bind(language)
.execute(&*pool)
.await
.map_err(|e| e.to_string())?;
sqlx
::query(
"INSERT INTO history (id, source, source_icon, content_type, content, favicon, timestamp, language) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(id)
.bind(source)
.bind(source_icon)
.bind(content_type)
.bind(content)
.bind(favicon)
.bind(timestamp)
.bind(language)
.execute(&*pool).await
.map_err(|e| e.to_string())?;
}
}
let _ = app_handle.track_event("history_item_added", Some(serde_json::json!({
let _ = app_handle.track_event(
"history_item_added",
Some(serde_json::json!({
"content_type": item.content_type.to_string()
})));
}))
);
Ok(())
}
@ -107,16 +106,16 @@ pub async fn add_history_item(
#[tauri::command]
pub async fn search_history(
pool: tauri::State<'_, SqlitePool>,
query: String,
query: String
) -> Result<Vec<HistoryItem>, String> {
let query = format!("%{}%", query);
let rows = sqlx::query(
"SELECT id, source, source_icon, content_type, content, favicon, timestamp, language FROM history WHERE content LIKE ? ORDER BY timestamp DESC"
)
.bind(query)
.fetch_all(&*pool)
.await
.map_err(|e| e.to_string())?;
let rows = sqlx
::query(
"SELECT id, source, source_icon, content_type, content, favicon, timestamp, language FROM history WHERE content LIKE ? ORDER BY timestamp DESC"
)
.bind(query)
.fetch_all(&*pool).await
.map_err(|e| e.to_string())?;
let items = rows
.iter()
@ -139,16 +138,16 @@ pub async fn search_history(
pub async fn load_history_chunk(
pool: tauri::State<'_, SqlitePool>,
offset: i64,
limit: i64,
limit: i64
) -> Result<Vec<HistoryItem>, String> {
let rows = sqlx::query(
"SELECT id, source, source_icon, content_type, content, favicon, timestamp, language FROM history ORDER BY timestamp DESC LIMIT ? OFFSET ?"
)
.bind(limit)
.bind(offset)
.fetch_all(&*pool)
.await
.map_err(|e| e.to_string())?;
let rows = sqlx
::query(
"SELECT id, source, source_icon, content_type, content, favicon, timestamp, language FROM history ORDER BY timestamp DESC LIMIT ? OFFSET ?"
)
.bind(limit)
.bind(offset)
.fetch_all(&*pool).await
.map_err(|e| e.to_string())?;
let items = rows
.iter()
@ -171,12 +170,12 @@ pub async fn load_history_chunk(
pub async fn delete_history_item(
app_handle: tauri::AppHandle,
pool: tauri::State<'_, SqlitePool>,
id: String,
id: String
) -> Result<(), String> {
sqlx::query("DELETE FROM history WHERE id = ?")
sqlx
::query("DELETE FROM history WHERE id = ?")
.bind(id)
.execute(&*pool)
.await
.execute(&*pool).await
.map_err(|e| e.to_string())?;
let _ = app_handle.track_event("history_item_deleted", None);
@ -189,9 +188,9 @@ pub async fn clear_history(
app_handle: tauri::AppHandle,
pool: tauri::State<'_, SqlitePool>
) -> Result<(), String> {
sqlx::query("DELETE FROM history")
.execute(&*pool)
.await
sqlx
::query("DELETE FROM history")
.execute(&*pool).await
.map_err(|e| e.to_string())?;
let _ = app_handle.track_event("history_cleared", None);

View file

@ -0,0 +1 @@
INSERT INTO settings (key, value) VALUES ('autostart', 'true');

View file

@ -1,8 +1,8 @@
use serde::{Deserialize, Serialize};
use serde::{ Deserialize, Serialize };
use serde_json;
use sqlx::Row;
use sqlx::SqlitePool;
use tauri::{Emitter, Manager};
use tauri::{ Emitter, Manager };
use tauri_plugin_aptabase::EventTracker;
#[derive(Deserialize, Serialize)]
@ -16,10 +16,10 @@ pub async fn initialize_settings(pool: &SqlitePool) -> Result<(), Box<dyn std::e
};
let json = serde_json::to_string(&default_keybind)?;
sqlx::query("INSERT INTO settings (key, value) VALUES ('keybind', ?)")
sqlx
::query("INSERT INTO settings (key, value) VALUES ('keybind', ?)")
.bind(json)
.execute(pool)
.await?;
.execute(pool).await?;
Ok(())
}
@ -28,26 +28,24 @@ pub async fn initialize_settings(pool: &SqlitePool) -> Result<(), Box<dyn std::e
pub async fn save_keybind(
app_handle: tauri::AppHandle,
pool: tauri::State<'_, SqlitePool>,
keybind: Vec<String>,
keybind: Vec<String>
) -> Result<(), String> {
let keybind_str = keybind.join("+");
let keybind_clone = keybind_str.clone();
app_handle
.emit("update-shortcut", &keybind_str)
.map_err(|e| e.to_string())?;
app_handle.emit("update-shortcut", &keybind).map_err(|e| e.to_string())?;
let json = serde_json::to_string(&keybind).map_err(|e| e.to_string())?;
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES ('keybind', ?)")
sqlx
::query("INSERT OR REPLACE INTO settings (key, value) VALUES ('keybind', ?)")
.bind(json)
.execute(&*pool)
.await
.execute(&*pool).await
.map_err(|e| e.to_string())?;
let _ = app_handle.track_event("keybind_saved", Some(serde_json::json!({
"keybind": keybind_clone
})));
let _ = app_handle.track_event(
"keybind_saved",
Some(serde_json::json!({
"keybind": keybind
}))
);
Ok(())
}
@ -55,12 +53,12 @@ pub async fn save_keybind(
#[tauri::command]
pub async fn get_setting(
pool: tauri::State<'_, SqlitePool>,
key: String,
key: String
) -> Result<String, String> {
let row = sqlx::query("SELECT value FROM settings WHERE key = ?")
let row = sqlx
::query("SELECT value FROM settings WHERE key = ?")
.bind(key)
.fetch_optional(&*pool)
.await
.fetch_optional(&*pool).await
.map_err(|e| e.to_string())?;
Ok(row.map(|r| r.get("value")).unwrap_or_default())
@ -71,18 +69,21 @@ pub async fn save_setting(
app_handle: tauri::AppHandle,
pool: tauri::State<'_, SqlitePool>,
key: String,
value: String,
value: String
) -> Result<(), String> {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
sqlx
::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(key.clone())
.bind(value)
.execute(&*pool)
.await
.execute(&*pool).await
.map_err(|e| e.to_string())?;
let _ = app_handle.track_event("setting_saved", Some(serde_json::json!({
let _ = app_handle.track_event(
"setting_saved",
Some(serde_json::json!({
"key": key
})));
}))
);
Ok(())
}
@ -91,15 +92,18 @@ pub async fn save_setting(
pub async fn get_keybind(app_handle: tauri::AppHandle) -> Result<Vec<String>, String> {
let pool = app_handle.state::<SqlitePool>();
let row = sqlx::query("SELECT value FROM settings WHERE key = 'keybind'")
.fetch_optional(&*pool)
.await
let row = sqlx
::query("SELECT value FROM settings WHERE key = 'keybind'")
.fetch_optional(&*pool).await
.map_err(|e| e.to_string())?;
let json = row.map(|r| r.get::<String, _>("value")).unwrap_or_else(|| {
serde_json::to_string(&vec!["Meta".to_string(), "V".to_string()])
.expect("Failed to serialize default keybind")
});
let json = row
.map(|r| r.get::<String, _>("value"))
.unwrap_or_else(|| {
serde_json
::to_string(&vec!["MetaLeft".to_string(), "KeyV".to_string()])
.expect("Failed to serialize default keybind")
});
serde_json::from_str::<Vec<String>>(&json).map_err(|e| e.to_string())
}

View file

@ -1,7 +1,4 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
#![cfg_attr(all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows")]
mod api;
mod db;
@ -10,7 +7,7 @@ mod utils;
use sqlx::sqlite::SqlitePoolOptions;
use std::fs;
use tauri::Manager;
use tauri_plugin_aptabase::{EventTracker, InitOptions};
use tauri_plugin_aptabase::{ EventTracker, InitOptions };
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_prevent_default::Flags;
@ -18,7 +15,8 @@ fn main() {
let runtime = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
let _guard = runtime.enter();
tauri::Builder::default()
tauri::Builder
::default()
.plugin(tauri_plugin_clipboard::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_sql::Builder::default().build())
@ -26,34 +24,37 @@ fn main() {
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_updater::Builder::default().build())
.plugin(
tauri_plugin_aptabase::Builder::new("A-SH-8937252746")
tauri_plugin_aptabase::Builder
::new("A-SH-8937252746")
.with_options(InitOptions {
host: Some("https://aptabase.pandadev.net".to_string()),
flush_interval: None,
})
.with_panic_hook(Box::new(|client, info, msg| {
let location = info
.location()
.map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column()))
.unwrap_or_else(|| "".to_string());
.with_panic_hook(
Box::new(|client, info, msg| {
let location = info
.location()
.map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column()))
.unwrap_or_else(|| "".to_string());
let _ = client.track_event(
"panic",
Some(serde_json::json!({
let _ = client.track_event(
"panic",
Some(
serde_json::json!({
"info": format!("{} ({})", msg, location),
})),
);
}))
.build(),
})
)
);
})
)
.build()
)
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
Some(vec![]),
))
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec![])))
.plugin(
tauri_plugin_prevent_default::Builder::new()
tauri_plugin_prevent_default::Builder
::new()
.with_flags(Flags::all().difference(Flags::CONTEXT_MENU))
.build(),
.build()
)
.setup(|app| {
let app_data_dir = app.path().app_data_dir().unwrap();
@ -75,8 +76,7 @@ fn main() {
tauri::async_runtime::spawn(async move {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await
.connect(&db_url).await
.expect("Failed to create pool");
app_handle_clone.manage(pool);
@ -91,7 +91,10 @@ fn main() {
let _ = api::clipboard::start_monitor(app_handle.clone());
utils::commands::center_window_on_current_monitor(main_window.as_ref().unwrap());
main_window.as_ref().map(|w| w.hide()).unwrap_or(Ok(()))?;
main_window
.as_ref()
.map(|w| w.hide())
.unwrap_or(Ok(()))?;
let _ = app.track_event("app_started", None);
@ -109,21 +112,23 @@ fn main() {
}
}
})
.invoke_handler(tauri::generate_handler![
api::clipboard::write_and_paste,
db::history::get_history,
db::history::add_history_item,
db::history::search_history,
db::history::load_history_chunk,
db::history::delete_history_item,
db::history::clear_history,
db::history::read_image,
db::settings::get_setting,
db::settings::save_setting,
db::settings::save_keybind,
db::settings::get_keybind,
utils::commands::fetch_page_meta,
])
.invoke_handler(
tauri::generate_handler![
api::clipboard::write_and_paste,
db::history::get_history,
db::history::add_history_item,
db::history::search_history,
db::history::load_history_chunk,
db::history::delete_history_item,
db::history::clear_history,
db::history::read_image,
db::settings::get_setting,
db::settings::save_setting,
db::settings::save_keybind,
db::settings::get_keybind,
utils::commands::fetch_page_meta
]
)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -1,34 +1,37 @@
use active_win_pos_rs::get_active_window;
use base64::{engine::general_purpose::STANDARD, Engine};
use base64::{ engine::general_purpose::STANDARD, Engine };
use image::codecs::png::PngEncoder;
use tauri::PhysicalPosition;
use meta_fetcher;
pub fn center_window_on_current_monitor(window: &tauri::WebviewWindow) {
if let Some(monitor) = window.available_monitors().unwrap().iter().find(|m| {
let primary_monitor = window
.primary_monitor()
if
let Some(monitor) = window
.available_monitors()
.unwrap()
.expect("Failed to get primary monitor");
let mouse_position = primary_monitor.position();
let monitor_position = m.position();
let monitor_size = m.size();
mouse_position.x >= monitor_position.x
&& mouse_position.x < monitor_position.x + monitor_size.width as i32
&& mouse_position.y >= monitor_position.y
&& mouse_position.y < monitor_position.y + monitor_size.height as i32
}) {
.iter()
.find(|m| {
let primary_monitor = window
.primary_monitor()
.unwrap()
.expect("Failed to get primary monitor");
let mouse_position = primary_monitor.position();
let monitor_position = m.position();
let monitor_size = m.size();
mouse_position.x >= monitor_position.x &&
mouse_position.x < monitor_position.x + (monitor_size.width as i32) &&
mouse_position.y >= monitor_position.y &&
mouse_position.y < monitor_position.y + (monitor_size.height as i32)
})
{
let monitor_size = monitor.size();
let window_size = window.outer_size().unwrap();
let x = (monitor_size.width as i32 - window_size.width as i32) / 2;
let y = (monitor_size.height as i32 - window_size.height as i32) / 2;
let x = ((monitor_size.width as i32) - (window_size.width as i32)) / 2;
let y = ((monitor_size.height as i32) - (window_size.height as i32)) / 2;
window
.set_position(PhysicalPosition::new(
monitor.position().x + x,
monitor.position().y + y,
))
.set_position(PhysicalPosition::new(monitor.position().x + x, monitor.position().y + y))
.unwrap();
}
}
@ -51,7 +54,6 @@ fn _process_icon_to_base64(path: &str) -> Result<String, Box<dyn std::error::Err
Ok(STANDARD.encode(png_buffer))
}
pub fn detect_color(color: &str) -> bool {
let color = color.trim().to_lowercase();
@ -60,12 +62,16 @@ pub fn detect_color(color: &str) -> bool {
let hex = &color[1..];
return match hex.len() {
3 | 6 | 8 => hex.chars().all(|c| c.is_ascii_hexdigit()),
_ => false
_ => false,
};
}
// rgb/rgba
if (color.starts_with("rgb(") || color.starts_with("rgba(")) && color.ends_with(")") && !color[..color.len()-1].contains(")") {
if
(color.starts_with("rgb(") || color.starts_with("rgba(")) &&
color.ends_with(")") &&
!color[..color.len() - 1].contains(")")
{
let values = color
.trim_start_matches("rgba(")
.trim_start_matches("rgb(")
@ -75,12 +81,16 @@ pub fn detect_color(color: &str) -> bool {
return match values.len() {
3 | 4 => values.iter().all(|v| v.trim().parse::<f32>().is_ok()),
_ => false
_ => false,
};
}
// hsl/hsla
if (color.starts_with("hsl(") || color.starts_with("hsla(")) && color.ends_with(")") && !color[..color.len()-1].contains(")") {
if
(color.starts_with("hsl(") || color.starts_with("hsla(")) &&
color.ends_with(")") &&
!color[..color.len() - 1].contains(")")
{
let values = color
.trim_start_matches("hsla(")
.trim_start_matches("hsl(")
@ -90,7 +100,7 @@ pub fn detect_color(color: &str) -> bool {
return match values.len() {
3 | 4 => values.iter().all(|v| v.trim().parse::<f32>().is_ok()),
_ => false
_ => false,
};
}
@ -99,11 +109,9 @@ pub fn detect_color(color: &str) -> bool {
#[tauri::command]
pub async fn fetch_page_meta(url: String) -> Result<(String, Option<String>), String> {
let metadata = meta_fetcher::fetch_metadata(&url)
let metadata = meta_fetcher
::fetch_metadata(&url)
.map_err(|e| format!("Failed to fetch metadata: {}", e))?;
Ok((
metadata.title.unwrap_or_else(|| "No title found".to_string()),
metadata.image
))
Ok((metadata.title.unwrap_or_else(|| "No title found".to_string()), metadata.image))
}

View file

@ -5,7 +5,7 @@ use reqwest;
use url::Url;
pub async fn fetch_favicon_as_base64(
url: Url,
url: Url
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let favicon_url = format!("https://favicone.com/{}", url.host_str().unwrap());

120
src-tauri/src/utils/keys.rs Normal file
View file

@ -0,0 +1,120 @@
use global_hotkey::hotkey::Code;
use std::str::FromStr;
pub struct KeyCode(Code);
impl FromStr for KeyCode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let code = match s {
"Backquote" => Code::Backquote,
"Backslash" => Code::Backslash,
"BracketLeft" => Code::BracketLeft,
"BracketRight" => Code::BracketRight,
"Comma" => Code::Comma,
"Digit0" => Code::Digit0,
"Digit1" => Code::Digit1,
"Digit2" => Code::Digit2,
"Digit3" => Code::Digit3,
"Digit4" => Code::Digit4,
"Digit5" => Code::Digit5,
"Digit6" => Code::Digit6,
"Digit7" => Code::Digit7,
"Digit8" => Code::Digit8,
"Digit9" => Code::Digit9,
"Equal" => Code::Equal,
"KeyA" => Code::KeyA,
"KeyB" => Code::KeyB,
"KeyC" => Code::KeyC,
"KeyD" => Code::KeyD,
"KeyE" => Code::KeyE,
"KeyF" => Code::KeyF,
"KeyG" => Code::KeyG,
"KeyH" => Code::KeyH,
"KeyI" => Code::KeyI,
"KeyJ" => Code::KeyJ,
"KeyK" => Code::KeyK,
"KeyL" => Code::KeyL,
"KeyM" => Code::KeyM,
"KeyN" => Code::KeyN,
"KeyO" => Code::KeyO,
"KeyP" => Code::KeyP,
"KeyQ" => Code::KeyQ,
"KeyR" => Code::KeyR,
"KeyS" => Code::KeyS,
"KeyT" => Code::KeyT,
"KeyU" => Code::KeyU,
"KeyV" => Code::KeyV,
"KeyW" => Code::KeyW,
"KeyX" => Code::KeyX,
"KeyY" => Code::KeyY,
"KeyZ" => Code::KeyZ,
"Minus" => Code::Minus,
"Period" => Code::Period,
"Quote" => Code::Quote,
"Semicolon" => Code::Semicolon,
"Slash" => Code::Slash,
"Backspace" => Code::Backspace,
"CapsLock" => Code::CapsLock,
"Delete" => Code::Delete,
"Enter" => Code::Enter,
"Space" => Code::Space,
"Tab" => Code::Tab,
"End" => Code::End,
"Home" => Code::Home,
"Insert" => Code::Insert,
"PageDown" => Code::PageDown,
"PageUp" => Code::PageUp,
"ArrowDown" => Code::ArrowDown,
"ArrowLeft" => Code::ArrowLeft,
"ArrowRight" => Code::ArrowRight,
"ArrowUp" => Code::ArrowUp,
"NumLock" => Code::NumLock,
"Numpad0" => Code::Numpad0,
"Numpad1" => Code::Numpad1,
"Numpad2" => Code::Numpad2,
"Numpad3" => Code::Numpad3,
"Numpad4" => Code::Numpad4,
"Numpad5" => Code::Numpad5,
"Numpad6" => Code::Numpad6,
"Numpad7" => Code::Numpad7,
"Numpad8" => Code::Numpad8,
"Numpad9" => Code::Numpad9,
"NumpadAdd" => Code::NumpadAdd,
"NumpadDecimal" => Code::NumpadDecimal,
"NumpadDivide" => Code::NumpadDivide,
"NumpadMultiply" => Code::NumpadMultiply,
"NumpadSubtract" => Code::NumpadSubtract,
"Escape" => Code::Escape,
"PrintScreen" => Code::PrintScreen,
"ScrollLock" => Code::ScrollLock,
"Pause" => Code::Pause,
"AudioVolumeDown" => Code::AudioVolumeDown,
"AudioVolumeMute" => Code::AudioVolumeMute,
"AudioVolumeUp" => Code::AudioVolumeUp,
"F1" => Code::F1,
"F2" => Code::F2,
"F3" => Code::F3,
"F4" => Code::F4,
"F5" => Code::F5,
"F6" => Code::F6,
"F7" => Code::F7,
"F8" => Code::F8,
"F9" => Code::F9,
"F10" => Code::F10,
"F11" => Code::F11,
"F12" => Code::F12,
_ => {
return Err(format!("Unknown key code: {}", s));
}
};
Ok(KeyCode(code))
}
}
impl From<KeyCode> for Code {
fn from(key_code: KeyCode) -> Self {
key_code.0
}
}

View file

@ -1,6 +1,6 @@
use chrono;
use log::{LevelFilter, SetLoggerError};
use std::fs::{File, OpenOptions};
use log::{ LevelFilter, SetLoggerError };
use std::fs::{ File, OpenOptions };
use std::io::Write;
use std::panic;
@ -50,32 +50,38 @@ pub fn init_logger(app_data_dir: &std::path::Path) -> Result<(), SetLoggerError>
// Set up panic hook
let panic_file = file.try_clone().expect("Failed to clone file handle");
panic::set_hook(Box::new(move |panic_info| {
let mut file = panic_file.try_clone().expect("Failed to clone file handle");
panic::set_hook(
Box::new(move |panic_info| {
let mut file = panic_file.try_clone().expect("Failed to clone file handle");
let location = panic_info.location()
.map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column()))
.unwrap_or_else(|| "unknown location".to_string());
let location = panic_info
.location()
.map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column()))
.unwrap_or_else(|| "unknown location".to_string());
let message = match panic_info.payload().downcast_ref::<&str>() {
Some(s) => *s,
None => match panic_info.payload().downcast_ref::<String>() {
Some(s) => s.as_str(),
None => "Unknown panic message",
},
};
let message = match panic_info.payload().downcast_ref::<&str>() {
Some(s) => *s,
None =>
match panic_info.payload().downcast_ref::<String>() {
Some(s) => s.as_str(),
None => "Unknown panic message",
}
};
let _ = writeln!(
file,
"{} [PANIC] rust_panic: {} ({})",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
message,
location
);
}));
let _ = writeln!(
file,
"{} [PANIC] rust_panic: {} ({})",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
message,
location
);
})
);
let logger = Box::new(FileLogger { file });
unsafe { log::set_logger_racy(Box::leak(logger))? };
unsafe {
log::set_logger_racy(Box::leak(logger))?;
}
log::set_max_level(LevelFilter::Debug);
Ok(())
}

View file

@ -2,3 +2,4 @@ pub mod commands;
pub mod favicon;
pub mod types;
pub mod logger;
pub mod keys;

View file

@ -1,5 +1,5 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use chrono::{ DateTime, Utc };
use serde::{ Deserialize, Serialize };
use std::fmt;
use uuid::Uuid;
@ -115,7 +115,7 @@ impl HistoryItem {
content: String,
favicon: Option<String>,
source_icon: Option<String>,
language: Option<String>,
language: Option<String>
) -> Self {
Self {
id: Uuid::new_v4().to_string(),
@ -130,7 +130,7 @@ impl HistoryItem {
}
pub fn to_row(
&self,
&self
) -> (
String,
String,

View file

@ -1,6 +1,6 @@
{
"productName": "Qopy",
"version": "0.3.3",
"version": "0.3.4",
"identifier": "net.pandadev.qopy",
"build": {
"frontendDist": "../dist",

217
types/keys.ts Normal file
View file

@ -0,0 +1,217 @@
export enum KeyValues {
Backquote = 'Backquote',
Backslash = 'Backslash',
BracketLeft = 'BracketLeft',
BracketRight = 'BracketRight',
Comma = 'Comma',
Digit0 = 'Digit0',
Digit1 = 'Digit1',
Digit2 = 'Digit2',
Digit3 = 'Digit3',
Digit4 = 'Digit4',
Digit5 = 'Digit5',
Digit6 = 'Digit6',
Digit7 = 'Digit7',
Digit8 = 'Digit8',
Digit9 = 'Digit9',
Equal = 'Equal',
KeyA = 'KeyA',
KeyB = 'KeyB',
KeyC = 'KeyC',
KeyD = 'KeyD',
KeyE = 'KeyE',
KeyF = 'KeyF',
KeyG = 'KeyG',
KeyH = 'KeyH',
KeyI = 'KeyI',
KeyJ = 'KeyJ',
KeyK = 'KeyK',
KeyL = 'KeyL',
KeyM = 'KeyM',
KeyN = 'KeyN',
KeyO = 'KeyO',
KeyP = 'KeyP',
KeyQ = 'KeyQ',
KeyR = 'KeyR',
KeyS = 'KeyS',
KeyT = 'KeyT',
KeyU = 'KeyU',
KeyV = 'KeyV',
KeyW = 'KeyW',
KeyX = 'KeyX',
KeyY = 'KeyY',
KeyZ = 'KeyZ',
Minus = 'Minus',
Period = 'Period',
Quote = 'Quote',
Semicolon = 'Semicolon',
Slash = 'Slash',
AltLeft = 'AltLeft',
AltRight = 'AltRight',
Backspace = 'Backspace',
CapsLock = 'CapsLock',
ContextMenu = 'ContextMenu',
ControlLeft = 'ControlLeft',
ControlRight = 'ControlRight',
Enter = 'Enter',
MetaLeft = 'MetaLeft',
MetaRight = 'MetaRight',
ShiftLeft = 'ShiftLeft',
ShiftRight = 'ShiftRight',
Space = 'Space',
Tab = 'Tab',
Delete = 'Delete',
End = 'End',
Home = 'Home',
Insert = 'Insert',
PageDown = 'PageDown',
PageUp = 'PageUp',
ArrowDown = 'ArrowDown',
ArrowLeft = 'ArrowLeft',
ArrowRight = 'ArrowRight',
ArrowUp = 'ArrowUp',
NumLock = 'NumLock',
Numpad0 = 'Numpad0',
Numpad1 = 'Numpad1',
Numpad2 = 'Numpad2',
Numpad3 = 'Numpad3',
Numpad4 = 'Numpad4',
Numpad5 = 'Numpad5',
Numpad6 = 'Numpad6',
Numpad7 = 'Numpad7',
Numpad8 = 'Numpad8',
Numpad9 = 'Numpad9',
NumpadAdd = 'NumpadAdd',
NumpadDecimal = 'NumpadDecimal',
NumpadDivide = 'NumpadDivide',
NumpadMultiply = 'NumpadMultiply',
NumpadSubtract = 'NumpadSubtract',
Escape = 'Escape',
PrintScreen = 'PrintScreen',
ScrollLock = 'ScrollLock',
Pause = 'Pause',
AudioVolumeDown = 'AudioVolumeDown',
AudioVolumeMute = 'AudioVolumeMute',
AudioVolumeUp = 'AudioVolumeUp',
F1 = 'F1',
F2 = 'F2',
F3 = 'F3',
F4 = 'F4',
F5 = 'F5',
F6 = 'F6',
F7 = 'F7',
F8 = 'F8',
F9 = 'F9',
F10 = 'F10',
F11 = 'F11',
F12 = 'F12',
}
export enum KeyLabels {
Backquote = '`',
Backslash = '\\',
BracketLeft = '[',
BracketRight = ']',
Comma = ',',
Digit0 = '0',
Digit1 = '1',
Digit2 = '2',
Digit3 = '3',
Digit4 = '4',
Digit5 = '5',
Digit6 = '6',
Digit7 = '7',
Digit8 = '8',
Digit9 = '9',
Equal = '=',
KeyA = 'A',
KeyB = 'B',
KeyC = 'C',
KeyD = 'D',
KeyE = 'E',
KeyF = 'F',
KeyG = 'G',
KeyH = 'H',
KeyI = 'I',
KeyJ = 'J',
KeyK = 'K',
KeyL = 'L',
KeyM = 'M',
KeyN = 'N',
KeyO = 'O',
KeyP = 'P',
KeyQ = 'Q',
KeyR = 'R',
KeyS = 'S',
KeyT = 'T',
KeyU = 'U',
KeyV = 'V',
KeyW = 'W',
KeyX = 'X',
KeyY = 'Y',
KeyZ = 'Z',
Minus = '-',
Period = '.',
Quote = "'",
Semicolon = ';',
Slash = '/',
AltLeft = 'Alt',
AltRight = 'Alt (Right)',
Backspace = 'Backspace',
CapsLock = 'Caps Lock',
ContextMenu = 'Context Menu',
ControlLeft = 'Ctrl',
ControlRight = 'Ctrl (Right)',
Enter = 'Enter',
MetaLeft = 'Meta',
MetaRight = 'Meta (Right)',
ShiftLeft = 'Shift',
ShiftRight = 'Shift (Right)',
Space = 'Space',
Tab = 'Tab',
Delete = 'Delete',
End = 'End',
Home = 'Home',
Insert = 'Insert',
PageDown = 'Page Down',
PageUp = 'Page Up',
ArrowDown = '↓',
ArrowLeft = '←',
ArrowRight = '→',
ArrowUp = '↑',
NumLock = 'Num Lock',
Numpad0 = 'Numpad 0',
Numpad1 = 'Numpad 1',
Numpad2 = 'Numpad 2',
Numpad3 = 'Numpad 3',
Numpad4 = 'Numpad 4',
Numpad5 = 'Numpad 5',
Numpad6 = 'Numpad 6',
Numpad7 = 'Numpad 7',
Numpad8 = 'Numpad 8',
Numpad9 = 'Numpad 9',
NumpadAdd = 'Numpad +',
NumpadDecimal = 'Numpad .',
NumpadDivide = 'Numpad /',
NumpadMultiply = 'Numpad *',
NumpadSubtract = 'Numpad -',
Escape = 'Esc',
PrintScreen = 'Print Screen',
ScrollLock = 'Scroll Lock',
Pause = 'Pause',
AudioVolumeDown = 'Volume Down',
AudioVolumeMute = 'Volume Mute',
AudioVolumeUp = 'Volume Up',
F1 = 'F1',
F2 = 'F2',
F3 = 'F3',
F4 = 'F4',
F5 = 'F5',
F6 = 'F6',
F7 = 'F7',
F8 = 'F8',
F9 = 'F9',
F10 = 'F10',
F11 = 'F11',
F12 = 'F12',
}