513 lines
13 KiB
JavaScript
513 lines
13 KiB
JavaScript
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();
|
|
}
|
|
}
|