fedora
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
|
||||
// Clipboard history service using cliphist + local content cache
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Public API
|
||||
property bool active: Settings.data.appLauncher.enableClipboardHistory && cliphistAvailable
|
||||
property bool loading: false
|
||||
property var items: [] // [{id, preview, mime, isImage}]
|
||||
|
||||
// Check if cliphist is available on the system
|
||||
property bool cliphistAvailable: false
|
||||
property bool dependencyChecked: false
|
||||
|
||||
// Optional automatic watchers to feed cliphist DB
|
||||
property bool autoWatch: true
|
||||
property bool watchersStarted: false
|
||||
|
||||
// Expose decoded thumbnails by id and a revision to notify bindings
|
||||
property var imageDataById: ({})
|
||||
property var _imageDataInsertOrder: [] // insertion-order IDs for LRU eviction
|
||||
readonly property int _imageDataMaxEntries: 20 // max decoded images held in RAM at once
|
||||
property int revision: 0
|
||||
|
||||
// Local content cache - stores full text content by ID
|
||||
// This avoids relying on cliphist decode which can be unreliable
|
||||
property var contentCache: ({})
|
||||
|
||||
// Track the most recent clipboard content for instant access
|
||||
property string _latestTextContent: ""
|
||||
property string _latestTextId: ""
|
||||
|
||||
// Approximate first-seen timestamps for entries this session (seconds)
|
||||
property var firstSeenById: ({})
|
||||
|
||||
// Internal: store callback for decode
|
||||
property var _decodeCallback: null
|
||||
property int _decodeRequestId: 0
|
||||
|
||||
// Queue for base64 decodes
|
||||
property var _b64Queue: []
|
||||
property var _b64CurrentCb: null
|
||||
property string _b64CurrentMime: ""
|
||||
property string _b64CurrentId: ""
|
||||
|
||||
signal listCompleted
|
||||
|
||||
// Check if cliphist is available
|
||||
Component.onCompleted: {
|
||||
checkCliphistAvailability();
|
||||
}
|
||||
|
||||
// Check dependency availability
|
||||
function checkCliphistAvailability() {
|
||||
if (dependencyChecked)
|
||||
return;
|
||||
dependencyCheckProcess.command = ["sh", "-c", "command -v cliphist"];
|
||||
dependencyCheckProcess.running = true;
|
||||
}
|
||||
|
||||
// Process to check if cliphist is available
|
||||
Process {
|
||||
id: dependencyCheckProcess
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.dependencyChecked = true;
|
||||
if (exitCode === 0) {
|
||||
root.cliphistAvailable = true;
|
||||
// Start watchers if feature is enabled
|
||||
if (root.active) {
|
||||
startWatchers();
|
||||
}
|
||||
} else {
|
||||
root.cliphistAvailable = false;
|
||||
// Show toast notification if feature is enabled but cliphist is missing
|
||||
if (Settings.data.appLauncher.enableClipboardHistory) {
|
||||
ToastService.showWarning(I18n.tr("toast.clipboard.unavailable"), I18n.tr("toast.clipboard.unavailable-desc"), 6000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start/stop watchers when enabled changes
|
||||
onActiveChanged: {
|
||||
if (root.active) {
|
||||
startWatchers();
|
||||
} else {
|
||||
stopWatchers();
|
||||
loading = false;
|
||||
items = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: periodically refresh list so UI updates even if not in clip mode
|
||||
Timer {
|
||||
interval: 5000
|
||||
repeat: true
|
||||
running: root.active
|
||||
onTriggered: list()
|
||||
}
|
||||
|
||||
// Internal process objects
|
||||
Process {
|
||||
id: listProc
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
const out = String(stdout.text);
|
||||
const lines = out.split('\n').filter(l => l.length > 0);
|
||||
// cliphist list default format: "<id> <preview>" or "<id>\t<preview>"
|
||||
const parsed = lines.map((l, i) => {
|
||||
let id = "";
|
||||
let preview = "";
|
||||
const m = l.match(/^(\d+)\s+(.+)$/);
|
||||
if (m) {
|
||||
id = m[1];
|
||||
preview = m[2];
|
||||
} else {
|
||||
const tab = l.indexOf('\t');
|
||||
id = tab > -1 ? l.slice(0, tab) : l;
|
||||
preview = tab > -1 ? l.slice(tab + 1) : "";
|
||||
}
|
||||
const lower = preview.toLowerCase();
|
||||
const isImage = lower.startsWith("[image]") || lower.includes(" binary data ");
|
||||
// Best-effort mime guess from preview
|
||||
var mime = "text/plain";
|
||||
if (isImage) {
|
||||
if (lower.includes(" png"))
|
||||
mime = "image/png";
|
||||
else if (lower.includes(" jpg") || lower.includes(" jpeg"))
|
||||
mime = "image/jpeg";
|
||||
else if (lower.includes(" webp"))
|
||||
mime = "image/webp";
|
||||
else if (lower.includes(" gif"))
|
||||
mime = "image/gif";
|
||||
else
|
||||
mime = "image/*";
|
||||
}
|
||||
// Record first seen time for new ids (approximate copy time)
|
||||
if (!root.firstSeenById[id]) {
|
||||
const assumedAge = i * 15 * 60;
|
||||
root.firstSeenById[id] = Time.timestamp - assumedAge;
|
||||
}
|
||||
// Smart type detection
|
||||
var contentType = "text";
|
||||
if (isImage) {
|
||||
contentType = "image";
|
||||
} else {
|
||||
const t = preview.trim();
|
||||
const tLower = t.toLowerCase();
|
||||
if (/^#([a-f0-9]{3}|[a-f0-9]{6}|[a-f0-9]{8})$/.test(tLower)) {
|
||||
contentType = "color";
|
||||
} else if (/^https?:\/\//i.test(t)) {
|
||||
contentType = "link";
|
||||
} else if (/^(\/|~\/|file:\/\/)/i.test(t) && !t.startsWith('//') && !t.includes('\n')) {
|
||||
contentType = "file";
|
||||
} else if ((t.includes('{') && t.includes('}') && (t.includes(';') || t.includes('='))) || t.includes('</') || t.includes('/>') || t.includes('=>') || t.includes('===') || t.includes('!==') || t.includes('::') || t.includes('->') ||
|
||||
/^(?:const|let|var|function|class|struct|interface|type|enum|import|export|func|fn|pub|def|using|namespace|property|public|private|protected)\b/i.test(t) || /^(?:#include|#define|#\[|@|\/\/|\/\*|<\?|<html|<body|<!DOCTYPE)/i.test(t) || /\b(?:require\(|module\.exports)\b/i.test(t)) {
|
||||
contentType = "code";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"id": id,
|
||||
"preview": preview,
|
||||
"isImage": isImage,
|
||||
"mime": mime,
|
||||
"contentType": contentType
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out browser junk when copying images
|
||||
const filtered = parsed.filter(item => {
|
||||
if (item.isImage)
|
||||
return true;
|
||||
const p = item.preview;
|
||||
// Skip UTF-16 encoded text (has null bytes between chars), chromium browser artifact
|
||||
const nullCount = (p.match(/\x00/g) || []).length;
|
||||
if (nullCount > p.length * 0.2)
|
||||
return false;
|
||||
// Skip browser-generated HTML wrapper, firefox
|
||||
if (p.toLowerCase().startsWith("<meta http-equiv="))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
items = filtered;
|
||||
loading = false;
|
||||
|
||||
// Try to capture current clipboard and associate with newest item
|
||||
if (filtered.length > 0 && !filtered[0].isImage && !root.contentCache[filtered[0].id]) {
|
||||
root.captureCurrentClipboard();
|
||||
}
|
||||
|
||||
root.listCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: decodeProc
|
||||
property int requestId: 0
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (requestId === root._decodeRequestId && root._decodeCallback) {
|
||||
const out = String(stdout.text);
|
||||
try {
|
||||
root._decodeCallback(out);
|
||||
} finally {
|
||||
root._decodeCallback = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: copyProc
|
||||
stdout: StdioCollector {}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: pasteProc
|
||||
stdout: StdioCollector {}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: deleteProc
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
revision++;
|
||||
Qt.callLater(() => list());
|
||||
}
|
||||
}
|
||||
|
||||
// Base64 decode pipeline (queued)
|
||||
Process {
|
||||
id: decodeB64Proc
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
const b64 = String(stdout.text).trim();
|
||||
if (root._b64CurrentCb) {
|
||||
const url = `data:${root._b64CurrentMime};base64,${b64}`;
|
||||
try {
|
||||
root._b64CurrentCb(url);
|
||||
} catch (e) {}
|
||||
}
|
||||
if (root._b64CurrentId !== "") {
|
||||
const entryId = root._b64CurrentId;
|
||||
root.imageDataById[entryId] = `data:${root._b64CurrentMime};base64,${b64}`;
|
||||
// Track insertion order and evict oldest entries beyond the cap
|
||||
root._imageDataInsertOrder.push(entryId);
|
||||
while (root._imageDataInsertOrder.length > root._imageDataMaxEntries) {
|
||||
const evicted = root._imageDataInsertOrder.shift();
|
||||
delete root.imageDataById[evicted];
|
||||
}
|
||||
root.revision += 1;
|
||||
}
|
||||
root._b64CurrentCb = null;
|
||||
root._b64CurrentMime = "";
|
||||
root._b64CurrentId = "";
|
||||
Qt.callLater(root._startNextB64);
|
||||
}
|
||||
}
|
||||
|
||||
// Text watcher - stores to cliphist and triggers content capture
|
||||
Process {
|
||||
id: watchText
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (root.autoWatch && root.watchersStarted && Settings.data.appLauncher.clipboardWatchTextCommand.trim() !== "") {
|
||||
watchTextRestartTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: watchTextRestartTimer
|
||||
interval: 1000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (root.autoWatch && root.watchersStarted)
|
||||
watchText.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Image watcher
|
||||
Process {
|
||||
id: watchImage
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (root.autoWatch && root.watchersStarted && Settings.data.appLauncher.clipboardWatchImageCommand.trim() !== "") {
|
||||
watchImageRestartTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: watchImageRestartTimer
|
||||
interval: 1000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (root.autoWatch && root.watchersStarted)
|
||||
watchImage.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Capture current clipboard text when needed
|
||||
Process {
|
||||
id: captureTextProc
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode === 0) {
|
||||
const content = String(stdout.text);
|
||||
if (content.length > 0) {
|
||||
root._latestTextContent = content;
|
||||
// Associate with newest item if we have one
|
||||
if (root.items.length > 0 && !root.items[0].isImage) {
|
||||
const newestId = root.items[0].id;
|
||||
if (!root.contentCache[newestId]) {
|
||||
root.contentCache[newestId] = content;
|
||||
root.revision++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startWatchers() {
|
||||
if (!root.active || !autoWatch || watchersStarted || !root.cliphistAvailable)
|
||||
return;
|
||||
watchersStarted = true;
|
||||
|
||||
// Text watcher
|
||||
watchText.command = ["sh", "-c", Settings.data.appLauncher.clipboardWatchTextCommand];
|
||||
watchText.running = true;
|
||||
|
||||
// Image watcher
|
||||
watchImage.command = ["sh", "-c", Settings.data.appLauncher.clipboardWatchImageCommand];
|
||||
watchImage.running = true;
|
||||
}
|
||||
|
||||
function stopWatchers() {
|
||||
if (!watchersStarted)
|
||||
return;
|
||||
watchText.running = false;
|
||||
watchImage.running = false;
|
||||
watchersStarted = false;
|
||||
}
|
||||
|
||||
// Capture current clipboard text and cache it
|
||||
function captureCurrentClipboard() {
|
||||
if (captureTextProc.running)
|
||||
return;
|
||||
captureTextProc.command = ["wl-paste", "--no-newline"];
|
||||
captureTextProc.running = true;
|
||||
}
|
||||
|
||||
function list(maxPreviewWidth) {
|
||||
if (!root.active || !root.cliphistAvailable) {
|
||||
return;
|
||||
}
|
||||
if (listProc.running)
|
||||
return;
|
||||
loading = true;
|
||||
const width = maxPreviewWidth || 100;
|
||||
listProc.command = ["cliphist", "list", "-preview-width", String(width)];
|
||||
listProc.running = true;
|
||||
}
|
||||
|
||||
// Get content for an ID - uses cache first, falls back to cliphist decode
|
||||
function getContent(id) {
|
||||
if (root.contentCache[id]) {
|
||||
return root.contentCache[id];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Async decode - checks cache first, then falls back to cliphist
|
||||
function decode(id, cb) {
|
||||
if (!root.cliphistAvailable) {
|
||||
if (cb)
|
||||
cb("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cached = root.contentCache[id];
|
||||
if (cached) {
|
||||
if (cb)
|
||||
cb(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to cliphist decode
|
||||
if (decodeProc.running) {
|
||||
decodeProc.running = false;
|
||||
}
|
||||
root._decodeRequestId++;
|
||||
decodeProc.requestId = root._decodeRequestId;
|
||||
root._decodeCallback = function (content) {
|
||||
// Cache the result if successful
|
||||
if (content && content.trim()) {
|
||||
root.contentCache[id] = content;
|
||||
}
|
||||
if (cb)
|
||||
cb(content);
|
||||
};
|
||||
const idStr = String(id);
|
||||
decodeProc.command = ["cliphist", "decode", idStr];
|
||||
decodeProc.running = true;
|
||||
}
|
||||
|
||||
function decodeToDataUrl(id, mime, cb) {
|
||||
if (!root.cliphistAvailable) {
|
||||
if (cb)
|
||||
cb("");
|
||||
return;
|
||||
}
|
||||
// If cached, return immediately
|
||||
if (root.imageDataById[id]) {
|
||||
if (cb)
|
||||
cb(root.imageDataById[id]);
|
||||
return;
|
||||
}
|
||||
// Queue request; ensures single process handles sequentially
|
||||
root._b64Queue.push({
|
||||
"id": id,
|
||||
"mime": mime || "image/*",
|
||||
"cb": cb
|
||||
});
|
||||
if (!decodeB64Proc.running && root._b64CurrentCb === null) {
|
||||
_startNextB64();
|
||||
}
|
||||
}
|
||||
|
||||
function getImageData(id) {
|
||||
if (id === undefined) {
|
||||
return null;
|
||||
}
|
||||
return root.imageDataById[id];
|
||||
}
|
||||
|
||||
function _startNextB64() {
|
||||
if (root._b64Queue.length === 0 || !root.cliphistAvailable)
|
||||
return;
|
||||
const job = root._b64Queue.shift();
|
||||
root._b64CurrentCb = job.cb;
|
||||
root._b64CurrentMime = job.mime;
|
||||
root._b64CurrentId = job.id;
|
||||
decodeB64Proc.command = ["sh", "-c", `cliphist decode ${job.id} | base64 -w 0`];
|
||||
decodeB64Proc.running = true;
|
||||
}
|
||||
|
||||
function copyToClipboard(id) {
|
||||
if (!root.cliphistAvailable) {
|
||||
return;
|
||||
}
|
||||
copyProc.command = ["sh", "-c", `cliphist decode ${id} | wl-copy`];
|
||||
copyProc.running = true;
|
||||
}
|
||||
|
||||
function pasteFromClipboard(id, mime) {
|
||||
if (!root.cliphistAvailable) {
|
||||
return;
|
||||
}
|
||||
const isImage = mime && mime.startsWith("image/");
|
||||
const typeArg = isImage ? ` --type ${mime}` : "";
|
||||
const pasteKeys = isImage ? "wtype -M ctrl -k v" : "wtype -M ctrl -M shift v";
|
||||
const cmd = `cliphist decode ${id} | wl-copy${typeArg} && ${pasteKeys}`;
|
||||
pasteProc.command = ["sh", "-c", cmd];
|
||||
pasteProc.running = true;
|
||||
}
|
||||
|
||||
function pasteText(text) {
|
||||
if (!text)
|
||||
return;
|
||||
const escaped = text.replace(/'/g, "'\\''");
|
||||
const cmd = `printf '%s' '${escaped}' | wl-copy && wtype -M ctrl -M shift v`;
|
||||
pasteProc.command = ["sh", "-c", cmd];
|
||||
pasteProc.running = true;
|
||||
}
|
||||
|
||||
function deleteById(id) {
|
||||
if (!root.cliphistAvailable) {
|
||||
return;
|
||||
}
|
||||
if (deleteProc.running) {
|
||||
return;
|
||||
}
|
||||
const idStr = String(id).trim();
|
||||
// Remove from caches
|
||||
delete root.contentCache[idStr];
|
||||
delete root.imageDataById[idStr];
|
||||
const orderIdx = root._imageDataInsertOrder.indexOf(idStr);
|
||||
if (orderIdx !== -1)
|
||||
root._imageDataInsertOrder.splice(orderIdx, 1);
|
||||
deleteProc.command = ["sh", "-c", `echo ${idStr} | cliphist delete`];
|
||||
deleteProc.running = true;
|
||||
}
|
||||
|
||||
function wipeAll() {
|
||||
if (!root.cliphistAvailable) {
|
||||
return;
|
||||
}
|
||||
// Clear caches
|
||||
root.contentCache = {};
|
||||
root.imageDataById = {};
|
||||
root._imageDataInsertOrder = [];
|
||||
root._latestTextContent = "";
|
||||
root._latestTextId = "";
|
||||
|
||||
Quickshell.execDetached(["cliphist", "wipe"]);
|
||||
revision++;
|
||||
Qt.callLater(() => list());
|
||||
}
|
||||
|
||||
// Parse image metadata from cliphist preview string
|
||||
function parseImageMeta(preview) {
|
||||
const re = /\[\[\s*binary data\s+([\d\.]+\s*(?:KiB|MiB|GiB|B))\s+(\w+)\s+(\d+)x(\d+)\s*\]\]/i;
|
||||
const match = (preview || "").match(re);
|
||||
if (!match)
|
||||
return null;
|
||||
return {
|
||||
"size": match[1],
|
||||
"fmt": (match[2] || "").toUpperCase(),
|
||||
"w": Number(match[3]),
|
||||
"h": Number(match[4])
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
|
||||
// Manages emoji data loading, searching, and clipboard operations
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var emojis: []
|
||||
property bool loaded: false
|
||||
|
||||
// Usage tracking for popular emojis
|
||||
// Format: { "emoji": { count: number, lastUsed: timestamp } }
|
||||
property var usageCounts: ({})
|
||||
|
||||
// File path for persisting usage data
|
||||
readonly property string usageFilePath: Settings.cacheDir + "emoji_usage.json"
|
||||
|
||||
// Searches emojis based on query
|
||||
function search(query) {
|
||||
if (!loaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!query || query.trim() === "") {
|
||||
return emojis;
|
||||
}
|
||||
|
||||
const terms = query.toLowerCase().split(" ").filter(t => t);
|
||||
const results = emojis.filter(emoji => {
|
||||
for (let term of terms) {
|
||||
const emojiMatch = emoji.emoji.toLowerCase().includes(term);
|
||||
const nameMatch = emoji.name.toLowerCase().includes(term);
|
||||
const keywordMatch = emoji.keywords.some(kw => kw.toLowerCase().includes(term));
|
||||
const categoryMatch = emoji.category.toLowerCase().includes(term);
|
||||
|
||||
if (!emojiMatch && !nameMatch && !keywordMatch && !categoryMatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function _getPopularEmojis(limit) {
|
||||
var emojisWithUsage = emojis.map(function (emoji) {
|
||||
const usageData = usageCounts[emoji.emoji];
|
||||
return {
|
||||
emoji: emoji,
|
||||
usageCount: usageData ? (usageData.count || 0) : 0,
|
||||
lastUsed: usageData ? (usageData.lastUsed || 0) : 0
|
||||
};
|
||||
}).filter(function (item) {
|
||||
return item.usageCount > 0;
|
||||
});
|
||||
|
||||
// Sort by last used timestamp (descending - most recent first)
|
||||
// Then by usage count if timestamps are equal
|
||||
emojisWithUsage.sort(function (a, b) {
|
||||
if (b.lastUsed !== a.lastUsed) {
|
||||
return b.lastUsed - a.lastUsed;
|
||||
}
|
||||
if (b.usageCount !== a.usageCount) {
|
||||
return b.usageCount - a.usageCount;
|
||||
}
|
||||
return a.emoji.name.localeCompare(b.emoji.name);
|
||||
});
|
||||
|
||||
// Return the emoji objects limited by the specified count
|
||||
return emojisWithUsage.slice(0, limit).map(function (item) {
|
||||
return item.emoji;
|
||||
});
|
||||
}
|
||||
|
||||
function getCategoriesWithCounts() {
|
||||
if (!loaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
var categoryCounts = {};
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
var emoji = emojis[i];
|
||||
var category = emoji.category || "other";
|
||||
if (!categoryCounts[category]) {
|
||||
categoryCounts[category] = 0;
|
||||
}
|
||||
categoryCounts[category]++;
|
||||
}
|
||||
|
||||
var categories = [];
|
||||
for (var cat in categoryCounts) {
|
||||
categories.push({
|
||||
name: cat,
|
||||
count: categoryCounts[cat]
|
||||
});
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
function getEmojisByCategory(category) {
|
||||
if (!loaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (category === "recent") {
|
||||
return _getPopularEmojis(25);
|
||||
}
|
||||
|
||||
return emojis.filter(function (emoji) {
|
||||
return emoji.category === category;
|
||||
});
|
||||
}
|
||||
|
||||
// Record emoji usage
|
||||
function recordUsage(emojiChar) {
|
||||
if (emojiChar) {
|
||||
const currentData = usageCounts[emojiChar] || {
|
||||
count: 0,
|
||||
lastUsed: 0
|
||||
};
|
||||
usageCounts[emojiChar] = {
|
||||
count: currentData.count + 1,
|
||||
lastUsed: Date.now()
|
||||
};
|
||||
_saveUsageData();
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure usage file exists with default content
|
||||
function _ensureUsageFileExists() {
|
||||
Quickshell.execDetached(["sh", "-c", `mkdir -p "$(dirname "${root.usageFilePath}")" && echo '{}' > "${root.usageFilePath}"`]);
|
||||
}
|
||||
|
||||
// File paths
|
||||
readonly property string userEmojiPath: Settings.configDir + "emoji.json"
|
||||
readonly property string builtinEmojiPath: `${Quickshell.shellDir}/Assets/Launcher/emoji.json`
|
||||
|
||||
// Internal data
|
||||
property var _userEmojiData: []
|
||||
property var _builtinEmojiData: []
|
||||
property int _pendingLoads: 0
|
||||
|
||||
// Initialize on component completion
|
||||
Component.onCompleted: {
|
||||
_loadUsageData();
|
||||
_loadEmojis();
|
||||
}
|
||||
|
||||
// File loaders
|
||||
FileView {
|
||||
id: userEmojiFile
|
||||
path: root.userEmojiPath
|
||||
printErrors: false
|
||||
watchChanges: false
|
||||
|
||||
onLoaded: {
|
||||
try {
|
||||
const content = text();
|
||||
if (content) {
|
||||
const parsed = JSON.parse(content);
|
||||
_userEmojiData = Array.isArray(parsed) ? parsed : [];
|
||||
} else {
|
||||
_userEmojiData = [];
|
||||
}
|
||||
} catch (e) {
|
||||
_userEmojiData = [];
|
||||
}
|
||||
_onLoadComplete();
|
||||
}
|
||||
|
||||
onLoadFailed: function (error) {
|
||||
_userEmojiData = [];
|
||||
_onLoadComplete();
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: builtinEmojiFile
|
||||
path: root.builtinEmojiPath
|
||||
printErrors: false
|
||||
watchChanges: false
|
||||
|
||||
onLoaded: {
|
||||
try {
|
||||
const content = text();
|
||||
if (content) {
|
||||
const parsed = JSON.parse(content);
|
||||
_builtinEmojiData = Array.isArray(parsed) ? parsed : [];
|
||||
} else {
|
||||
_builtinEmojiData = [];
|
||||
}
|
||||
} catch (e) {
|
||||
_builtinEmojiData = [];
|
||||
}
|
||||
_onLoadComplete();
|
||||
}
|
||||
|
||||
onLoadFailed: function (error) {
|
||||
_builtinEmojiData = [];
|
||||
_onLoadComplete();
|
||||
}
|
||||
}
|
||||
|
||||
// Load emoji files
|
||||
function _loadEmojis() {
|
||||
_pendingLoads = 2;
|
||||
userEmojiFile.reload();
|
||||
builtinEmojiFile.reload();
|
||||
}
|
||||
|
||||
// Called when one file finishes loading
|
||||
function _onLoadComplete() {
|
||||
_pendingLoads--;
|
||||
if (_pendingLoads <= 0) {
|
||||
_finalizeEmojis();
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and deduplicate emojis
|
||||
function _finalizeEmojis() {
|
||||
const emojiMap = new Map();
|
||||
|
||||
// Add built-in emojis first
|
||||
for (const emoji of _builtinEmojiData) {
|
||||
if (emoji && emoji.emoji) {
|
||||
emojiMap.set(emoji.emoji, emoji);
|
||||
}
|
||||
}
|
||||
|
||||
// Add user emojis (override built-ins if duplicate)
|
||||
for (const emoji of _userEmojiData) {
|
||||
if (emoji && emoji.emoji) {
|
||||
emojiMap.set(emoji.emoji, emoji);
|
||||
}
|
||||
}
|
||||
|
||||
emojis = Array.from(emojiMap.values());
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
// FileView for usage data
|
||||
FileView {
|
||||
id: usageFile
|
||||
path: root.usageFilePath
|
||||
printErrors: false
|
||||
watchChanges: false
|
||||
|
||||
onLoaded: {
|
||||
try {
|
||||
const content = text();
|
||||
if (content && content.trim() !== "") {
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
root.usageCounts = parsed;
|
||||
} else {
|
||||
root.usageCounts = {};
|
||||
}
|
||||
} else {
|
||||
root.usageCounts = {};
|
||||
}
|
||||
} catch (e) {
|
||||
root.usageCounts = {};
|
||||
}
|
||||
}
|
||||
|
||||
onLoadFailed: function (error) {
|
||||
root.usageCounts = {};
|
||||
Qt.callLater(_ensureUsageFileExists);
|
||||
}
|
||||
}
|
||||
|
||||
// Timer for debouncing usage data saves
|
||||
Timer {
|
||||
id: saveTimer
|
||||
interval: 1000
|
||||
repeat: false
|
||||
onTriggered: _doSaveUsageData()
|
||||
}
|
||||
|
||||
// Load usage data
|
||||
function _loadUsageData() {
|
||||
usageFile.reload();
|
||||
}
|
||||
|
||||
// Save usage data with debounce
|
||||
function _saveUsageData() {
|
||||
saveTimer.restart();
|
||||
}
|
||||
|
||||
// Actually save usage data to file
|
||||
function _doSaveUsageData() {
|
||||
try {
|
||||
const content = JSON.stringify(root.usageCounts);
|
||||
Quickshell.execDetached(["sh", "-c", `mkdir -p "$(dirname "${root.usageFilePath}")" && echo '${content}' > "${root.usageFilePath}"`]);
|
||||
} catch (e) {
|
||||
Logger.e("EmojiService", "Failed to save usage data: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Copies emoji to clipboard
|
||||
function copy(emojiChar) {
|
||||
if (emojiChar) {
|
||||
recordUsage(emojiChar); // Record usage before copying
|
||||
Quickshell.execDetached(["sh", "-c", `echo -n "${emojiChar}" | wl-copy`]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.UI
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
property string currentLayout: I18n.tr("common.unknown")
|
||||
property string fullLayoutName: I18n.tr("common.unknown")
|
||||
property string previousLayout: ""
|
||||
property bool isInitialized: false
|
||||
|
||||
// Updates current layout from various format strings. Called by compositors
|
||||
function setCurrentLayout(layoutString) {
|
||||
root.fullLayoutName = layoutString || I18n.tr("common.unknown");
|
||||
root.currentLayout = extractLayoutCode(layoutString);
|
||||
}
|
||||
|
||||
// Extract layout code from various format strings
|
||||
// Priority: variant > country code > language lookup > fallback
|
||||
function extractLayoutCode(layoutString) {
|
||||
if (!layoutString)
|
||||
return I18n.tr("common.unknown");
|
||||
|
||||
const str = layoutString.toLowerCase();
|
||||
|
||||
// If it's already a short code (2-3 chars), return uppercase
|
||||
if (/^[a-z]{2,3}(\+.*)?$/.test(str)) {
|
||||
return str.split('+')[0].toUpperCase();
|
||||
}
|
||||
|
||||
// Check for layout variants first - these are more meaningful than country codes
|
||||
// when distinguishing between multiple layouts of the same language
|
||||
for (const [pattern, display] of Object.entries(variantMap)) {
|
||||
if (str.includes(pattern)) {
|
||||
return display;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract short code from parentheses like "English (US)"
|
||||
const shortCodeMatch = str.match(/\(([a-z]{2,3})\)/i);
|
||||
if (shortCodeMatch) {
|
||||
return shortCodeMatch[1].toUpperCase();
|
||||
}
|
||||
|
||||
// Check for language/country names in the language map
|
||||
// First, try to match at the start of the string (the primary language)
|
||||
for (const [lang, code] of Object.entries(languageMap)) {
|
||||
if (str.startsWith(lang)) {
|
||||
return code.toUpperCase();
|
||||
}
|
||||
}
|
||||
// Then try word boundary matching anywhere in the string
|
||||
for (const [lang, code] of Object.entries(languageMap)) {
|
||||
const regex = new RegExp(`\\b${lang}\\b`);
|
||||
if (regex.test(str)) {
|
||||
return code.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing matches, try first 2-3 characters if they look like a code
|
||||
const codeMatch = str.match(/^([a-z]{2,3})/);
|
||||
return codeMatch ? codeMatch[1].toUpperCase() : I18n.tr("common.unknown");
|
||||
}
|
||||
|
||||
// Watch for layout changes and show toast
|
||||
onCurrentLayoutChanged: {
|
||||
// Update previousLayout after checking for changes
|
||||
const layoutChanged = isInitialized && currentLayout !== previousLayout && currentLayout !== I18n.tr("common.unknown") && previousLayout !== "" && previousLayout !== I18n.tr("common.unknown");
|
||||
|
||||
if (layoutChanged) {
|
||||
if (Settings.data.notifications.enableKeyboardLayoutToast) {
|
||||
const message = I18n.tr("toast.keyboard-layout.changed", {
|
||||
"layout": fullLayoutName
|
||||
});
|
||||
ToastService.showNotice(I18n.tr("toast.keyboard-layout.title"), message, "", 2000);
|
||||
}
|
||||
Logger.d("KeyboardLayout", "Layout changed from", previousLayout, "to", currentLayout);
|
||||
}
|
||||
|
||||
// Update previousLayout for next comparison
|
||||
previousLayout = currentLayout;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.i("KeyboardLayout", "Service started");
|
||||
// Mark as initialized after a delay to allow first layout update to complete
|
||||
// This prevents showing a toast on the initial load
|
||||
initializationTimer.start();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: initializationTimer
|
||||
interval: 2000 // Wait 2 seconds for first layout update to complete
|
||||
onTriggered: {
|
||||
isInitialized = true;
|
||||
// Set previousLayout to current value after initialization
|
||||
previousLayout = currentLayout;
|
||||
Logger.d("KeyboardLayout", "Service initialized, current layout:", currentLayout);
|
||||
}
|
||||
}
|
||||
|
||||
// Layout variants - checked BEFORE country codes
|
||||
// These display the variant name when it's more meaningful than the country
|
||||
property var variantMap: {
|
||||
// Alternative keyboard layouts
|
||||
"colemak": "Colemak",
|
||||
"dvorak": "Dvorak",
|
||||
"workman": "Workman",
|
||||
"programmer dvorak": "Dvk-P",
|
||||
"norman": "Norman",
|
||||
// International variants
|
||||
"intl": "Intl",
|
||||
"international": "Intl",
|
||||
"altgr-intl": "Intl",
|
||||
"with dead keys": "Dead",
|
||||
// Common variants
|
||||
"phonetic": "Phon",
|
||||
"extended": "Ext",
|
||||
"ergonomic": "Ergo",
|
||||
"legacy": "Legacy",
|
||||
// Input methods
|
||||
"pinyin": "Pinyin",
|
||||
"cangjie": "Cangjie",
|
||||
"romaji": "Romaji",
|
||||
"kana": "Kana"
|
||||
}
|
||||
|
||||
// Language/country name to ISO code mapping
|
||||
property var languageMap: {
|
||||
// English variants
|
||||
"english": "us",
|
||||
"american": "us",
|
||||
"united states": "us",
|
||||
"us english": "us",
|
||||
"british": "gb",
|
||||
"united kingdom": "gb",
|
||||
"english (uk)": "gb",
|
||||
"canadian": "ca",
|
||||
"canada": "ca",
|
||||
"canadian english": "ca",
|
||||
"australian": "au",
|
||||
"australia": "au",
|
||||
// Nordic countries
|
||||
"swedish": "se",
|
||||
"svenska": "se",
|
||||
"sweden": "se",
|
||||
"norwegian": "no",
|
||||
"norsk": "no",
|
||||
"norway": "no",
|
||||
"danish": "dk",
|
||||
"dansk": "dk",
|
||||
"denmark": "dk",
|
||||
"finnish": "fi",
|
||||
"suomi": "fi",
|
||||
"finland": "fi",
|
||||
"icelandic": "is",
|
||||
"íslenska": "is",
|
||||
"iceland": "is",
|
||||
// Western/Central European Germanic
|
||||
"german": "de",
|
||||
"deutsch": "de",
|
||||
"germany": "de",
|
||||
"austrian": "at",
|
||||
"austria": "at",
|
||||
"österreich": "at",
|
||||
"swiss": "ch",
|
||||
"switzerland": "ch",
|
||||
"schweiz": "ch",
|
||||
"suisse": "ch",
|
||||
"dutch": "nl",
|
||||
"nederlands": "nl",
|
||||
"netherlands": "nl",
|
||||
"holland": "nl",
|
||||
"belgian": "be",
|
||||
"belgium": "be",
|
||||
"belgië": "be",
|
||||
"belgique": "be",
|
||||
// Romance languages (Western/Southern Europe)
|
||||
"french": "fr",
|
||||
"français": "fr",
|
||||
"france": "fr",
|
||||
"canadian french": "ca",
|
||||
"spanish": "es",
|
||||
"español": "es",
|
||||
"spain": "es",
|
||||
"castilian": "es",
|
||||
"italian": "it",
|
||||
"italiano": "it",
|
||||
"italy": "it",
|
||||
"portuguese": "pt",
|
||||
"português": "pt",
|
||||
"portugal": "pt",
|
||||
"catalan": "ad",
|
||||
"català": "ad",
|
||||
"andorra": "ad",
|
||||
// Eastern European Romance
|
||||
"romanian": "ro",
|
||||
"română": "ro",
|
||||
"romania": "ro",
|
||||
// Slavic languages (Eastern Europe)
|
||||
"russian": "ru",
|
||||
"русский": "ru",
|
||||
"russia": "ru",
|
||||
"polish": "pl",
|
||||
"polski": "pl",
|
||||
"poland": "pl",
|
||||
"czech": "cz",
|
||||
"čeština": "cz",
|
||||
"czech republic": "cz",
|
||||
"slovak": "sk",
|
||||
"slovenčina": "sk",
|
||||
"slovakia": "sk",
|
||||
// Ukrainian
|
||||
"ukraine": "ua",
|
||||
"ukrainian": "ua",
|
||||
"українська": "ua",
|
||||
"bulgarian": "bg",
|
||||
"български": "bg",
|
||||
"bulgaria": "bg",
|
||||
"serbian": "rs",
|
||||
"srpski": "rs",
|
||||
"serbia": "rs",
|
||||
"croatian": "hr",
|
||||
"hrvatski": "hr",
|
||||
"croatia": "hr",
|
||||
"slovenian": "si",
|
||||
"slovenščina": "si",
|
||||
"slovenia": "si",
|
||||
"bosnian": "ba",
|
||||
"bosanski": "ba",
|
||||
"bosnia": "ba",
|
||||
"macedonian": "mk",
|
||||
"македонски": "mk",
|
||||
"macedonia": "mk",
|
||||
// Celtic languages (Western Europe)
|
||||
"irish": "ie",
|
||||
"gaeilge": "ie",
|
||||
"ireland": "ie",
|
||||
"welsh": "gb",
|
||||
"cymraeg": "gb",
|
||||
"wales": "gb",
|
||||
"scottish": "gb",
|
||||
"gàidhlig": "gb",
|
||||
"scotland": "gb",
|
||||
// Baltic languages (Northern Europe)
|
||||
"estonian": "ee",
|
||||
"eesti": "ee",
|
||||
"estonia": "ee",
|
||||
"latvian": "lv",
|
||||
"latviešu": "lv",
|
||||
"latvia": "lv",
|
||||
"lithuanian": "lt",
|
||||
"lietuvių": "lt",
|
||||
"lithuania": "lt",
|
||||
// Other European languages
|
||||
"hungarian": "hu",
|
||||
"magyar": "hu",
|
||||
"hungary": "hu",
|
||||
"greek": "gr",
|
||||
"ελληνικά": "gr",
|
||||
"greece": "gr",
|
||||
"albanian": "al",
|
||||
"shqip": "al",
|
||||
"albania": "al",
|
||||
"maltese": "mt",
|
||||
"malti": "mt",
|
||||
"malta": "mt",
|
||||
// West/Southwest Asian languages
|
||||
"turkish": "tr",
|
||||
"türkçe": "tr",
|
||||
"turkey": "tr",
|
||||
"arabic": "ar",
|
||||
"العربية": "ar",
|
||||
"arab": "ar",
|
||||
"hebrew": "il",
|
||||
"עברית": "il",
|
||||
"israel": "il",
|
||||
// South American languages
|
||||
"brazilian": "br",
|
||||
"brazilian portuguese": "br",
|
||||
"brasil": "br",
|
||||
"brazil": "br",
|
||||
// East Asian languages
|
||||
"japanese": "jp",
|
||||
"日本語": "jp",
|
||||
"japan": "jp",
|
||||
"korean": "kr",
|
||||
"한국어": "kr",
|
||||
"korea": "kr",
|
||||
"south korea": "kr",
|
||||
"chinese": "cn",
|
||||
"中文": "cn",
|
||||
"china": "cn",
|
||||
"simplified chinese": "cn",
|
||||
"traditional chinese": "tw",
|
||||
"taiwan": "tw",
|
||||
"繁體中文": "tw",
|
||||
// Southeast Asian languages
|
||||
"thai": "th",
|
||||
"ไทย": "th",
|
||||
"thailand": "th",
|
||||
"vietnamese": "vn",
|
||||
"tiếng việt": "vn",
|
||||
"vietnam": "vn",
|
||||
// South Asian languages
|
||||
"hindi": "in",
|
||||
"हिन्दी": "in",
|
||||
"india": "in",
|
||||
// African languages
|
||||
"afrikaans": "za",
|
||||
"south africa": "za",
|
||||
"south african": "za"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
pragma Singleton
|
||||
import Qt.labs.folderlistmodel 2.10
|
||||
import QtQml.Models
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Component registration - only poll when something needs lock key state
|
||||
function registerComponent(componentId) {
|
||||
root._registered[componentId] = true;
|
||||
root._registered = Object.assign({}, root._registered);
|
||||
Logger.d("LockKeys", "Component registered:", componentId, "- total:", root._registeredCount);
|
||||
}
|
||||
|
||||
function unregisterComponent(componentId) {
|
||||
delete root._registered[componentId];
|
||||
root._registered = Object.assign({}, root._registered);
|
||||
Logger.d("LockKeys", "Component unregistered:", componentId, "- total:", root._registeredCount);
|
||||
}
|
||||
|
||||
property var _registered: ({})
|
||||
readonly property int _registeredCount: Object.keys(_registered).length
|
||||
readonly property bool shouldRun: _registeredCount > 0
|
||||
|
||||
property bool capsLockOn: false
|
||||
property bool numLockOn: false
|
||||
property bool scrollLockOn: false
|
||||
|
||||
signal capsLockChanged(bool active)
|
||||
signal numLockChanged(bool active)
|
||||
signal scrollLockChanged(bool active)
|
||||
|
||||
Instantiator {
|
||||
model: FolderListModel {
|
||||
id: folderModel
|
||||
folder: Qt.resolvedUrl("/sys/class/leds")
|
||||
showFiles: false
|
||||
showOnlyReadable: true
|
||||
}
|
||||
delegate: Component {
|
||||
FileView {
|
||||
id: fileView
|
||||
path: filePath + "/brightness"
|
||||
// sysfs brightness can fail transiently (e.g. resume); omit console spam like other sysfs FileViews.
|
||||
printErrors: false
|
||||
watchChanges: false
|
||||
|
||||
function parseBrightnessLedOn(raw) {
|
||||
var t = raw.trim();
|
||||
if (t === "" || !/^[0-9]+$/.test(t))
|
||||
return null;
|
||||
return parseInt(t, 10) !== 0;
|
||||
}
|
||||
|
||||
function applyLockState(kind, state, emitIfChanged) {
|
||||
switch (kind) {
|
||||
case "numlock":
|
||||
if (emitIfChanged && root.numLockOn !== state) {
|
||||
root.numLockOn = state;
|
||||
root.numLockChanged(state);
|
||||
Logger.i("LockKeysService", "Num Lock:", state, fileView.path);
|
||||
} else if (!emitIfChanged) {
|
||||
root.numLockOn = state;
|
||||
}
|
||||
break;
|
||||
case "capslock":
|
||||
if (emitIfChanged && root.capsLockOn !== state) {
|
||||
root.capsLockOn = state;
|
||||
root.capsLockChanged(state);
|
||||
Logger.i("LockKeysService", "Caps Lock:", state, fileView.path);
|
||||
} else if (!emitIfChanged) {
|
||||
root.capsLockOn = state;
|
||||
}
|
||||
break;
|
||||
case "scrolllock":
|
||||
if (emitIfChanged && root.scrollLockOn !== state) {
|
||||
root.scrollLockOn = state;
|
||||
root.scrollLockChanged(state);
|
||||
Logger.i("LockKeysService", "Scroll Lock:", state, fileView.path);
|
||||
} else if (!emitIfChanged) {
|
||||
root.scrollLockOn = state;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only apply after a successful read — failed reloads must not update UI (empty text is not "off").
|
||||
onLoaded: {
|
||||
if (!fileView.isWanted)
|
||||
return;
|
||||
var state = fileView.parseBrightnessLedOn(fileView.text());
|
||||
if (state === null)
|
||||
return;
|
||||
|
||||
var kind = fileName.split("::")[1];
|
||||
|
||||
// First read after polling starts: sync bar/UI from sysfs without firing
|
||||
// *Changed signals (OSD listens to those and would flash on startup).
|
||||
if (!fileView.initialCheckDone) {
|
||||
fileView.initialCheckDone = true;
|
||||
fileView.applyLockState(kind, state, false);
|
||||
return;
|
||||
}
|
||||
|
||||
fileView.applyLockState(kind, state, true);
|
||||
}
|
||||
|
||||
// FolderListModel only provides filters for file names, not folders
|
||||
property bool isWanted: {
|
||||
if (fileName.startsWith("input") && fileName.includes("::")) {
|
||||
switch (fileName.split("::")[1]) {
|
||||
case "numlock":
|
||||
case "capslock":
|
||||
case "scrolllock":
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// After shouldRun becomes true, first brightness read updates properties only (no *Changed signals).
|
||||
property bool initialCheckDone: false
|
||||
property variant connections: Connections {
|
||||
target: root
|
||||
function onShouldRunChanged() {
|
||||
if (root.shouldRun) {
|
||||
fileView.initialCheckDone = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sysfs does not provide change notifications
|
||||
property variant refreshTimer: Timer {
|
||||
interval: 200
|
||||
running: root.shouldRun && fileView.isWanted
|
||||
repeat: true
|
||||
onTriggered: fileView.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.i("LockKeysService", "Service started (polling deferred until a consumer registers).");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user