diff --git a/browser-extension/firefox/popup/popup.js b/browser-extension/firefox/popup/popup.js
index 3531e13f..7dff38f3 100644
--- a/browser-extension/firefox/popup/popup.js
+++ b/browser-extension/firefox/popup/popup.js
@@ -1,3 +1,5 @@
+import { loadOpenAppState, isOpenAppEnabled, setOpenAppEnabled } from "../src/open-app-toggle.js";
+
const APP_URL = "https://github.com/tonhowtf/omniget/releases/latest";
const SVG = {
@@ -11,15 +13,24 @@ const SVG = {
let currentData = null;
let pageTitle = "";
+let pageThumbnail = "";
async function init() {
const toggle = document.getElementById("sniffer-toggle");
+ const openAppToggle = document.getElementById("open-app-toggle");
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
pageTitle = tab?.title || "";
+ pageThumbnail = tab?.favIconUrl || "";
} catch {}
+ await loadOpenAppState();
+ openAppToggle.checked = isOpenAppEnabled();
+ openAppToggle.addEventListener("change", () => {
+ setOpenAppEnabled(openAppToggle.checked);
+ });
+
chrome.runtime.sendMessage({ type: "getDetectedMedia" }, (response) => {
if (!response) return;
currentData = response;
@@ -309,9 +320,11 @@ function sendToApp(url, platform, mediaEntry) {
const msg = { type: "sendToOmniGet", url, platform };
if (pageTitle) msg.title = pageTitle;
+ if (pageThumbnail) msg.thumbnail = pageThumbnail;
if (currentData?.tabUrl) msg.referer = currentData.tabUrl;
if (mediaEntry?.mediaType) msg.mediaType = mediaEntry.mediaType;
if (mediaEntry?.contentType) msg.contentType = mediaEntry.contentType;
+ msg.openApp = isOpenAppEnabled();
if (mediaEntry?.requestHeaders) {
const skip = ["host", "connection", "accept-encoding", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests"];
@@ -344,6 +357,7 @@ async function sendBatch(groups) {
if (currentData?.tabUrl) msg.referer = currentData.tabUrl;
if (group.master.mediaType) msg.mediaType = group.master.mediaType;
if (group.master.contentType) msg.contentType = group.master.contentType;
+ msg.openApp = isOpenAppEnabled();
try {
await new Promise((resolve) => {
diff --git a/browser-extension/firefox/src/background.js b/browser-extension/firefox/src/background.js
index b7b85a5e..6dd6f519 100644
--- a/browser-extension/firefox/src/background.js
+++ b/browser-extension/firefox/src/background.js
@@ -156,9 +156,11 @@ async function handleSendToApp(msg) {
const platform = msg.platform || "generic";
let pageTitle = "";
+ let pageThumbnail = "";
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
pageTitle = tab?.title || "";
+ pageThumbnail = tab?.favIconUrl || "";
} catch {}
let cookies = null;
@@ -204,9 +206,12 @@ async function handleSendToApp(msg) {
if (msg.referer) message.referer = msg.referer;
if (msg.title) message.title = msg.title;
else if (pageTitle) message.title = pageTitle;
+ if (msg.thumbnail) message.thumbnail = msg.thumbnail;
+ else if (pageThumbnail) message.thumbnail = pageThumbnail;
if (msg.mediaType) message.mediaType = msg.mediaType;
if (msg.contentType) message.contentType = msg.contentType;
if (msg.headers) message.headers = msg.headers;
+ if (typeof msg.openApp === "boolean") message.openApp = msg.openApp;
message.pageUrl = msg.referer || "";
message.userAgent = navigator.userAgent;
diff --git a/browser-extension/firefox/src/media-sniffer.js b/browser-extension/firefox/src/media-sniffer.js
index f343ae40..ea49c6b5 100644
--- a/browser-extension/firefox/src/media-sniffer.js
+++ b/browser-extension/firefox/src/media-sniffer.js
@@ -71,6 +71,18 @@ const PLATFORM_CDN_HOSTS = [
"tiktokcdn-us.com",
];
+const BLOCKED_PATH_PATTERNS = [
+ /\/analytics(\/|\?|$)/i,
+ /\/pixel(\/|\?|\.|$)/i,
+ /\/collect(\/|\?|$)/i,
+ /\/beacon(\/|\?|$)/i,
+ /\/telemetry(\/|\?|$)/i,
+ /\/track(\/|\?|$|er)/i,
+ /1x1\.gif$/i,
+ /\/spacer\.gif$/i,
+ /\/transparent\.gif$/i,
+];
+
const MIN_CONTENT_LENGTH = 50 * 1024;
const detectedMedia = new Map();
@@ -96,6 +108,14 @@ function isBlockedHost(url) {
} catch { return false; }
}
+function isBlockedPath(url) {
+ try {
+ const u = new URL(url);
+ const target = u.pathname + u.search;
+ return BLOCKED_PATH_PATTERNS.some(rx => rx.test(target));
+ } catch { return false; }
+}
+
function isMediaByExtension(url) {
try {
const path = new URL(url).pathname.toLowerCase();
@@ -168,16 +188,18 @@ function isPlatformCdnFragment(url, contentType, contentLength) {
} catch { return false; }
}
+const storageArea = chrome.storage.session ?? chrome.storage.local;
+
function persistMedia(tabId) {
const media = detectedMedia.get(tabId);
if (!media || media.size === 0) return;
const arr = Array.from(media.entries());
- chrome.storage.local.set({ [`media_${tabId}`]: arr }).catch(() => {});
+ storageArea.set({ [`media_${tabId}`]: arr }).catch(() => {});
}
export async function restoreMedia() {
try {
- const data = await chrome.storage.local.get(null);
+ const data = await storageArea.get(null);
for (const [key, value] of Object.entries(data)) {
if (!key.startsWith("media_")) continue;
const tabId = parseInt(key.replace("media_", ""));
@@ -214,7 +236,10 @@ export function registerSnifferListeners(onMediaDetected) {
if (details.statusCode < 200 || details.statusCode >= 300) return;
const url = details.url;
- if (isBlockedHost(url)) return;
+ if (isBlockedHost(url) || isBlockedPath(url)) {
+ pendingRequests.delete(details.requestId);
+ return;
+ }
const contentType = getContentType(details.responseHeaders);
const contentLength = getContentLength(details.responseHeaders);
@@ -280,13 +305,13 @@ export function registerSnifferListeners(onMediaDetected) {
chrome.tabs.onRemoved.addListener((tabId) => {
detectedMedia.delete(tabId);
- chrome.storage.local.remove(`media_${tabId}`).catch(() => {});
+ storageArea.remove(`media_${tabId}`).catch(() => {});
});
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.url) {
detectedMedia.delete(tabId);
- chrome.storage.local.remove(`media_${tabId}`).catch(() => {});
+ storageArea.remove(`media_${tabId}`).catch(() => {});
}
});
}
diff --git a/browser-extension/firefox/src/open-app-toggle.js b/browser-extension/firefox/src/open-app-toggle.js
new file mode 100644
index 00000000..6b279245
--- /dev/null
+++ b/browser-extension/firefox/src/open-app-toggle.js
@@ -0,0 +1,18 @@
+const STORAGE_KEY = "omniget_open_app_on_download";
+
+let openApp = true;
+
+export async function loadOpenAppState() {
+ const result = await chrome.storage.local.get(STORAGE_KEY);
+ openApp = result[STORAGE_KEY] !== false;
+ return openApp;
+}
+
+export function isOpenAppEnabled() {
+ return openApp;
+}
+
+export async function setOpenAppEnabled(value) {
+ openApp = value;
+ await chrome.storage.local.set({ [STORAGE_KEY]: value });
+}
diff --git a/browser-extension/sync.mjs b/browser-extension/sync.mjs
new file mode 100644
index 00000000..226ecb2b
--- /dev/null
+++ b/browser-extension/sync.mjs
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const CHROME = path.join(__dirname, "chrome");
+const FIREFOX = path.join(__dirname, "firefox");
+
+const SHARED = [
+ "src/action-click.js",
+ "src/action-feedback.js",
+ "src/action-title.js",
+ "src/background.js",
+ "src/context-menu.js",
+ "src/cookies.js",
+ "src/detect.js",
+ "src/media-sniffer.js",
+ "src/open-app-toggle.js",
+ "src/sniffer-toggle.js",
+ "popup/popup.js",
+ "popup/popup.css",
+ "popup/popup.html",
+ "pages/error.html",
+ "pages/error.css",
+ "pages/error.js",
+ "pages/error-content.js",
+];
+
+const SHARED_DIRS = ["_locales", "icons"];
+
+function copyFile(rel) {
+ const from = path.join(CHROME, rel);
+ const to = path.join(FIREFOX, rel);
+ if (!fs.existsSync(from)) {
+ console.warn(`[skip] missing in chrome: ${rel}`);
+ return { changed: false, same: false, skipped: true };
+ }
+ fs.mkdirSync(path.dirname(to), { recursive: true });
+ const a = fs.readFileSync(from);
+ if (fs.existsSync(to)) {
+ const b = fs.readFileSync(to);
+ if (a.equals(b)) return { changed: false, same: true, skipped: false };
+ }
+ fs.writeFileSync(to, a);
+ return { changed: true, same: false, skipped: false };
+}
+
+function copyDirRecursive(relDir) {
+ const from = path.join(CHROME, relDir);
+ const to = path.join(FIREFOX, relDir);
+ if (!fs.existsSync(from)) return { changed: 0, same: 0 };
+ let changed = 0;
+ let same = 0;
+ const walk = (srcDir, dstDir) => {
+ fs.mkdirSync(dstDir, { recursive: true });
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
+ const srcPath = path.join(srcDir, entry.name);
+ const dstPath = path.join(dstDir, entry.name);
+ if (entry.isDirectory()) {
+ walk(srcPath, dstPath);
+ } else if (entry.isFile()) {
+ const a = fs.readFileSync(srcPath);
+ if (fs.existsSync(dstPath)) {
+ const b = fs.readFileSync(dstPath);
+ if (a.equals(b)) {
+ same++;
+ continue;
+ }
+ }
+ fs.writeFileSync(dstPath, a);
+ changed++;
+ }
+ }
+ };
+ walk(from, to);
+ return { changed, same };
+}
+
+let totalChanged = 0;
+let totalSame = 0;
+
+for (const rel of SHARED) {
+ const r = copyFile(rel);
+ if (r.changed) {
+ console.log(`[copy] ${rel}`);
+ totalChanged++;
+ } else if (r.same) {
+ totalSame++;
+ }
+}
+
+for (const rel of SHARED_DIRS) {
+ const r = copyDirRecursive(rel);
+ if (r.changed > 0) console.log(`[copy] ${rel}/ (${r.changed} file(s))`);
+ totalChanged += r.changed;
+ totalSame += r.same;
+}
+
+console.log(`\nSynced chrome → firefox: ${totalChanged} changed, ${totalSame} already identical.`);
+console.log(`Note: manifest.json is intentionally per-browser and not synced.`);
diff --git a/package.json b/package.json
index 82af792e..f3b0cbf1 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,12 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
- "tauri": "tauri"
+ "tauri": "tauri",
+ "version:set": "node scripts/bump-version.js",
+ "generate:i18n-keys": "node scripts/generate-i18n-keys.js",
+ "extension:sync": "node browser-extension/sync.mjs",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"license": "GPL-3.0",
"dependencies": {
@@ -29,9 +34,11 @@
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
+ "@types/node": "^25.6.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
- "vite": "^6.0.3"
+ "vite": "^6.0.3",
+ "vitest": "^4.1.4"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1be6625f..a0dc5ca8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -38,16 +38,19 @@ importers:
devDependencies:
'@sveltejs/adapter-static':
specifier: ^3.0.6
- version: 3.0.10(@sveltejs/kit@2.51.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1))(svelte@5.51.0)(typescript@5.6.3)(vite@6.4.1))
+ version: 3.0.10(@sveltejs/kit@2.51.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0)))(svelte@5.51.0)(typescript@5.6.3)(vite@6.4.1(@types/node@25.6.0)))
'@sveltejs/kit':
specifier: ^2.9.0
- version: 2.51.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1))(svelte@5.51.0)(typescript@5.6.3)(vite@6.4.1)
+ version: 2.51.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0)))(svelte@5.51.0)(typescript@5.6.3)(vite@6.4.1(@types/node@25.6.0))
'@sveltejs/vite-plugin-svelte':
specifier: ^5.0.0
- version: 5.1.1(svelte@5.51.0)(vite@6.4.1)
+ version: 5.1.1(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0))
'@tauri-apps/cli':
specifier: ^2
version: 2.10.0
+ '@types/node':
+ specifier: ^25.6.0
+ version: 25.6.0
svelte:
specifier: ^5.0.0
version: 5.51.0
@@ -59,7 +62,10 @@ importers:
version: 5.6.3
vite:
specifier: ^6.0.3
- version: 6.4.1
+ version: 6.4.1(@types/node@25.6.0)
+ vitest:
+ specifier: ^4.1.4
+ version: 4.1.4(@types/node@25.6.0)(vite@6.4.1(@types/node@25.6.0))
packages:
@@ -528,15 +534,53 @@ packages:
'@tauri-apps/plugin-updater@2.10.0':
resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==}
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/node@25.6.0':
+ resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
+
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+ '@vitest/expect@4.1.4':
+ resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==}
+
+ '@vitest/mocker@4.1.4':
+ resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.4':
+ resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==}
+
+ '@vitest/runner@4.1.4':
+ resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==}
+
+ '@vitest/snapshot@4.1.4':
+ resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==}
+
+ '@vitest/spy@4.1.4':
+ resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==}
+
+ '@vitest/utils@4.1.4':
+ resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==}
+
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
@@ -546,10 +590,18 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -558,6 +610,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
@@ -578,6 +633,9 @@ packages:
devalue@5.6.2:
resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==}
+ es-module-lexer@2.0.0:
+ resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
+
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
@@ -589,6 +647,13 @@ packages:
esrap@2.2.3:
resolution: {integrity: sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==}
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ engines: {node: '>=12.0.0'}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -632,6 +697,12 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ obug@2.1.1:
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -659,6 +730,9 @@ packages:
set-cookie-parser@3.0.1:
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
@@ -667,6 +741,12 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ std-env@4.0.0:
+ resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
+
svelte-check@4.4.0:
resolution: {integrity: sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg==}
engines: {node: '>= 18.0.0'}
@@ -684,10 +764,21 @@ packages:
peerDependencies:
svelte: '>=3.49.0'
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.1.1:
+ resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==}
+ engines: {node: '>=18'}
+
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
@@ -697,6 +788,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ undici-types@7.19.2:
+ resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
+
vite@6.4.1:
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -745,6 +839,52 @@ packages:
vite:
optional: true
+ vitest@4.1.4:
+ resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.4
+ '@vitest/browser-preview': 4.1.4
+ '@vitest/browser-webdriverio': 4.1.4
+ '@vitest/coverage-istanbul': 4.1.4
+ '@vitest/coverage-v8': 4.1.4
+ '@vitest/ui': 4.1.4
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
@@ -934,15 +1074,15 @@ snapshots:
dependencies:
acorn: 8.15.0
- '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.51.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1))(svelte@5.51.0)(typescript@5.6.3)(vite@6.4.1))':
+ '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.51.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0)))(svelte@5.51.0)(typescript@5.6.3)(vite@6.4.1(@types/node@25.6.0)))':
dependencies:
- '@sveltejs/kit': 2.51.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1))(svelte@5.51.0)(typescript@5.6.3)(vite@6.4.1)
+ '@sveltejs/kit': 2.51.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0)))(svelte@5.51.0)(typescript@5.6.3)(vite@6.4.1(@types/node@25.6.0))
- '@sveltejs/kit@2.51.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1))(svelte@5.51.0)(typescript@5.6.3)(vite@6.4.1)':
+ '@sveltejs/kit@2.51.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0)))(svelte@5.51.0)(typescript@5.6.3)(vite@6.4.1(@types/node@25.6.0))':
dependencies:
'@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0)
- '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.51.0)(vite@6.4.1)
+ '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0))
'@types/cookie': 0.6.0
acorn: 8.15.0
cookie: 0.6.0
@@ -955,29 +1095,29 @@ snapshots:
set-cookie-parser: 3.0.1
sirv: 3.0.2
svelte: 5.51.0
- vite: 6.4.1
+ vite: 6.4.1(@types/node@25.6.0)
optionalDependencies:
typescript: 5.6.3
- '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1))(svelte@5.51.0)(vite@6.4.1)':
+ '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0)))(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0))':
dependencies:
- '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.51.0)(vite@6.4.1)
+ '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0))
debug: 4.4.3
svelte: 5.51.0
- vite: 6.4.1
+ vite: 6.4.1(@types/node@25.6.0)
transitivePeerDependencies:
- supports-color
- '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1)':
+ '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0))':
dependencies:
- '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1))(svelte@5.51.0)(vite@6.4.1)
+ '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0)))(svelte@5.51.0)(vite@6.4.1(@types/node@25.6.0))
debug: 4.4.3
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.21
svelte: 5.51.0
- vite: 6.4.1
- vitefu: 1.1.1(vite@6.4.1)
+ vite: 6.4.1(@types/node@25.6.0)
+ vitefu: 1.1.1(vite@6.4.1(@types/node@25.6.0))
transitivePeerDependencies:
- supports-color
@@ -1056,24 +1196,82 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.10.1
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
'@types/cookie@0.6.0': {}
+ '@types/deep-eql@4.0.2': {}
+
'@types/estree@1.0.8': {}
+ '@types/node@25.6.0':
+ dependencies:
+ undici-types: 7.19.2
+
'@types/trusted-types@2.0.7': {}
+ '@vitest/expect@4.1.4':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.4
+ '@vitest/utils': 4.1.4
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.4(vite@6.4.1(@types/node@25.6.0))':
+ dependencies:
+ '@vitest/spy': 4.1.4
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 6.4.1(@types/node@25.6.0)
+
+ '@vitest/pretty-format@4.1.4':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.4':
+ dependencies:
+ '@vitest/utils': 4.1.4
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.4':
+ dependencies:
+ '@vitest/pretty-format': 4.1.4
+ '@vitest/utils': 4.1.4
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.4': {}
+
+ '@vitest/utils@4.1.4':
+ dependencies:
+ '@vitest/pretty-format': 4.1.4
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
acorn@8.15.0: {}
aria-query@5.3.2: {}
+ assertion-error@2.0.1: {}
+
axobject-query@4.1.0: {}
+ chai@6.2.2: {}
+
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
clsx@2.1.1: {}
+ convert-source-map@2.0.0: {}
+
cookie@0.6.0: {}
debug@4.4.3:
@@ -1084,6 +1282,8 @@ snapshots:
devalue@5.6.2: {}
+ es-module-lexer@2.0.0: {}
+
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
@@ -1119,6 +1319,12 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+
+ expect-type@1.3.0: {}
+
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -1146,6 +1352,10 @@ snapshots:
nanoid@3.3.11: {}
+ obug@2.1.1: {}
+
+ pathe@2.0.3: {}
+
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@@ -1195,6 +1405,8 @@ snapshots:
set-cookie-parser@3.0.1: {}
+ siginfo@2.0.0: {}
+
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
@@ -1203,6 +1415,10 @@ snapshots:
source-map-js@1.2.1: {}
+ stackback@0.0.2: {}
+
+ std-env@4.0.0: {}
+
svelte-check@4.4.0(picomatch@4.0.3)(svelte@5.51.0)(typescript@5.6.3):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -1240,16 +1456,24 @@ snapshots:
'@sveltekit-i18n/parser-default': 1.1.1
svelte: 5.51.0
+ tinybench@2.9.0: {}
+
+ tinyexec@1.1.1: {}
+
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
+ tinyrainbow@3.1.0: {}
+
totalist@3.0.1: {}
typescript@5.6.3: {}
- vite@6.4.1:
+ undici-types@7.19.2: {}
+
+ vite@6.4.1(@types/node@25.6.0):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
@@ -1258,10 +1482,43 @@ snapshots:
rollup: 4.57.1
tinyglobby: 0.2.15
optionalDependencies:
+ '@types/node': 25.6.0
fsevents: 2.3.3
- vitefu@1.1.1(vite@6.4.1):
+ vitefu@1.1.1(vite@6.4.1(@types/node@25.6.0)):
optionalDependencies:
- vite: 6.4.1
+ vite: 6.4.1(@types/node@25.6.0)
+
+ vitest@4.1.4(@types/node@25.6.0)(vite@6.4.1(@types/node@25.6.0)):
+ dependencies:
+ '@vitest/expect': 4.1.4
+ '@vitest/mocker': 4.1.4(vite@6.4.1(@types/node@25.6.0))
+ '@vitest/pretty-format': 4.1.4
+ '@vitest/runner': 4.1.4
+ '@vitest/snapshot': 4.1.4
+ '@vitest/spy': 4.1.4
+ '@vitest/utils': 4.1.4
+ es-module-lexer: 2.0.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 4.0.0
+ tinybench: 2.9.0
+ tinyexec: 1.1.1
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.1.0
+ vite: 6.4.1(@types/node@25.6.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 25.6.0
+ transitivePeerDependencies:
+ - msw
+
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
zimmerframe@1.1.4: {}
diff --git a/scripts/bump-version.js b/scripts/bump-version.js
new file mode 100644
index 00000000..ae40adbe
--- /dev/null
+++ b/scripts/bump-version.js
@@ -0,0 +1,197 @@
+#!/usr/bin/env node
+
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+import { execSync } from "child_process";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const arg = process.argv[2];
+
+if (!arg) {
+ console.error("Usage: node scripts/bump-version.js
");
+ process.exit(1);
+}
+
+function readCurrentVersion() {
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
+ return pkg.version;
+}
+
+function bump(current, kind) {
+ const match = current.match(/^(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?$/);
+ if (!match) {
+ console.error(`Cannot parse current version: ${current}`);
+ process.exit(1);
+ }
+ let [, maj, min, patch] = match;
+ maj = Number(maj);
+ min = Number(min);
+ patch = Number(patch);
+ if (kind === "major") {
+ maj += 1;
+ min = 0;
+ patch = 0;
+ } else if (kind === "minor") {
+ min += 1;
+ patch = 0;
+ } else if (kind === "patch") {
+ patch += 1;
+ } else {
+ console.error(`Unknown bump kind: ${kind}`);
+ process.exit(1);
+ }
+ return `${maj}.${min}.${patch}`;
+}
+
+const current = readCurrentVersion();
+let version;
+if (["major", "minor", "patch"].includes(arg)) {
+ version = bump(current, arg);
+} else if (/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(arg)) {
+ version = arg;
+} else {
+ console.error(`Invalid version: ${arg}`);
+ console.error("Expected: major | minor | patch | X.Y.Z[-suffix]");
+ process.exit(1);
+}
+
+console.log(`Bumping ${current} → ${version}`);
+
+const changed = [];
+
+function writeJson(filePath, mutator) {
+ const raw = fs.readFileSync(filePath, "utf8");
+ const data = JSON.parse(raw);
+ mutator(data);
+ const out = JSON.stringify(data, null, 2) + (raw.endsWith("\n") ? "\n" : "");
+ fs.writeFileSync(filePath, out);
+ changed.push(path.relative(root, filePath));
+}
+
+function writeText(filePath, mutator) {
+ const original = fs.readFileSync(filePath, "utf8");
+ const updated = mutator(original);
+ if (updated !== original) {
+ fs.writeFileSync(filePath, updated);
+ changed.push(path.relative(root, filePath));
+ }
+}
+
+writeJson(path.join(root, "package.json"), (p) => {
+ p.version = version;
+});
+
+const pkgLockPath = path.join(root, "package-lock.json");
+if (fs.existsSync(pkgLockPath)) {
+ writeJson(pkgLockPath, (p) => {
+ p.version = version;
+ if (p.packages && p.packages[""]) {
+ p.packages[""].version = version;
+ }
+ });
+}
+
+const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml");
+writeText(cargoTomlPath, (content) => {
+ const lines = content.split("\n");
+ let inPackage = false;
+ let replaced = false;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const sectionMatch = line.match(/^\[([^\]]+)\]\s*$/);
+ if (sectionMatch) {
+ inPackage = sectionMatch[1].trim() === "package";
+ continue;
+ }
+ if (inPackage && /^version\s*=\s*"[^"]+"/.test(line)) {
+ lines[i] = line.replace(/^version\s*=\s*"[^"]+"/, `version = "${version}"`);
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ console.error("Failed to update version in Cargo.toml");
+ process.exit(1);
+ }
+ return lines.join("\n");
+});
+
+const cargoLockPath = path.join(root, "src-tauri", "Cargo.lock");
+if (fs.existsSync(cargoLockPath)) {
+ writeText(cargoLockPath, (content) => {
+ return content.replace(
+ /(name = "omniget"\nversion = ")([^"]+)(")/,
+ (_, a, _b, c) => `${a}${version}${c}`
+ );
+ });
+}
+
+const tauriConfPath = path.join(root, "src-tauri", "tauri.conf.json");
+writeJson(tauriConfPath, (conf) => {
+ conf.version = version;
+});
+
+const metainfoPath = path.join(root, "flatpak", "wtf.tonho.omniget.metainfo.xml");
+writeText(metainfoPath, (content) => {
+ let updated = content.replace(
+ /(raw\.githubusercontent\.com\/tonhowtf\/omniget\/v)(\d+\.\d+\.\d+(?:-[\w.]+)?)(\/)/g,
+ (_, a, _b, c) => `${a}${version}${c}`
+ );
+ return updated;
+});
+
+const changelogStorePath = path.join(root, "src", "lib", "stores", "changelog-store.svelte.ts");
+if (fs.existsSync(changelogStorePath)) {
+ writeText(changelogStorePath, (content) =>
+ content.replace(
+ /(currentVersion\s*=\s*")[^"]+(";)/g,
+ (_, a, c) => `${a}${version}${c}`
+ )
+ );
+}
+
+const aboutProjectPath = path.join(root, "src", "routes", "about", "project", "+page.svelte");
+if (fs.existsSync(aboutProjectPath)) {
+ writeText(aboutProjectPath, (content) =>
+ content.replace(
+ /(const\s+APP_VERSION\s*=\s*")[^"]+(";)/,
+ (_, a, c) => `${a}${version}${c}`
+ )
+ );
+}
+
+if (changed.length === 0) {
+ console.error("No files changed — aborting.");
+ process.exit(1);
+}
+
+console.log("Updated files:");
+for (const file of changed) {
+ console.log(` - ${file}`);
+}
+
+function hasBinary(name) {
+ const probe = process.platform === "win32" ? "where" : "which";
+ try {
+ execSync(`${probe} ${name}`, { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+const metainfoAbs = path.join(root, "flatpak", "wtf.tonho.omniget.metainfo.xml");
+if (hasBinary("appstreamcli")) {
+ try {
+ execSync(`appstreamcli validate "${metainfoAbs}"`, { stdio: "inherit" });
+ console.log("appstreamcli validate: OK");
+ } catch {
+ console.error("appstreamcli validate failed — aborting.");
+ process.exit(1);
+ }
+} else {
+ console.warn("appstreamcli not found in PATH — skipping metainfo validation.");
+}
+
+console.log(`v${version}`);
diff --git a/scripts/generate-i18n-keys.js b/scripts/generate-i18n-keys.js
new file mode 100644
index 00000000..8d7d9b32
--- /dev/null
+++ b/scripts/generate-i18n-keys.js
@@ -0,0 +1,111 @@
+#!/usr/bin/env node
+
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const i18nDir = path.join(__dirname, "..", "src", "lib", "i18n");
+const enPath = path.join(i18nDir, "en.json");
+const outputFile = path.join(i18nDir, "keys.ts");
+
+function extractKeys(obj, prefix = "") {
+ const keys = [];
+ for (const [key, value] of Object.entries(obj)) {
+ const fullKey = prefix ? `${prefix}.${key}` : key;
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
+ keys.push(...extractKeys(value, fullKey));
+ } else {
+ keys.push(fullKey);
+ }
+ }
+ return keys;
+}
+
+function generateKeysFile() {
+ if (!fs.existsSync(enPath)) {
+ console.error("Error: en.json not found at", enPath);
+ process.exit(1);
+ }
+
+ const enContent = JSON.parse(fs.readFileSync(enPath, "utf-8"));
+ const keys = extractKeys(enContent).sort();
+
+ const grouped = {};
+ for (const key of keys) {
+ const category = key.split(".")[0];
+ if (!grouped[category]) grouped[category] = [];
+ grouped[category].push(key);
+ }
+
+ let output = `/**
+ * i18n Translation Keys Reference
+ *
+ * AUTO-GENERATED FILE
+ * Run: pnpm generate:i18n-keys
+ *
+ * This file provides autocomplete hints for translation keys.
+ * Import the \`t\` function from '$lib/i18n' to use translations.
+ *
+ * Usage:
+ * \`\`\`svelte
+ *
+ *
+ * {\$t('app.name')}
+ * {\$t('queue.items', { count: 5 })}
+ * \`\`\`
+ */
+
+export type TranslationKeys =\n`;
+
+ const categories = Object.keys(grouped).sort();
+ for (let i = 0; i < categories.length; i++) {
+ const category = categories[i];
+ const categoryKeys = grouped[category];
+
+ for (let j = 0; j < categoryKeys.length; j++) {
+ const key = categoryKeys[j];
+ output += ` | '${key}'\n`;
+ }
+
+ if (i < categories.length - 1) {
+ output += "\n";
+ }
+ }
+
+ output += " // Allows dynamic/computed keys while preserving autocomplete for known keys\n";
+ output += " | (string & {});\n";
+
+ fs.writeFileSync(outputFile, output, "utf-8");
+ console.log(`Generated ${keys.length} translation keys in keys.ts`);
+
+ const otherLocales = fs
+ .readdirSync(i18nDir)
+ .filter((f) => f.endsWith(".json") && f !== "en.json");
+
+ let hadMismatch = false;
+ const enKeySet = new Set(keys);
+ for (const file of otherLocales) {
+ const p = path.join(i18nDir, file);
+ try {
+ const content = JSON.parse(fs.readFileSync(p, "utf-8"));
+ const localeKeys = new Set(extractKeys(content));
+ const missing = [...enKeySet].filter((k) => !localeKeys.has(k));
+ const extra = [...localeKeys].filter((k) => !enKeySet.has(k));
+ if (missing.length > 0 || extra.length > 0) {
+ hadMismatch = true;
+ console.warn(`[${file}] missing: ${missing.length}, extra: ${extra.length}`);
+ }
+ } catch (err) {
+ hadMismatch = true;
+ console.warn(`[${file}] failed to parse: ${err.message}`);
+ }
+ }
+ if (!hadMismatch) {
+ console.log(`All ${otherLocales.length} other locales in sync with en.json`);
+ }
+}
+
+generateKeysFile();
diff --git a/src/app.css b/src/app.css
index 17871bbf..a7f07952 100644
--- a/src/app.css
+++ b/src/app.css
@@ -8,6 +8,12 @@
--padding: 10px;
--border-radius: 11px;
--sidebar-width: 80px;
+ --bottom-nav-height: 44px;
+ --mobile-nav-clearance: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px) + var(--padding));
+ --safe-area-top: env(safe-area-inset-top, 0px);
+ --safe-area-right: env(safe-area-inset-right, 0px);
+ --safe-area-bottom: env(safe-area-inset-bottom, 0px);
+ --safe-area-left: env(safe-area-inset-left, 0px);
--font-system: 'DM Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: 'IBM Plex Mono', 'DejaVu Sans Mono', 'Liberation Mono', 'Noto Sans Mono', 'Ubuntu Mono', 'Courier New', monospace;
diff --git a/src/app.d.ts b/src/app.d.ts
new file mode 100644
index 00000000..aec25a7d
--- /dev/null
+++ b/src/app.d.ts
@@ -0,0 +1,6 @@
+declare const __COMMIT_HASH__: string;
+declare const __GIT_BRANCH__: string;
+declare const __APP_VERSION__: string;
+declare const __BUILD_DATE__: string;
+
+declare namespace App {}
diff --git a/src/app.html b/src/app.html
index c5fbfa5d..ece14f6e 100644
--- a/src/app.html
+++ b/src/app.html
@@ -3,7 +3,7 @@
-
+
OmniGet
%sveltekit.head%
diff --git a/src/lib/plugin-invoke.test.ts b/src/lib/plugin-invoke.test.ts
new file mode 100644
index 00000000..c9c1ec8a
--- /dev/null
+++ b/src/lib/plugin-invoke.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const invokeMock = vi.fn();
+
+vi.mock("@tauri-apps/api/core", () => ({
+ invoke: (cmd: string, args: unknown) => invokeMock(cmd, args),
+}));
+
+import { pluginInvoke } from "./plugin-invoke";
+
+describe("pluginInvoke", () => {
+ beforeEach(() => {
+ invokeMock.mockReset();
+ });
+
+ it("routes through the plugin_command Tauri handler", async () => {
+ invokeMock.mockResolvedValueOnce({ ok: true });
+ await pluginInvoke("courses", "hotmart_login", { email: "e", password: "p" });
+ expect(invokeMock).toHaveBeenCalledTimes(1);
+ expect(invokeMock.mock.calls[0][0]).toBe("plugin_command");
+ });
+
+ it("passes pluginId, command, and args in the payload", async () => {
+ invokeMock.mockResolvedValueOnce(undefined);
+ await pluginInvoke("telegram", "list_chats", { limit: 20 });
+ expect(invokeMock.mock.calls[0][1]).toEqual({
+ pluginId: "telegram",
+ command: "list_chats",
+ args: { limit: 20 },
+ });
+ });
+
+ it("defaults args to an empty object when not provided", async () => {
+ invokeMock.mockResolvedValueOnce("ok");
+ await pluginInvoke("convert", "list_formats");
+ expect(invokeMock.mock.calls[0][1]).toEqual({
+ pluginId: "convert",
+ command: "list_formats",
+ args: {},
+ });
+ });
+
+ it("propagates the resolved value", async () => {
+ invokeMock.mockResolvedValueOnce({ success: true, count: 3 });
+ const result = await pluginInvoke<{ success: boolean; count: number }>(
+ "courses",
+ "list",
+ );
+ expect(result).toEqual({ success: true, count: 3 });
+ });
+
+ it("propagates rejection from invoke", async () => {
+ invokeMock.mockRejectedValueOnce(new Error("plugin not loaded"));
+ await expect(pluginInvoke("missing", "cmd")).rejects.toThrow(
+ "plugin not loaded",
+ );
+ });
+});
diff --git a/vite.config.js b/vite.config.js
index 3ecfa0a7..3120b2ff 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,32 +1,63 @@
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
+import { execSync } from "child_process";
+import { readFileSync } from "fs";
-// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
-// https://vite.dev/config/
-export default defineConfig(async () => ({
- plugins: [sveltekit()],
+function getGitInfo() {
+ try {
+ return {
+ hash: execSync("git rev-parse HEAD").toString().trim(),
+ branch: execSync("git rev-parse --abbrev-ref HEAD").toString().trim(),
+ };
+ } catch {
+ return { hash: "unknown", branch: "unknown" };
+ }
+}
- // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
- //
- // 1. prevent Vite from obscuring rust errors
- clearScreen: false,
- // 2. tauri expects a fixed port, fail if that port is not available
- server: {
- port: 1420,
- strictPort: true,
- host: host || false,
- hmr: host
- ? {
- protocol: "ws",
- host,
- port: 1421,
- }
- : undefined,
- watch: {
- // 3. tell Vite to ignore watching `src-tauri`
- ignored: ["**/src-tauri/**"],
+function getVersion() {
+ try {
+ return JSON.parse(readFileSync("package.json", "utf8")).version;
+ } catch {
+ return "0.0.0";
+ }
+}
+
+// https://vite.dev/config/
+export default defineConfig(async () => {
+ const gitInfo = getGitInfo();
+
+ return {
+ plugins: [sveltekit()],
+
+ define: {
+ __COMMIT_HASH__: JSON.stringify(gitInfo.hash),
+ __GIT_BRANCH__: JSON.stringify(gitInfo.branch),
+ __APP_VERSION__: JSON.stringify(getVersion()),
+ __BUILD_DATE__: JSON.stringify(new Date().toISOString().split("T")[0]),
},
- },
-}));
+
+ // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
+ //
+ // 1. prevent Vite from obscuring rust errors
+ clearScreen: false,
+ // 2. tauri expects a fixed port, fail if that port is not available
+ server: {
+ port: 1420,
+ strictPort: true,
+ host: host || false,
+ hmr: host
+ ? {
+ protocol: "ws",
+ host,
+ port: 1421,
+ }
+ : undefined,
+ watch: {
+ // 3. tell Vite to ignore watching `src-tauri`
+ ignored: ["**/src-tauri/**"],
+ },
+ },
+ };
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 00000000..4e322410
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from "vitest/config";
+import path from "path";
+
+export default defineConfig({
+ test: {
+ include: ["src/**/*.test.ts"],
+ environment: "node",
+ globals: false,
+ },
+ resolve: {
+ alias: {
+ $lib: path.resolve(__dirname, "src/lib"),
+ $components: path.resolve(__dirname, "src/components"),
+ },
+ },
+ define: {
+ __COMMIT_HASH__: JSON.stringify("test-commit"),
+ __GIT_BRANCH__: JSON.stringify("test-branch"),
+ __APP_VERSION__: JSON.stringify("0.0.0-test"),
+ __BUILD_DATE__: JSON.stringify("2026-04-13"),
+ },
+});