import { timeout } from "ags/time"; import { monitorFile, readFile } from "ags/file"; import { execAsync } from "ags/process"; import GObject, { getter, register, signal } from "ags/gobject"; import AstalIO from "gi://AstalIO"; import GLib from "gi://GLib?version=2.0"; import Gio from "gi://Gio?version=2.0"; export enum ClipboardItemType { TEXT = 0, IMAGE = 1 } export class ClipboardItem { id: number; type: ClipboardItemType; preview: string; constructor(id: number, type: ClipboardItemType, preview: string) { this.id = id; this.type = type; this.preview = preview; } } export { Clipboard }; /** Cliphist Manager and event listener * This only supports wipe and store events from cliphist */ @register({ GTypeName: "Clipboard" }) class Clipboard extends GObject.Object { private static instance: Clipboard; declare $signals: GObject.Object.SignalSignatures & { "copied": Clipboard["copied"]; "wiped": Clipboard["wiped"]; }; #dbFile: Gio.File; #dbMonitor: Gio.FileMonitor; #updateDone: boolean = false; #history = new Array; #changesTimeout: (AstalIO.Time|undefined); #ignoreChanges: boolean = false; @signal(GObject.TYPE_JSOBJECT) copied(_item: object) {} @signal() wiped() {}; @getter(Array) public get history() { return this.#history; } constructor() { super(); this.#dbFile = this.getCliphistDatabase(); this.#dbMonitor = monitorFile(this.#dbFile.get_path()!, () => { if(this.#ignoreChanges || this.#changesTimeout) return; this.#changesTimeout = timeout(300, () => this.#changesTimeout = undefined); if(this.#updateDone) { this.updateDatabase(); return; } this.init(); }); if(this.#dbFile.query_exists(null)) { this.init(); return; } console.log("Clipboard: cliphist database not found. Try copying something first!"); } vfunc_dispose(): void { this.#dbMonitor.cancel(); this.#dbMonitor.unref(); } private init() { console.log("Clipboard: Starting to read cliphist history..."); this.updateDatabase().then(() => { console.log("Clipboard: Done reading cliphist history!"); }).catch((err) => console.error(`Clipboard: An error occurred while reading cliphist history. Stderr: ${err}`) ); } public async copyAsync(content: string): Promise { const proc = Gio.Subprocess.new( ["wl-copy", content], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE ); const stderr = Gio.DataInputStream.new(proc.get_stderr_pipe()!); if(!proc.wait_check(null)) { try { const [err, ] = stderr.read_upto('\x00', -1); console.error(`Clipboard: An error occurred while copying text. Stderr: ${err}`); } catch(_) { console.error(`Clipboard: An error occurred while copying text and shell couldn't read \ stderr for more info.`); } } return proc.get_exit_status() === 0; } public async selectItem(itemToSelect: number|ClipboardItem): Promise { const item = await this.getItemContent(itemToSelect); let res: boolean = true; if(item) await this.copyAsync(item).catch(() => res = false); return res; } /** Gets history item's content by its ID. * @returns the clipboard item's content */ public async getItemContent(item: number|ClipboardItem): Promise { const id = (typeof item === "number") ? item : item.id; const cmd = Gio.Subprocess.new([ "cliphist", "decode", id.toString() ], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); const [ , stdout, stderr ] = cmd.communicate_utf8(null, null); if(stderr) { console.error(`Clipboard: An error occurred while getting item content. Stderr:\n${stderr}`); return; } return stdout; } /** Searches for the cliphist database file * Will not work if cliphist config file is not on default path */ private getCliphistDatabase(): Gio.File { // Check if env variable is set const path = GLib.getenv("CLIPHIST_DB_PATH"); if(path != null) return Gio.File.new_for_path(path); // Check config file const confFile = Gio.File.new_for_path(`${GLib.get_user_config_dir()}/cliphist/config`); if(confFile.query_exists(null)) { const cliphistConf = readFile(confFile.get_path()!); for(const line of cliphistConf.split('\n').map(l => l.trim())) { if(line.startsWith('#')) continue; const [ key, value ] = line.split('\s', 1); if(key === "db-path") { return Gio.File.new_for_path(value.trimStart()); } } } // return default path if none of the above matches return Gio.File.new_for_path(`${GLib.get_user_cache_dir()}/cliphist/db`); } private getContentType(preview: string): ClipboardItemType { return /^\[\[.*binary data.*x.*\]\]$/u.test(preview) ? ClipboardItemType.IMAGE : ClipboardItemType.TEXT; } public async wipeHistory(noExec?: boolean): Promise { if(noExec) { this.#history = []; this.emit("wiped"); return; } this.#ignoreChanges = true; await execAsync("cliphist wipe").then(() => { this.#history = []; this.emit("wiped"); }).catch((err: Gio.IOErrorEnum) => console.error(`Clipboard: An error occurred on cliphist database wipe. Stderr: ${ err.message ? `${err.message}\n` : ""}${err.stack}`) ).finally(() => this.#ignoreChanges = false); } public async updateDatabase(): Promise { const proc = Gio.Subprocess.new([ "cliphist", "list" ], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); proc.communicate_utf8_async(null, null, (_, asyncRes) => { const [ success, stdout, stderr ] = proc.communicate_utf8_finish(asyncRes); if(!success || stderr) { console.error("Clipboard: Couldn't communicate with cliphist! Is it installed?"); return; } if(!stdout.trim()) { this.wipeHistory(true); this.notify("history"); return; } const items = stdout.split('\n'); if(this.#updateDone) { const [ id, preview ] = items[0].split('\t'); const clipItem = { id: Number.parseInt(id), preview, type: this.getContentType(preview) } as ClipboardItem; this.#history.unshift(clipItem); this.emit("copied", clipItem); this.notify("history"); return; } for(const item of items) { if(!item) continue; const [ id, preview ] = item.split('\t'); const clipItem = { id: Number.parseInt(id), preview, type: this.getContentType(preview) } as ClipboardItem; this.#history.push(clipItem); this.emit("copied", clipItem); this.notify("history"); } this.#updateDone = true; }); } public static getDefault(): Clipboard { if(!this.instance) this.instance = new Clipboard(); return this.instance; } }