Add signed extension release workflow
This commit is contained in:
1 parent
6badceaf46
commit
9e997463da
10 files changed
+976
-38
No files matched your search
+218
-34
@@ -1,16 +1,144 @@
|
||||
import { CONFIG } from "./config.js";
|
||||
|
||||
const HOST = "si.komelt.local_history_archive";
|
||||
const QUEUE_KEY = "pendingVisits";
|
||||
const LEGACY_QUEUE_KEY = "pendingVisits";
|
||||
const LOCAL_QUEUE_KEY = "pendingLocalVisits";
|
||||
const REMOTE_QUEUE_KEY = "pendingRemoteVisits";
|
||||
const MAX_QUEUE = 2000;
|
||||
const MAX_REMOTE_URL_LENGTH = 2083;
|
||||
|
||||
async function queued() {
|
||||
const { [QUEUE_KEY]: visits = [] } = await chrome.storage.local.get(QUEUE_KEY);
|
||||
return visits;
|
||||
let operationTail = Promise.resolve();
|
||||
let runtimeSettings = CONFIG;
|
||||
let flushInProgress = false;
|
||||
let flushRequested = false;
|
||||
let activeFlush = null;
|
||||
|
||||
async function refreshRuntimeSettings() {
|
||||
try {
|
||||
const managed = await chrome.storage.managed.get(["collectorUrl", "apiToken", "deviceId"]);
|
||||
runtimeSettings = {
|
||||
collectorUrl: typeof managed.collectorUrl === "string" ? managed.collectorUrl : CONFIG.collectorUrl,
|
||||
apiToken: typeof managed.apiToken === "string" ? managed.apiToken : CONFIG.apiToken,
|
||||
deviceId: typeof managed.deviceId === "string" ? managed.deviceId : CONFIG.deviceId
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("Managed extension settings unavailable", error?.message || error);
|
||||
}
|
||||
}
|
||||
async function persist(visits) {
|
||||
await chrome.storage.local.set({ [QUEUE_KEY]: visits.slice(-MAX_QUEUE) });
|
||||
|
||||
function runSerialized(operation) {
|
||||
const next = operationTail.then(operation, operation);
|
||||
operationTail = next.catch((error) => {
|
||||
console.warn("Queued operation failed", error?.message || error);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function capped(visits) {
|
||||
return visits.slice(-MAX_QUEUE);
|
||||
}
|
||||
|
||||
async function readQueues() {
|
||||
const values = await chrome.storage.local.get([
|
||||
LEGACY_QUEUE_KEY,
|
||||
LOCAL_QUEUE_KEY,
|
||||
REMOTE_QUEUE_KEY
|
||||
]);
|
||||
return {
|
||||
legacy: Array.isArray(values[LEGACY_QUEUE_KEY]) ? values[LEGACY_QUEUE_KEY] : [],
|
||||
local: Array.isArray(values[LOCAL_QUEUE_KEY]) ? values[LOCAL_QUEUE_KEY] : [],
|
||||
remote: Array.isArray(values[REMOTE_QUEUE_KEY]) ? values[REMOTE_QUEUE_KEY] : []
|
||||
};
|
||||
}
|
||||
|
||||
async function snapshotQueues() {
|
||||
return runSerialized(async () => {
|
||||
const { legacy, local, remote } = await readQueues();
|
||||
const localVisits = capped(legacy.length ? [...legacy, ...local] : local);
|
||||
const remoteVisits = capped(
|
||||
legacy.length && remoteEnabled()
|
||||
? [...legacy.filter(isRemoteVisit), ...remote]
|
||||
: remote
|
||||
);
|
||||
|
||||
if (
|
||||
legacy.length
|
||||
|| localVisits.length !== local.length
|
||||
|| remoteVisits.length !== remote.length
|
||||
) {
|
||||
const update = {
|
||||
[LOCAL_QUEUE_KEY]: localVisits,
|
||||
[REMOTE_QUEUE_KEY]: remoteVisits
|
||||
};
|
||||
if (legacy.length) update[LEGACY_QUEUE_KEY] = [];
|
||||
await chrome.storage.local.set(update);
|
||||
}
|
||||
|
||||
return { localVisits, remoteVisits };
|
||||
});
|
||||
}
|
||||
|
||||
function sameQueueItem(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function deliveredItemsStillQueued(current, snapshot, offset, deliveredCount) {
|
||||
const confirmed = snapshot.slice(offset, offset + deliveredCount);
|
||||
if (confirmed.length !== deliveredCount || current.length < deliveredCount) return 0;
|
||||
for (let index = 0; index < deliveredCount; index += 1) {
|
||||
if (!sameQueueItem(current[index], confirmed[index])) return 0;
|
||||
}
|
||||
return deliveredCount;
|
||||
}
|
||||
|
||||
async function acknowledgeQueuePrefix(key, snapshot, offset, deliveredCount) {
|
||||
if (!deliveredCount) return;
|
||||
try {
|
||||
await runSerialized(async () => {
|
||||
const { [key]: current = [] } = await chrome.storage.local.get(key);
|
||||
const visits = Array.isArray(current) ? current : [];
|
||||
const removeCount = deliveredItemsStillQueued(visits, snapshot, offset, deliveredCount);
|
||||
if (removeCount) {
|
||||
await chrome.storage.local.set({ [key]: capped(visits.slice(removeCount)) });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Could not acknowledge queued visits", error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
function remoteEnabled() {
|
||||
return Boolean(runtimeSettings.collectorUrl && runtimeSettings.apiToken);
|
||||
}
|
||||
function isRemoteVisit(visit) {
|
||||
return isHttpUrl(visit?.url) && visit.url.length <= MAX_REMOTE_URL_LENGTH;
|
||||
}
|
||||
|
||||
async function enqueueVisit(visit) {
|
||||
await refreshRuntimeSettings();
|
||||
try {
|
||||
await runSerialized(async () => {
|
||||
const { legacy, local, remote } = await readQueues();
|
||||
const localVisits = [...(legacy.length ? legacy : []), ...local, visit];
|
||||
const remoteVisits = [
|
||||
...(legacy.length && remoteEnabled() ? legacy.filter(isRemoteVisit) : []),
|
||||
...remote
|
||||
];
|
||||
if (remoteEnabled() && isRemoteVisit(visit)) remoteVisits.push(visit);
|
||||
|
||||
const update = {
|
||||
[LOCAL_QUEUE_KEY]: capped(localVisits),
|
||||
[REMOTE_QUEUE_KEY]: capped(remoteVisits)
|
||||
};
|
||||
if (legacy.length) update[LEGACY_QUEUE_KEY] = [];
|
||||
await chrome.storage.local.set(update);
|
||||
});
|
||||
requestFlush();
|
||||
} catch (error) {
|
||||
console.warn("Could not queue visit", error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveLocally(visits) {
|
||||
if (!visits.length) return true;
|
||||
try {
|
||||
@@ -21,40 +149,43 @@ async function archiveLocally(visits) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function sendToCollector(visits) {
|
||||
function isHttpUrl(url) {
|
||||
if (typeof url !== "string") return false;
|
||||
try {
|
||||
const protocol = new URL(url).protocol;
|
||||
return protocol === "http:" || protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function sendToCollector(visits, onChunkDelivered) {
|
||||
// Empty settings deliberately disable sending; the local SQLite archive still works.
|
||||
if (!CONFIG.collectorUrl || !CONFIG.apiToken) return true;
|
||||
if (!remoteEnabled()) return false;
|
||||
const remoteVisits = visits
|
||||
.filter((visit) => {
|
||||
if (typeof visit?.url !== "string") return false;
|
||||
try {
|
||||
const protocol = new URL(visit.url).protocol;
|
||||
return protocol === "http:" || protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.filter(isRemoteVisit)
|
||||
.map((visit) => ({
|
||||
...visit,
|
||||
title: String(visit.title ?? "").slice(0, 1000)
|
||||
}));
|
||||
try {
|
||||
for (let offset = 0; offset < remoteVisits.length; offset += 100) {
|
||||
const response = await fetch(CONFIG.collectorUrl, {
|
||||
const chunk = remoteVisits.slice(offset, offset + 100);
|
||||
const response = await fetch(runtimeSettings.collectorUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${CONFIG.apiToken}`
|
||||
"Authorization": `Bearer ${runtimeSettings.apiToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
device_id: CONFIG.deviceId,
|
||||
visits: remoteVisits.slice(offset, offset + 100)
|
||||
device_id: runtimeSettings.deviceId,
|
||||
visits: chunk
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = (await response.text()).trim().slice(0, 1000);
|
||||
throw new Error(`HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
if (onChunkDelivered) await onChunkDelivered(offset, chunk.length);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -62,23 +193,76 @@ async function sendToCollector(visits) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function flush() {
|
||||
const visits = await queued();
|
||||
if (!visits.length) return;
|
||||
const [localOk, remoteOk] = await Promise.all([archiveLocally(visits), sendToCollector(visits)]);
|
||||
if (localOk && remoteOk) await persist([]);
|
||||
|
||||
async function flushOnce() {
|
||||
await refreshRuntimeSettings();
|
||||
const { localVisits, remoteVisits } = await snapshotQueues();
|
||||
if (!localVisits.length && !remoteVisits.length) return;
|
||||
|
||||
const [localOk, remoteOk] = await Promise.all([
|
||||
archiveLocally(localVisits),
|
||||
sendToCollector(
|
||||
remoteVisits,
|
||||
(offset, deliveredCount) => acknowledgeQueuePrefix(
|
||||
REMOTE_QUEUE_KEY,
|
||||
remoteVisits,
|
||||
offset,
|
||||
deliveredCount
|
||||
)
|
||||
)
|
||||
]);
|
||||
if (localOk && localVisits.length) {
|
||||
await acknowledgeQueuePrefix(LOCAL_QUEUE_KEY, localVisits, 0, localVisits.length);
|
||||
}
|
||||
if (remoteOk && remoteVisits.length) {
|
||||
await acknowledgeQueuePrefix(REMOTE_QUEUE_KEY, remoteVisits, 0, remoteVisits.length);
|
||||
}
|
||||
}
|
||||
chrome.history.onVisited.addListener(async (item) => {
|
||||
const visits = await queued();
|
||||
visits.push({
|
||||
|
||||
async function flushLoop() {
|
||||
while (flushRequested) {
|
||||
flushRequested = false;
|
||||
try {
|
||||
await flushOnce();
|
||||
} catch (error) {
|
||||
console.warn("Queue flush failed", error?.message || error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requestFlush() {
|
||||
flushRequested = true;
|
||||
if (flushInProgress) return activeFlush;
|
||||
|
||||
flushInProgress = true;
|
||||
activeFlush = flushLoop().catch((error) => {
|
||||
console.warn("Queue flush loop failed", error?.message || error);
|
||||
}).then(() => {
|
||||
flushInProgress = false;
|
||||
if (flushRequested) return requestFlush();
|
||||
return undefined;
|
||||
});
|
||||
return activeFlush;
|
||||
}
|
||||
chrome.history.onVisited.addListener((item) => {
|
||||
return enqueueVisit({
|
||||
url: item.url, title: item.title || "",
|
||||
visit_time: new Date(item.lastVisitTime || Date.now()).toISOString(),
|
||||
transition: "observed", source: "chrome-extension"
|
||||
});
|
||||
await persist(visits);
|
||||
await flush();
|
||||
});
|
||||
chrome.runtime.onStartup.addListener(flush);
|
||||
chrome.runtime.onInstalled.addListener(flush);
|
||||
chrome.downloads.onCreated.addListener((item) => {
|
||||
if (!isHttpUrl(item?.url)) return;
|
||||
const startTime = new Date(item.startTime || Date.now());
|
||||
return enqueueVisit({
|
||||
url: item.url,
|
||||
title: "",
|
||||
visit_time: Number.isNaN(startTime.getTime()) ? new Date().toISOString() : startTime.toISOString(),
|
||||
transition: "download",
|
||||
source: "chrome-extension"
|
||||
});
|
||||
});
|
||||
chrome.runtime.onStartup.addListener(() => requestFlush());
|
||||
chrome.runtime.onInstalled.addListener(() => requestFlush());
|
||||
chrome.alarms.create("local-history-archive-flush", { periodInMinutes: 1 });
|
||||
chrome.alarms.onAlarm.addListener(flush);
|
||||
chrome.alarms.onAlarm.addListener(() => requestFlush());
|
||||
Reference in new issue
Block a user