[gnome] Add clipboard manager extension
This commit is contained in:
@ -0,0 +1,512 @@
|
||||
import GLib from 'gi://GLib';
|
||||
import Gio from 'gi://Gio';
|
||||
import * as DS from './dataStructures.js';
|
||||
|
||||
let EXTENSION_UUID;
|
||||
let CACHE_DIR;
|
||||
const OLD_REGISTRY_FILE = GLib.build_filenamev([
|
||||
GLib.get_user_cache_dir(),
|
||||
'clipboard-indicator@tudmotu.com',
|
||||
'registry.txt',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Stores our compacting log implementation. Here are its key ideas:
|
||||
* - We only ever append to the log.
|
||||
* - This means there will be operations that cancel each other out. These are wasted/useless ops
|
||||
* that must be occasionally pruned. MAX_WASTED_OPS limits the number of useless ops.
|
||||
* - The available operations are listed in the OP_TYPE_* constants.
|
||||
* - An add op never moves (until compaction), allowing us to derive globally unique entry IDs based
|
||||
* on the order in which these add ops are discovered.
|
||||
*/
|
||||
let DATABASE_FILE;
|
||||
const BYTE_ORDER = Gio.DataStreamByteOrder.LITTLE_ENDIAN;
|
||||
|
||||
// Don't use zero b/c DataInputStream uses 0 as its error value
|
||||
const OP_TYPE_SAVE_TEXT = 1;
|
||||
const OP_TYPE_DELETE_TEXT = 2;
|
||||
const OP_TYPE_FAVORITE_ITEM = 3;
|
||||
const OP_TYPE_UNFAVORITE_ITEM = 4;
|
||||
const OP_TYPE_MOVE_ITEM_TO_END = 5;
|
||||
|
||||
const MAX_WASTED_OPS = 500;
|
||||
let uselessOpCount;
|
||||
|
||||
let opQueue = new DS.LinkedList();
|
||||
let opInProgress = false;
|
||||
let writeStream;
|
||||
|
||||
export function init(uuid) {
|
||||
EXTENSION_UUID = uuid;
|
||||
CACHE_DIR = GLib.build_filenamev([GLib.get_user_cache_dir(), EXTENSION_UUID]);
|
||||
DATABASE_FILE = GLib.build_filenamev([CACHE_DIR, 'database.log']);
|
||||
|
||||
if (GLib.mkdir_with_parents(CACHE_DIR, 0o775) !== 0) {
|
||||
console.log(
|
||||
EXTENSION_UUID,
|
||||
"Failed to create cache dir, extension likely won't work",
|
||||
CACHE_DIR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function destroy() {
|
||||
_pushToOpQueue((resolve) => {
|
||||
if (writeStream) {
|
||||
writeStream.close_async(0, null, (src, res) => {
|
||||
src.close_finish(res);
|
||||
resolve();
|
||||
});
|
||||
writeStream = undefined;
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function buildClipboardStateFromLog(callback) {
|
||||
if (typeof callback !== 'function') {
|
||||
throw TypeError('`callback` must be a function');
|
||||
}
|
||||
uselessOpCount = 0;
|
||||
|
||||
Gio.File.new_for_path(DATABASE_FILE).read_async(0, null, (src, res) => {
|
||||
try {
|
||||
_parseLog(src.read_finish(res), callback);
|
||||
} catch (e) {
|
||||
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) {
|
||||
_readAndConsumeOldFormat(callback);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _parseLog(stream, callback) {
|
||||
stream = Gio.DataInputStream.new(stream);
|
||||
stream.set_byte_order(BYTE_ORDER);
|
||||
|
||||
const state = {
|
||||
entries: new DS.LinkedList(),
|
||||
favorites: new DS.LinkedList(),
|
||||
nextId: 1,
|
||||
};
|
||||
_consumeStream(stream, state, callback);
|
||||
}
|
||||
|
||||
function _consumeStream(stream, state, callback) {
|
||||
const finish = () => {
|
||||
callback(state.entries, state.favorites, state.nextId);
|
||||
};
|
||||
const forceFill = (minBytes, fillCallback) => {
|
||||
stream.fill_async(/*count=*/ -1, 0, null, (src, res) => {
|
||||
if (src.fill_finish(res) < minBytes) {
|
||||
finish();
|
||||
} else {
|
||||
fillCallback();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let parseAvailableAware;
|
||||
|
||||
function loop() {
|
||||
if (stream.get_available() === 0) {
|
||||
forceFill(1, loop);
|
||||
return;
|
||||
}
|
||||
|
||||
const opType = stream.read_byte(null);
|
||||
if (opType === OP_TYPE_SAVE_TEXT) {
|
||||
stream.read_upto_async(
|
||||
/*stop_chars=*/ '\0',
|
||||
/*stop_chars_len=*/ 1,
|
||||
0,
|
||||
null,
|
||||
(src, res) => {
|
||||
const [text] = src.read_upto_finish(res);
|
||||
src.read_byte(null);
|
||||
|
||||
const node = new DS.LLNode();
|
||||
node.diskId = node.id = state.nextId++;
|
||||
node.type = DS.TYPE_TEXT;
|
||||
node.text = text || '';
|
||||
node.favorite = false;
|
||||
state.entries.append(node);
|
||||
|
||||
loop();
|
||||
},
|
||||
);
|
||||
} else if (opType === OP_TYPE_DELETE_TEXT) {
|
||||
uselessOpCount += 2;
|
||||
parseAvailableAware(4, () => {
|
||||
const id = stream.read_uint32(null);
|
||||
(state.entries.findById(id) || state.favorites.findById(id)).detach();
|
||||
});
|
||||
} else if (opType === OP_TYPE_FAVORITE_ITEM) {
|
||||
parseAvailableAware(4, () => {
|
||||
const id = stream.read_uint32(null);
|
||||
const entry = state.entries.findById(id);
|
||||
|
||||
entry.favorite = true;
|
||||
state.favorites.append(entry);
|
||||
});
|
||||
} else if (opType === OP_TYPE_UNFAVORITE_ITEM) {
|
||||
uselessOpCount += 2;
|
||||
parseAvailableAware(4, () => {
|
||||
const id = stream.read_uint32(null);
|
||||
const entry = state.favorites.findById(id);
|
||||
|
||||
entry.favorite = false;
|
||||
state.entries.append(entry);
|
||||
});
|
||||
} else if (opType === OP_TYPE_MOVE_ITEM_TO_END) {
|
||||
uselessOpCount++;
|
||||
parseAvailableAware(4, () => {
|
||||
const id = stream.read_uint32(null);
|
||||
const entry =
|
||||
state.entries.findById(id) || state.favorites.findById(id);
|
||||
|
||||
if (entry.favorite) {
|
||||
state.favorites.append(entry);
|
||||
} else {
|
||||
state.entries.append(entry);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(EXTENSION_UUID, 'Unknown op type, aborting load.', opType);
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
parseAvailableAware = (minBytes, parse) => {
|
||||
const safeParse = (cont) => {
|
||||
try {
|
||||
parse();
|
||||
cont();
|
||||
} catch (e) {
|
||||
console.log(EXTENSION_UUID, 'Parsing error');
|
||||
console.error(e);
|
||||
|
||||
const entries = new DS.LinkedList();
|
||||
let nextId = 1;
|
||||
const addEntry = (text) => {
|
||||
const node = new DS.LLNode();
|
||||
node.id = nextId++;
|
||||
node.type = DS.TYPE_TEXT;
|
||||
node.text = text;
|
||||
node.favorite = false;
|
||||
entries.prepend(node);
|
||||
};
|
||||
|
||||
addEntry('Your clipboard data has been corrupted and was moved to:');
|
||||
addEntry('~/.cache/clipboard-history@alexsaveau.dev/corrupted.log');
|
||||
addEntry('Please file a bug report at:');
|
||||
addEntry(
|
||||
'https://github.com/SUPERCILEX/gnome-clipboard-history/issues/new?assignees=&labels=bug&template=1-bug.md',
|
||||
);
|
||||
|
||||
try {
|
||||
if (
|
||||
!Gio.File.new_for_path(DATABASE_FILE).move(
|
||||
Gio.File.new_for_path(
|
||||
GLib.build_filenamev([CACHE_DIR, 'corrupted.log']),
|
||||
),
|
||||
Gio.FileCopyFlags.OVERWRITE,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
) {
|
||||
console.log(EXTENSION_UUID, 'Failed to move database file');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(EXTENSION_UUID, 'Crash moving database file');
|
||||
console.error(e);
|
||||
}
|
||||
callback(entries, new DS.LinkedList(), nextId, 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (stream.get_available() < minBytes) {
|
||||
forceFill(minBytes, () => {
|
||||
safeParse(loop);
|
||||
});
|
||||
} else {
|
||||
safeParse(loop);
|
||||
}
|
||||
};
|
||||
|
||||
loop();
|
||||
}
|
||||
|
||||
function _readAndConsumeOldFormat(callback) {
|
||||
Gio.File.new_for_path(OLD_REGISTRY_FILE).load_contents_async(
|
||||
null,
|
||||
(src, res) => {
|
||||
const entries = new DS.LinkedList();
|
||||
const favorites = new DS.LinkedList();
|
||||
let id = 1;
|
||||
|
||||
let contents;
|
||||
try {
|
||||
[, contents] = src.load_contents_finish(res);
|
||||
} catch (e) {
|
||||
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) {
|
||||
callback(entries, favorites, id);
|
||||
return;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
let registry = [];
|
||||
try {
|
||||
registry = JSON.parse(GLib.ByteArray.toString(contents));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
for (const entry of registry) {
|
||||
const node = new DS.LLNode();
|
||||
|
||||
node.diskId = node.id = id;
|
||||
node.type = DS.TYPE_TEXT;
|
||||
if (typeof entry === 'string') {
|
||||
node.text = entry;
|
||||
node.favorite = false;
|
||||
|
||||
entries.append(node);
|
||||
} else {
|
||||
node.text = entry.contents;
|
||||
node.favorite = entry.favorite;
|
||||
|
||||
favorites.append(node);
|
||||
}
|
||||
|
||||
id++;
|
||||
}
|
||||
|
||||
resetDatabase(() => entries.toArray().concat(favorites.toArray()));
|
||||
Gio.File.new_for_path(OLD_REGISTRY_FILE).trash_async(
|
||||
0,
|
||||
null,
|
||||
(src, res) => {
|
||||
src.trash_finish(res);
|
||||
},
|
||||
);
|
||||
|
||||
callback(entries, favorites, id);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function maybePerformLogCompaction(currentStateBuilder) {
|
||||
if (uselessOpCount >= MAX_WASTED_OPS) {
|
||||
resetDatabase(currentStateBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetDatabase(currentStateBuilder) {
|
||||
uselessOpCount = 0;
|
||||
|
||||
const state = currentStateBuilder();
|
||||
_pushToOpQueue((resolve) => {
|
||||
// Sigh, can't use truncate because it doesn't have an async variant. Instead, nuke the stream
|
||||
// and let the next append re-create it. Note that we can't use this stream because it tries to
|
||||
// apply our operations atomically and therefore writes to a temporary file instead of the one
|
||||
// we asked for.
|
||||
writeStream = undefined;
|
||||
|
||||
const priority = -10;
|
||||
Gio.File.new_for_path(DATABASE_FILE).replace_async(
|
||||
/*etag=*/ null,
|
||||
/*make_backup=*/ false,
|
||||
Gio.FileCreateFlags.PRIVATE,
|
||||
priority,
|
||||
null,
|
||||
(src, res) => {
|
||||
const stream = _intoDataStream(src.replace_finish(res));
|
||||
const finish = () => {
|
||||
stream.close_async(priority, null, (src, res) => {
|
||||
src.close_finish(res);
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
|
||||
if (state.length === 0) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
_writeToStream(stream, priority, finish, (dataStream) => {
|
||||
do {
|
||||
const entry = state[i];
|
||||
|
||||
if (entry.type === DS.TYPE_TEXT) {
|
||||
_storeTextOp(entry.text)(dataStream);
|
||||
} else {
|
||||
throw new TypeError('Unknown type: ' + entry.type);
|
||||
}
|
||||
if (entry.favorite) {
|
||||
_updateFavoriteStatusOp(entry.diskId, true)(dataStream);
|
||||
}
|
||||
|
||||
i++;
|
||||
} while (i % 1000 !== 0 && i < state.length);
|
||||
|
||||
// Flush the buffer every 1000 entries
|
||||
return i >= state.length;
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function storeTextEntry(text) {
|
||||
_appendBytesToLog(_storeTextOp(text), -5);
|
||||
}
|
||||
|
||||
function _storeTextOp(text) {
|
||||
return (dataStream) => {
|
||||
dataStream.put_byte(OP_TYPE_SAVE_TEXT, null);
|
||||
dataStream.put_string(text, null);
|
||||
dataStream.put_byte(0, null); // NUL terminator
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteTextEntry(id, isFavorite) {
|
||||
_appendBytesToLog(_deleteTextOp(id), 5);
|
||||
uselessOpCount += 2;
|
||||
if (isFavorite) {
|
||||
uselessOpCount++;
|
||||
}
|
||||
}
|
||||
|
||||
function _deleteTextOp(id) {
|
||||
return (dataStream) => {
|
||||
dataStream.put_byte(OP_TYPE_DELETE_TEXT, null);
|
||||
dataStream.put_uint32(id, null);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function updateFavoriteStatus(id, favorite) {
|
||||
_appendBytesToLog(_updateFavoriteStatusOp(id, favorite));
|
||||
|
||||
if (!favorite) {
|
||||
uselessOpCount += 2;
|
||||
}
|
||||
}
|
||||
|
||||
function _updateFavoriteStatusOp(id, favorite) {
|
||||
return (dataStream) => {
|
||||
dataStream.put_byte(
|
||||
favorite ? OP_TYPE_FAVORITE_ITEM : OP_TYPE_UNFAVORITE_ITEM,
|
||||
null,
|
||||
);
|
||||
dataStream.put_uint32(id, null);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function moveEntryToEnd(id) {
|
||||
_appendBytesToLog(_moveToEndOp(id));
|
||||
uselessOpCount++;
|
||||
}
|
||||
|
||||
function _moveToEndOp(id) {
|
||||
return (dataStream) => {
|
||||
dataStream.put_byte(OP_TYPE_MOVE_ITEM_TO_END, null);
|
||||
dataStream.put_uint32(id, null);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function _appendBytesToLog(callback, priority) {
|
||||
priority = priority || 0;
|
||||
_pushToOpQueue((resolve) => {
|
||||
const runUnsafe = () => {
|
||||
_writeToStream(writeStream, priority, resolve, callback);
|
||||
};
|
||||
|
||||
if (writeStream === undefined) {
|
||||
Gio.File.new_for_path(DATABASE_FILE).append_to_async(
|
||||
Gio.FileCreateFlags.PRIVATE,
|
||||
priority,
|
||||
null,
|
||||
(src, res) => {
|
||||
writeStream = _intoDataStream(src.append_to_finish(res));
|
||||
runUnsafe();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
runUnsafe();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _writeToStream(stream, priority, resolve, callback) {
|
||||
_writeCallbackBytesAsyncHack(callback, stream, priority, () => {
|
||||
stream.flush_async(priority, null, (src, res) => {
|
||||
src.flush_finish(res);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This garbage code is here to keep disk writes off the main thread. DataOutputStream doesn't have
|
||||
* async method variants, so we write to a memory buffer and then flush it asynchronously. We're
|
||||
* basically trying to balance memory allocations with disk writes.
|
||||
*/
|
||||
function _writeCallbackBytesAsyncHack(
|
||||
dataCallback,
|
||||
stream,
|
||||
priority,
|
||||
callback,
|
||||
) {
|
||||
if (dataCallback(stream)) {
|
||||
callback();
|
||||
} else {
|
||||
stream.flush_async(priority, null, (src, res) => {
|
||||
src.flush_finish(res);
|
||||
_writeCallbackBytesAsyncHack(dataCallback, stream, priority, callback);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _intoDataStream(stream) {
|
||||
const bufStream = Gio.BufferedOutputStream.new(stream);
|
||||
bufStream.set_auto_grow(true); // Blocks flushing, needed for hack
|
||||
const ioStream = Gio.DataOutputStream.new(bufStream);
|
||||
ioStream.set_byte_order(BYTE_ORDER);
|
||||
return ioStream;
|
||||
}
|
||||
|
||||
function _pushToOpQueue(op) {
|
||||
const consumeOp = () => {
|
||||
const resolve = () => {
|
||||
opInProgress = false;
|
||||
|
||||
const next = opQueue.head;
|
||||
if (next) {
|
||||
next.detach();
|
||||
next.op();
|
||||
}
|
||||
};
|
||||
|
||||
opInProgress = true;
|
||||
op(resolve);
|
||||
};
|
||||
|
||||
if (opInProgress) {
|
||||
const node = new DS.LLNode();
|
||||
node.op = consumeOp;
|
||||
opQueue.append(node);
|
||||
} else {
|
||||
consumeOp();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user