diff --git a/ags/scripts/clipboard.ts b/ags/scripts/clipboard.ts new file mode 100644 index 0000000..b4a9804 --- /dev/null +++ b/ags/scripts/clipboard.ts @@ -0,0 +1,232 @@ +import { AstalIO, execAsync, Gio, GLib, GObject, monitorFile, property, readFile, register, signal, timeout } from "astal"; + + +export enum ClipboardItemType { + TEXT = 0, + IMAGE = 1 +} + +export type ClipboardItem = { + id: number; + type: ClipboardItemType; + preview: string; +} + +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; + + #dbFile: Gio.File; + #dbMonitor: Gio.FileMonitor; + #updateDone: boolean = false; + #history = new Array; + #changesTimeout: (AstalIO.Time|undefined); + #ignoreChanges: boolean = false; + + @signal(Object) + declare copied: () => ClipboardItem; + + @signal() + declare wiped: () => void; + + + @property() + 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 { + await execAsync(`wl-copy "${content}"`).catch((err: Gio.IOErrorEnum) => { + console.error(`Clipboard: Couldn't copy text using wl-copy. Stderr:\n\t${err.message + } | Stack:\n\t\t${err.stack}`); + }); + } + + 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; + } +}