Files

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();
}
}