From 68e1e3cba737dfa97a292d88e59f871173edc8db Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 00:21:52 +0530
Subject: [PATCH 01/18] feat(ui): add smooth blinking cursor and remove preview
SVGs
---
package.json | 1 -
preview/browse.svg | 125 ------------------------
preview/downloads.svg | 158 -------------------------------
preview/splash.svg | 60 ------------
scripts/render-previews-impl.tsx | 19 +---
src/ui/components/TextField.tsx | 28 +++++-
6 files changed, 28 insertions(+), 363 deletions(-)
delete mode 100644 preview/browse.svg
delete mode 100644 preview/downloads.svg
delete mode 100644 preview/splash.svg
diff --git a/package.json b/package.json
index 4c95cf6..bdbadbc 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,6 @@
},
"files": [
"dist",
- "preview",
"scripts/ensure-webrtc.cjs"
],
"engines": {
diff --git a/preview/browse.svg b/preview/browse.svg
deleted file mode 100644
index 951a1c8..0000000
--- a/preview/browse.svg
+++ /dev/null
@@ -1,125 +0,0 @@
-
-
\ No newline at end of file
diff --git a/preview/downloads.svg b/preview/downloads.svg
deleted file mode 100644
index 55d6248..0000000
--- a/preview/downloads.svg
+++ /dev/null
@@ -1,158 +0,0 @@
-
-
\ No newline at end of file
diff --git a/preview/splash.svg b/preview/splash.svg
deleted file mode 100644
index bd904ab..0000000
--- a/preview/splash.svg
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
\ No newline at end of file
diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx
index 5c16724..02cd8e9 100644
--- a/scripts/render-previews-impl.tsx
+++ b/scripts/render-previews-impl.tsx
@@ -160,27 +160,17 @@ function save(
}
writeFileSync(
join(OUT_DIR, `${name}.svg`),
- ansiToSvg(frame, { cols: COLS, title: "torlink", ...extra }),
+ ansiToSvg(frame, { cols: COLS, title: "torhunt", ...extra }),
);
console.log(`preview/${name}.svg`);
}
-const CATEGORIES = sourcesByGroup()
- .map((g) => g.group.toLowerCase())
- .join(` ${ICON.dot} `);
-
save(
"splash",
makeStore({ view: "splash", region: "content" }),
-
+
-
- A curated, terminal-native torrent downloader.
-
-
- {CATEGORIES}
-
-
+
{}} />
@@ -188,8 +178,7 @@ save(
โต
search
{` ${ICON.dot} `}
- empty
- โต
+ โฅ
browse
{` ${ICON.dot} `}
^c
diff --git a/src/ui/components/TextField.tsx b/src/ui/components/TextField.tsx
index 2888555..d9233ad 100644
--- a/src/ui/components/TextField.tsx
+++ b/src/ui/components/TextField.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { Text, useInput } from "ink";
export interface TextFieldProps {
@@ -86,12 +86,22 @@ export function TextField({
const [value, setValue] = useState(defaultValue);
const [cursor, setCursor] = useState(defaultValue.length);
const [historyIdx, setHistoryIdx] = useState(-1);
+ const [cursorVisible, setCursorVisible] = useState(true);
const draftRef = useRef(defaultValue);
+ useEffect(() => {
+ if (isDisabled) return;
+ const interval = setInterval(() => {
+ setCursorVisible((v) => !v);
+ }, 530);
+ return () => clearInterval(interval);
+ }, [isDisabled]);
+
function apply(next: Edit): void {
setHistoryIdx(-1);
setValue(next.value);
setCursor(Math.max(0, Math.min(next.value.length, next.cursor)));
+ setCursorVisible(true);
if (next.value !== value) onChange?.(next.value);
}
@@ -149,10 +159,12 @@ export function TextField({
}
if (key.home) {
+ setCursorVisible(true);
setCursor(0);
return;
}
if (key.end) {
+ setCursorVisible(true);
setCursor(value.length);
return;
}
@@ -161,6 +173,7 @@ export function TextField({
// named keys arrive with an empty input, so they'd hit its default arm
// and vanish.
if (key.leftArrow) {
+ setCursorVisible(true);
if (key.ctrl || key.meta) {
setCursor(wordLeft(value, cursor));
return;
@@ -173,6 +186,7 @@ export function TextField({
return;
}
if (key.rightArrow) {
+ setCursorVisible(true);
if (key.ctrl || key.meta) {
setCursor(wordRight(value, cursor));
return;
@@ -201,9 +215,11 @@ export function TextField({
apply(killToEnd(value, cursor));
return;
case "a":
+ setCursorVisible(true);
setCursor(0);
return;
case "e":
+ setCursorVisible(true);
setCursor(value.length);
return;
// Every other ctrl combo is swallowed so views behind the field
@@ -238,12 +254,16 @@ export function TextField({
if (placeholder) {
return (
- {placeholder[0]}
+ {cursorVisible ? (
+ {placeholder[0]}
+ ) : (
+ {placeholder[0]}
+ )}
{placeholder.slice(1)}
);
}
- return {CURSOR};
+ return cursorVisible ? {CURSOR} : {CURSOR};
}
// Compute a viewport window that keeps the cursor visible.
@@ -267,7 +287,7 @@ export function TextField({
return (
{before}
- {atChar}
+ {cursorVisible ? {atChar} : {atChar}}
{after}
);
From 07ef9a54fd16142c25fbd62567b47e08e2c68fba Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 16:01:12 +0530
Subject: [PATCH 02/18] 1.9.1
---
package-lock.json | 4 ++--
package.json | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 9ecd3c8..d3f5c8c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "torlnk",
- "version": "1.9.0",
+ "version": "1.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "torlnk",
- "version": "1.9.0",
+ "version": "1.9.1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index bdbadbc..10ac48d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "torhunt",
- "version": "1.9.0",
+ "version": "1.9.1",
"description": "A sleek, zero-setup torrent finder and downloader that lives right in your terminal.",
"type": "module",
"bin": {
From 34f9c9359a00538279b34df4eb7735f501bc0ed8 Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 16:19:05 +0530
Subject: [PATCH 03/18] fix(package): normalize bin script path to avoid npm
publish warning
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 10ac48d..903c8c7 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"description": "A sleek, zero-setup torrent finder and downloader that lives right in your terminal.",
"type": "module",
"bin": {
- "torhunt": "./dist/cli.cjs"
+ "torhunt": "dist/cli.cjs"
},
"files": [
"dist",
From d328b90929684a818a4d7bb31b7f1273e8d9855c Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 17:09:55 +0530
Subject: [PATCH 04/18] feat(notifications): add native OS desktop alerts on
download completion
---
scripts/render-previews-impl.tsx | 1 +
src/config/config.ts | 6 +++
src/ui/App.tsx | 4 ++
src/ui/components/SettingsView.test.tsx | 1 +
src/ui/components/SettingsView.tsx | 31 ++++++++++++--
src/ui/testHarness.ts | 1 +
src/util/notify.test.ts | 10 +++++
src/util/notify.ts | 54 +++++++++++++++++++++++++
8 files changed, 105 insertions(+), 3 deletions(-)
create mode 100644 src/util/notify.test.ts
create mode 100644 src/util/notify.ts
diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx
index 02cd8e9..e80e0a4 100644
--- a/scripts/render-previews-impl.tsx
+++ b/scripts/render-previews-impl.tsx
@@ -89,6 +89,7 @@ function makeStore(
trackers: [],
preventSleep: true,
onComplete: "none",
+ notifyOnComplete: true,
} as Config,
setConfig: noop,
theme: DEFAULT_THEME,
diff --git a/src/config/config.ts b/src/config/config.ts
index 6e3ae30..a5f5e9f 100644
--- a/src/config/config.ts
+++ b/src/config/config.ts
@@ -11,6 +11,7 @@ export interface Config {
spinner: string;
preventSleep: boolean;
onComplete: OnCompleteAction;
+ notifyOnComplete: boolean;
}
export const defaultConfig: Config = {
@@ -20,6 +21,7 @@ export const defaultConfig: Config = {
spinner: "meter",
preventSleep: true,
onComplete: "none",
+ notifyOnComplete: true,
};
export async function loadConfig(): Promise {
@@ -52,6 +54,10 @@ export async function loadConfig(): Promise {
parsed.onComplete === "sleep" || parsed.onComplete === "shutdown" || parsed.onComplete === "none"
? parsed.onComplete
: defaultConfig.onComplete,
+ notifyOnComplete:
+ typeof parsed.notifyOnComplete === "boolean"
+ ? parsed.notifyOnComplete
+ : defaultConfig.notifyOnComplete,
};
return cfg;
} catch {
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index fae2f07..09c47b1 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -32,6 +32,7 @@ import {
triggerSleep,
triggerShutdown,
} from "../util/power";
+import { sendNotification } from "../util/notify";
import {
StoreContext,
type CaptureMode,
@@ -249,6 +250,9 @@ export function App({
const onCompleted = (name: string): void => {
setNotice(`${ICON.done} ${truncate(cleanText(name), 40)}`);
+ if (config.notifyOnComplete ?? true) {
+ sendNotification("torhunt โ Download Complete", cleanText(name));
+ }
if (queue.activeCount === 0 && queue.getItems().length === 0) {
releaseKeepAwake();
if (config.onComplete === "sleep") {
diff --git a/src/ui/components/SettingsView.test.tsx b/src/ui/components/SettingsView.test.tsx
index 226004e..8a627b6 100644
--- a/src/ui/components/SettingsView.test.tsx
+++ b/src/ui/components/SettingsView.test.tsx
@@ -19,6 +19,7 @@ describe("SettingsView", () => {
expect(ui.frame()).toContain("Color Theme:");
expect(ui.frame()).toContain("Spinner Style:");
expect(ui.frame()).toContain("Stay Awake:");
+ expect(ui.frame()).toContain("Desktop Alerts:");
expect(ui.frame()).toContain("On Queue Finish:");
expect(ui.frame()).toContain("v1.");
ui.unmount();
diff --git a/src/ui/components/SettingsView.tsx b/src/ui/components/SettingsView.tsx
index ae3f1b3..a62c679 100644
--- a/src/ui/components/SettingsView.tsx
+++ b/src/ui/components/SettingsView.tsx
@@ -38,6 +38,7 @@ export function SettingsView() {
{ id: "theme", label: "Color Theme" },
{ id: "spinner", label: "Spinner Loader" },
{ id: "preventSleep", label: "Stay Awake" },
+ { id: "notifyOnComplete", label: "Desktop Alerts" },
{ id: "onComplete", label: "When Finished" },
];
@@ -49,6 +50,14 @@ export function SettingsView() {
setNotice(nextVal ? "Stay Awake enabled: OS will not sleep while downloading" : "Stay Awake disabled: Standard OS sleep enabled");
};
+ const toggleNotifyOnComplete = () => {
+ const nextVal = !(config.notifyOnComplete ?? true);
+ const nextCfg = { ...config, notifyOnComplete: nextVal };
+ setConfig(nextCfg);
+ saveConfig(nextCfg);
+ setNotice(nextVal ? "Desktop Alerts enabled: OS notification when download completes" : "Desktop Alerts disabled");
+ };
+
const cycleOnComplete = () => {
const current = config.onComplete ?? "none";
const next: "none" | "sleep" | "shutdown" =
@@ -92,6 +101,8 @@ export function SettingsView() {
openSpinnerPicker();
} else if (item?.id === "preventSleep") {
togglePreventSleep();
+ } else if (item?.id === "notifyOnComplete") {
+ toggleNotifyOnComplete();
} else if (item?.id === "onComplete") {
cycleOnComplete();
}
@@ -180,11 +191,25 @@ export function SettingsView() {
- {/* Item 4: On Complete */}
+ {/* Item 4: Desktop Alerts */}
- {selectedIdx === 4 && focused ? "โ " : " "}On Queue Finish:
+ {selectedIdx === 4 && focused ? "โ " : " "}Desktop Alerts:
+
+
+
+
+ {(config.notifyOnComplete ?? true) ? "[Enabled]" : "[Disabled]"}
+
+
+
+
+ {/* Item 5: On Complete */}
+
+
+
+ {selectedIdx === 5 && focused ? "โ " : " "}On Queue Finish:
@@ -209,7 +234,7 @@ export function SettingsView() {
- Press โต to edit folder, pick theme/spinner, toggle stay awake, or cycle finish action.
+ Press โต to edit folder, pick theme/spinner, toggle stay awake/alerts, or cycle finish action.
diff --git a/src/ui/testHarness.ts b/src/ui/testHarness.ts
index c168a4f..73ad684 100644
--- a/src/ui/testHarness.ts
+++ b/src/ui/testHarness.ts
@@ -150,6 +150,7 @@ export function makeTestStore(overrides: Partial = {}): Store {
trackers: [],
preventSleep: true,
onComplete: "none",
+ notifyOnComplete: true,
} as Config,
setConfig: noop,
theme: DEFAULT_THEME,
diff --git a/src/util/notify.test.ts b/src/util/notify.test.ts
new file mode 100644
index 0000000..7ed5675
--- /dev/null
+++ b/src/util/notify.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from "vitest";
+import { sendNotification } from "./notify";
+
+describe("sendNotification utility", () => {
+ it("executes sendNotification without throwing errors or crashing", () => {
+ expect(() => {
+ sendNotification("torhunt", "Test notification message");
+ }).not.toThrow();
+ });
+});
diff --git a/src/util/notify.ts b/src/util/notify.ts
new file mode 100644
index 0000000..9b7ff8e
--- /dev/null
+++ b/src/util/notify.ts
@@ -0,0 +1,54 @@
+import { spawn } from "node:child_process";
+import os from "node:os";
+
+/**
+ * Sends a native OS desktop notification.
+ * - Windows: Uses PowerShell ToastNotification API with App ID 'torhunt'
+ * - macOS: Uses AppleScript display notification
+ * - Linux: Uses notify-send
+ */
+export function sendNotification(title: string, message: string): void {
+ const platform = os.platform();
+ const safeTitle = title.replace(/["'\\]/g, "");
+ const safeMessage = message.replace(/["'\\]/g, "");
+
+ try {
+ if (platform === "win32") {
+ // Windows 10/11 native XML Toast Notification
+ const script = `
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
+ $template = @"
+
+
+
+ ${safeTitle}
+ ${safeMessage}
+
+
+
+"@
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
+ $xml.LoadXml($template)
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("torhunt").Show($toast)
+ `;
+ spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
+ windowsHide: true,
+ stdio: "ignore",
+ }).unref();
+ } else if (platform === "darwin") {
+ spawn(
+ "osascript",
+ ["-e", `display notification "${safeMessage}" with title "${safeTitle}"`],
+ { stdio: "ignore" },
+ ).unref();
+ } else if (platform === "linux") {
+ spawn("notify-send", [safeTitle, safeMessage], {
+ stdio: "ignore",
+ }).unref();
+ }
+ } catch {
+ // Ignore if OS notification daemon is unavailable
+ }
+}
From dab972624be2fd4157720f66b978d0917ec1d7ab Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 17:35:54 +0530
Subject: [PATCH 05/18] fix(notifications): encode powershell script and use
registered AppID for Windows toast
---
src/util/notify.ts | 20 +++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/src/util/notify.ts b/src/util/notify.ts
index 9b7ff8e..9751b89 100644
--- a/src/util/notify.ts
+++ b/src/util/notify.ts
@@ -16,9 +16,9 @@ export function sendNotification(title: string, message: string): void {
if (platform === "win32") {
// Windows 10/11 native XML Toast Notification
const script = `
- [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
- [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
- $template = @"
+[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
+[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
+$template = @"
@@ -28,12 +28,14 @@ export function sendNotification(title: string, message: string): void {
"@
- $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
- $xml.LoadXml($template)
- $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
- [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("torhunt").Show($toast)
- `;
- spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
+$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
+$xml.LoadXml($template)
+$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
+$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'
+[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
+`;
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
+ spawn("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
windowsHide: true,
stdio: "ignore",
}).unref();
From ccd6cc8b51a8017c041ab1c4c5d306688a3abb08 Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 17:41:25 +0530
Subject: [PATCH 06/18] feat(notifications): add native macOS and Linux
notification support with sound and app tags
---
src/util/notify.ts | 27 ++++++++++++++++++++-------
1 file changed, 20 insertions(+), 7 deletions(-)
diff --git a/src/util/notify.ts b/src/util/notify.ts
index 9751b89..f2647e7 100644
--- a/src/util/notify.ts
+++ b/src/util/notify.ts
@@ -35,20 +35,33 @@ $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\power
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
`;
const encoded = Buffer.from(script, "utf16le").toString("base64");
- spawn("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
+ const child = spawn("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
windowsHide: true,
stdio: "ignore",
- }).unref();
+ });
+ child.on("error", () => {});
+ child.unref();
} else if (platform === "darwin") {
- spawn(
+ // macOS: Native AppleScript notification with system sound tone
+ const child = spawn(
"osascript",
- ["-e", `display notification "${safeMessage}" with title "${safeTitle}"`],
+ ["-e", `display notification "${safeMessage}" with title "${safeTitle}" sound name "Glass"`],
{ stdio: "ignore" },
- ).unref();
+ );
+ child.on("error", () => {});
+ child.unref();
} else if (platform === "linux") {
- spawn("notify-send", [safeTitle, safeMessage], {
+ // Linux: notify-send with -a torhunt app tag and normal urgency
+ const child = spawn("notify-send", ["-a", "torhunt", "-u", "normal", safeTitle, safeMessage], {
stdio: "ignore",
- }).unref();
+ });
+ child.on("error", () => {
+ // Fallback for older libnotify versions without -a flag
+ const fallback = spawn("notify-send", [safeTitle, safeMessage], { stdio: "ignore" });
+ fallback.on("error", () => {});
+ fallback.unref();
+ });
+ child.unref();
}
} catch {
// Ignore if OS notification daemon is unavailable
From a31b4dfba77a69d37905f0a8871111aed2cb38d0 Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 17:47:08 +0530
Subject: [PATCH 07/18] fix(notifications): sanitize XML special characters and
parentheses for PowerShell toast
---
src/util/notify.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/util/notify.ts b/src/util/notify.ts
index f2647e7..227247c 100644
--- a/src/util/notify.ts
+++ b/src/util/notify.ts
@@ -9,8 +9,8 @@ import os from "node:os";
*/
export function sendNotification(title: string, message: string): void {
const platform = os.platform();
- const safeTitle = title.replace(/["'\\]/g, "");
- const safeMessage = message.replace(/["'\\]/g, "");
+ const safeTitle = title.replace(/[<>&"'\\]/g, "").replace(/[\$()]/g, "");
+ const safeMessage = message.replace(/[<>&"'\\]/g, "").replace(/[\$()]/g, "");
try {
if (platform === "win32") {
From 1e8e4c1a6cbc88b38467c3b87b1626156789af3e Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 17:50:32 +0530
Subject: [PATCH 08/18] feat(cli): add notify-test subcommand for instant
cross-platform desktop alert testing
---
src/cli/args.ts | 3 +++
src/index.tsx | 6 ++++++
2 files changed, 9 insertions(+)
diff --git a/src/cli/args.ts b/src/cli/args.ts
index 4754d63..6930bf4 100644
--- a/src/cli/args.ts
+++ b/src/cli/args.ts
@@ -26,6 +26,7 @@ export type CliCommand =
| { kind: "files"; port?: number; host?: string; token?: string; dir?: string; daemon?: boolean }
| { kind: "attach" }
| { kind: "update"; force?: boolean }
+ | { kind: "notify-test" }
| { kind: "invalid"; arg: string };
// Valueless boolean flags for the headless subcommands (everything else is a
@@ -76,6 +77,7 @@ export function parseCliArgs(argv: string[]): CliCommand {
if (a === "--version" || a === "-v") return { kind: "version" };
if (a === "--help" || a === "-h") return { kind: "help" };
if (a === "attach") return { kind: "attach" };
+ if (a === "notify-test" || a === "--notify-test") return { kind: "notify-test" };
if (a === "update") return { kind: "update", force: args.slice(1).includes("--force") };
if (a === "watch") {
const { bools, rest: r0 } = splitBooleans(args.slice(1));
@@ -135,6 +137,7 @@ usage
torhunt attach open/reattach the TUI in a persistent tmux session
torhunt update [--force] update to the latest release and restart any daemon
(--force rebuilds/restarts even if already current)
+ torhunt notify-test test native OS desktop notifications
torhunt --version print the version
torhunt --help print this help message
diff --git a/src/index.tsx b/src/index.tsx
index 49dbad6..0a88d09 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -74,6 +74,12 @@ if (cmd.kind === "update") {
dir: cmd.dir,
};
void import("./daemon/files").then(({ runFiles }) => runFiles(options).catch(failHeadless));
+} else if (cmd.kind === "notify-test") {
+ void import("./util/notify").then(({ sendNotification }) => {
+ sendNotification("torhunt โ Test Notification", "Desktop notifications are working cleanly!");
+ console.log("โ Notification test dispatched to your OS.");
+ process.exit(0);
+ });
} else {
// Enter the alt-screen and hide the hardware cursor: the TUI draws its own
From 3e5cc1bc7dda28afe27bad76ff425e1d36c1f7da Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 18:06:04 +0530
Subject: [PATCH 09/18] fix(notifications): use System.Windows.Forms NotifyIcon
balloon tip and SystemSound for reliable Windows notifications
---
src/util/notify.ts | 30 ++++++++++++------------------
1 file changed, 12 insertions(+), 18 deletions(-)
diff --git a/src/util/notify.ts b/src/util/notify.ts
index 227247c..26ab246 100644
--- a/src/util/notify.ts
+++ b/src/util/notify.ts
@@ -14,25 +14,19 @@ export function sendNotification(title: string, message: string): void {
try {
if (platform === "win32") {
- // Windows 10/11 native XML Toast Notification
+ // Windows 10/11 system tray notification balloon with OS audio chime
const script = `
-[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
-[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
-$template = @"
-
-
-
- ${safeTitle}
- ${safeMessage}
-
-
-
-"@
-$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
-$xml.LoadXml($template)
-$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
-$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'
-[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
+Add-Type -AssemblyName System.Windows.Forms
+Add-Type -AssemblyName System.Drawing
+try { [System.Media.SystemSounds]::Asterisk.Play() } catch {}
+$icon = [System.Drawing.SystemIcons]::Information
+$notify = New-Object System.Windows.Forms.NotifyIcon
+$notify.Icon = $icon
+$notify.Text = "torhunt"
+$notify.Visible = $true
+$notify.ShowBalloonTip(5000, "${safeTitle}", "${safeMessage}", [System.Windows.Forms.ToolTipIcon]::Info)
+Start-Sleep -s 5
+$notify.Dispose()
`;
const encoded = Buffer.from(script, "utf16le").toString("base64");
const child = spawn("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
From 3834cafb18ded29a7caeda570ddc69fe92702536 Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 18:28:51 +0530
Subject: [PATCH 10/18] fix(notifications): spawn notification processes
detached so short-lived CLI calls do not kill them
---
src/util/notify.ts | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/util/notify.ts b/src/util/notify.ts
index 26ab246..87f5dd5 100644
--- a/src/util/notify.ts
+++ b/src/util/notify.ts
@@ -30,6 +30,7 @@ $notify.Dispose()
`;
const encoded = Buffer.from(script, "utf16le").toString("base64");
const child = spawn("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
+ detached: true,
windowsHide: true,
stdio: "ignore",
});
@@ -40,18 +41,19 @@ $notify.Dispose()
const child = spawn(
"osascript",
["-e", `display notification "${safeMessage}" with title "${safeTitle}" sound name "Glass"`],
- { stdio: "ignore" },
+ { detached: true, stdio: "ignore" },
);
child.on("error", () => {});
child.unref();
} else if (platform === "linux") {
// Linux: notify-send with -a torhunt app tag and normal urgency
const child = spawn("notify-send", ["-a", "torhunt", "-u", "normal", safeTitle, safeMessage], {
+ detached: true,
stdio: "ignore",
});
child.on("error", () => {
// Fallback for older libnotify versions without -a flag
- const fallback = spawn("notify-send", [safeTitle, safeMessage], { stdio: "ignore" });
+ const fallback = spawn("notify-send", [safeTitle, safeMessage], { detached: true, stdio: "ignore" });
fallback.on("error", () => {});
fallback.unref();
});
From 2404e64279735766e5835a58d63fa4dce0bc6bee Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 18:43:39 +0530
Subject: [PATCH 11/18] feat(notifications): trigger desktop notifications on
download start and completion
---
src/download/queue.ts | 2 ++
src/ui/App.tsx | 8 ++++++++
2 files changed, 10 insertions(+)
diff --git a/src/download/queue.ts b/src/download/queue.ts
index d80d619..575a463 100644
--- a/src/download/queue.ts
+++ b/src/download/queue.ts
@@ -153,6 +153,7 @@ export class DownloadQueue extends EventEmitter {
if (start) {
this.startEngine(item);
this.ensurePoll();
+ this.emit("started", item.name);
}
this.changed();
void this.persist();
@@ -194,6 +195,7 @@ export class DownloadQueue extends EventEmitter {
next.status = "downloading";
next.speed = 0;
this.startEngine(next);
+ this.emit("started", next.name);
started = true;
}
if (started) {
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 09c47b1..a4647cb 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -248,6 +248,13 @@ export function App({
updatePowerState();
queue.on("change", updatePowerState);
+ const onStarted = (name: string): void => {
+ if (config.notifyOnComplete ?? true) {
+ sendNotification("torhunt โ Download Started", cleanText(name));
+ }
+ };
+ queue.on("started", onStarted);
+
const onCompleted = (name: string): void => {
setNotice(`${ICON.done} ${truncate(cleanText(name), 40)}`);
if (config.notifyOnComplete ?? true) {
@@ -266,6 +273,7 @@ export function App({
return () => {
queue.off("change", updatePowerState);
+ queue.off("started", onStarted);
queue.off("completed", onCompleted);
releaseKeepAwake();
};
From bb5c59fe7fbe35436918e6f2065658ed30424a89 Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 19:04:15 +0530
Subject: [PATCH 12/18] fix(notifications): use
Windows.SystemToast.Notification AppID for reliable native Windows 10/11
toast popups
---
src/util/notify.ts | 32 +++++++++++++++++++-------------
1 file changed, 19 insertions(+), 13 deletions(-)
diff --git a/src/util/notify.ts b/src/util/notify.ts
index 87f5dd5..4a04f66 100644
--- a/src/util/notify.ts
+++ b/src/util/notify.ts
@@ -14,23 +14,29 @@ export function sendNotification(title: string, message: string): void {
try {
if (platform === "win32") {
- // Windows 10/11 system tray notification balloon with OS audio chime
+ // Windows 10/11 native WinRT Toast Notification
const script = `
-Add-Type -AssemblyName System.Windows.Forms
-Add-Type -AssemblyName System.Drawing
-try { [System.Media.SystemSounds]::Asterisk.Play() } catch {}
-$icon = [System.Drawing.SystemIcons]::Information
-$notify = New-Object System.Windows.Forms.NotifyIcon
-$notify.Icon = $icon
-$notify.Text = "torhunt"
-$notify.Visible = $true
-$notify.ShowBalloonTip(5000, "${safeTitle}", "${safeMessage}", [System.Windows.Forms.ToolTipIcon]::Info)
-Start-Sleep -s 5
-$notify.Dispose()
+[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
+[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
+$template = @"
+
+
+
+ ${safeTitle}
+ ${safeMessage}
+
+
+
+"@
+$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
+$xml.LoadXml($template)
+$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
+$appId = 'Windows.SystemToast.Notification'
+$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId)
+$notifier.Show($toast)
`;
const encoded = Buffer.from(script, "utf16le").toString("base64");
const child = spawn("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
- detached: true,
windowsHide: true,
stdio: "ignore",
});
From d3a774d1a3d072bd993563e97e74c910f68adbee Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 19:33:15 +0530
Subject: [PATCH 13/18] feat(ui): add retro loader animations (Retro Cassette,
CRT Scanline, 8-Bit Arcade, Synthwave Grid)
---
src/ui/spinnerPresets.test.ts | 4 ++++
src/ui/spinnerPresets.ts | 28 ++++++++++++++++++++++++++++
2 files changed, 32 insertions(+)
diff --git a/src/ui/spinnerPresets.test.ts b/src/ui/spinnerPresets.test.ts
index 0d1fa0a..38ee193 100644
--- a/src/ui/spinnerPresets.test.ts
+++ b/src/ui/spinnerPresets.test.ts
@@ -24,5 +24,9 @@ describe("spinnerPresets", () => {
expect(getSpinner("non-existent")).toEqual(DEFAULT_SPINNER);
expect(getSpinner("radar").id).toBe("radar");
expect(getSpinner("baton").id).toBe("baton");
+ expect(getSpinner("cassette").id).toBe("cassette");
+ expect(getSpinner("crt").id).toBe("crt");
+ expect(getSpinner("arcade").id).toBe("arcade");
+ expect(getSpinner("synthwave").id).toBe("synthwave");
});
});
diff --git a/src/ui/spinnerPresets.ts b/src/ui/spinnerPresets.ts
index 7101b8c..add71ef 100644
--- a/src/ui/spinnerPresets.ts
+++ b/src/ui/spinnerPresets.ts
@@ -42,6 +42,34 @@ export const SPINNERS: readonly SpinnerPreset[] = [
frames: ["โฐโฑโฑ", "โฐโฐโฑ", "โฐโฐโฐ", "โฑโฐโฐ", "โฑโฑโฐ", "โฑโฑโฑ"],
intervalMs: 100,
},
+ {
+ id: "cassette",
+ name: "Retro Cassette",
+ description: "Analog cassette tape reel spinning",
+ frames: ["[ โ โฏ ]", "[ โ โฏ ]", "[ โ โฏ ]", "[ โ โฏ ]"],
+ intervalMs: 100,
+ },
+ {
+ id: "crt",
+ name: "CRT Scanline",
+ description: "Retro CRT monitor phosphor sweep",
+ frames: ["โโโโโโโ", "โโโโโโโ", "โโโโโโโ", "โโโโโโโ", "โโโโโโโ", "โโโโโโโ"],
+ intervalMs: 80,
+ },
+ {
+ id: "arcade",
+ name: "Retro Arcade",
+ description: "8-bit arcade bounce scanner",
+ frames: ["[C ]", "[ C ]", "[ C ]", "[ C ]", "[ C ]", "[ C]", "[ C ]", "[ C ]", "[ C ]", "[ C ]"],
+ intervalMs: 80,
+ },
+ {
+ id: "synthwave",
+ name: "Synthwave Grid",
+ description: "Neon synthwave neon pulse grid",
+ frames: ["โฒ โณ โณ โณ", "โณ โฒ โณ โณ", "โณ โณ โฒ โณ", "โณ โณ โณ โฒ", "โณ โณ โฒ โณ", "โณ โฒ โณ โณ"],
+ intervalMs: 90,
+ },
] as const;
export const DEFAULT_SPINNER = SPINNERS.find((s) => s.id === "meter") ?? SPINNERS[0]!;
From ae6a6d2373ac2c144e89a5baecfefb9e1c12925a Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 19:38:58 +0530
Subject: [PATCH 14/18] feat(ui): add Retro Pac-Man spinner, keep CRT Scanline,
and remove cassette/arcade/synthwave
---
src/ui/spinnerPresets.test.ts | 4 +---
src/ui/spinnerPresets.ts | 24 +++++-------------------
2 files changed, 6 insertions(+), 22 deletions(-)
diff --git a/src/ui/spinnerPresets.test.ts b/src/ui/spinnerPresets.test.ts
index 38ee193..4905c96 100644
--- a/src/ui/spinnerPresets.test.ts
+++ b/src/ui/spinnerPresets.test.ts
@@ -24,9 +24,7 @@ describe("spinnerPresets", () => {
expect(getSpinner("non-existent")).toEqual(DEFAULT_SPINNER);
expect(getSpinner("radar").id).toBe("radar");
expect(getSpinner("baton").id).toBe("baton");
- expect(getSpinner("cassette").id).toBe("cassette");
expect(getSpinner("crt").id).toBe("crt");
- expect(getSpinner("arcade").id).toBe("arcade");
- expect(getSpinner("synthwave").id).toBe("synthwave");
+ expect(getSpinner("pacman").id).toBe("pacman");
});
});
diff --git a/src/ui/spinnerPresets.ts b/src/ui/spinnerPresets.ts
index add71ef..1f93538 100644
--- a/src/ui/spinnerPresets.ts
+++ b/src/ui/spinnerPresets.ts
@@ -42,13 +42,6 @@ export const SPINNERS: readonly SpinnerPreset[] = [
frames: ["โฐโฑโฑ", "โฐโฐโฑ", "โฐโฐโฐ", "โฑโฐโฐ", "โฑโฑโฐ", "โฑโฑโฑ"],
intervalMs: 100,
},
- {
- id: "cassette",
- name: "Retro Cassette",
- description: "Analog cassette tape reel spinning",
- frames: ["[ โ โฏ ]", "[ โ โฏ ]", "[ โ โฏ ]", "[ โ โฏ ]"],
- intervalMs: 100,
- },
{
id: "crt",
name: "CRT Scanline",
@@ -57,18 +50,11 @@ export const SPINNERS: readonly SpinnerPreset[] = [
intervalMs: 80,
},
{
- id: "arcade",
- name: "Retro Arcade",
- description: "8-bit arcade bounce scanner",
- frames: ["[C ]", "[ C ]", "[ C ]", "[ C ]", "[ C ]", "[ C]", "[ C ]", "[ C ]", "[ C ]", "[ C ]"],
- intervalMs: 80,
- },
- {
- id: "synthwave",
- name: "Synthwave Grid",
- description: "Neon synthwave neon pulse grid",
- frames: ["โฒ โณ โณ โณ", "โณ โฒ โณ โณ", "โณ โณ โฒ โณ", "โณ โณ โณ โฒ", "โณ โณ โฒ โณ", "โณ โฒ โณ โณ"],
- intervalMs: 90,
+ id: "pacman",
+ name: "Retro Pac-Man",
+ description: "8-bit Pac-Man chomping dots",
+ frames: ["C โข โข โข", "c โข โข โข", " C โข โข", " c โข โข", " C โข", " c โข", " C", " c"],
+ intervalMs: 110,
},
] as const;
From 222fca210bf943454fbab770a078bdfd016f457a Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 19:41:28 +0530
Subject: [PATCH 15/18] feat(ui): update Pac-Man loader to stationary chomper
eating incoming dots
---
src/ui/spinnerPresets.ts | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/src/ui/spinnerPresets.ts b/src/ui/spinnerPresets.ts
index 1f93538..97d2e34 100644
--- a/src/ui/spinnerPresets.ts
+++ b/src/ui/spinnerPresets.ts
@@ -52,9 +52,14 @@ export const SPINNERS: readonly SpinnerPreset[] = [
{
id: "pacman",
name: "Retro Pac-Man",
- description: "8-bit Pac-Man chomping dots",
- frames: ["C โข โข โข", "c โข โข โข", " C โข โข", " c โข โข", " C โข", " c โข", " C", " c"],
- intervalMs: 110,
+ description: "Stationary Pac-Man chomping incoming dots",
+ frames: [
+ "C โข โข โข",
+ "c โข โข",
+ "C โข",
+ "c ",
+ ],
+ intervalMs: 120,
},
] as const;
From b52788e75ae920704ed3f5d1f9b1da7f4fb0b507 Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 19:45:06 +0530
Subject: [PATCH 16/18] feat(ui): update Dot Matrix LED spinner with a 25-dot
wave animation grid
---
src/ui/spinnerPresets.ts | 21 +++++++++++++--------
1 file changed, 13 insertions(+), 8 deletions(-)
diff --git a/src/ui/spinnerPresets.ts b/src/ui/spinnerPresets.ts
index 97d2e34..b8faf56 100644
--- a/src/ui/spinnerPresets.ts
+++ b/src/ui/spinnerPresets.ts
@@ -50,16 +50,21 @@ export const SPINNERS: readonly SpinnerPreset[] = [
intervalMs: 80,
},
{
- id: "pacman",
- name: "Retro Pac-Man",
- description: "Stationary Pac-Man chomping incoming dots",
+ id: "matrix-dot",
+ name: "Dot Matrix LED",
+ description: "25-dot LED matrix wave and pulse grid",
frames: [
- "C โข โข โข",
- "c โข โข",
- "C โข",
- "c ",
+ "โ โ โ โ โ",
+ "โ โ โ โ โ",
+ "โ โ โ โ โ",
+ "โ โ โ โ โ",
+ "โ โ โ โ โ",
+ "โ โ โ โ โ",
+ "โ โ โ โ โ",
+ "โ โ โ โ โ",
+ "โ โ โ โ โ",
],
- intervalMs: 120,
+ intervalMs: 70,
},
] as const;
From 3484f3b06b9d56e532f6ada5cfb01e3a0f18e249 Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 19:45:49 +0530
Subject: [PATCH 17/18] fix(test): update test assertion for matrix-dot preset
---
src/ui/spinnerPresets.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/ui/spinnerPresets.test.ts b/src/ui/spinnerPresets.test.ts
index 4905c96..22823c9 100644
--- a/src/ui/spinnerPresets.test.ts
+++ b/src/ui/spinnerPresets.test.ts
@@ -25,6 +25,6 @@ describe("spinnerPresets", () => {
expect(getSpinner("radar").id).toBe("radar");
expect(getSpinner("baton").id).toBe("baton");
expect(getSpinner("crt").id).toBe("crt");
- expect(getSpinner("pacman").id).toBe("pacman");
+ expect(getSpinner("matrix-dot").id).toBe("matrix-dot");
});
});
From 0a85745ee20d4a7dd49fc0e387e1b5fabcf4ad58 Mon Sep 17 00:00:00 2001
From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com>
Date: Tue, 11 Aug 2026 19:51:09 +0530
Subject: [PATCH 18/18] fix(ui): remove matrix-dot preset; keep CRT Scanline
and classic retro spinners
---
src/ui/spinnerPresets.test.ts | 1 -
src/ui/spinnerPresets.ts | 17 -----------------
2 files changed, 18 deletions(-)
diff --git a/src/ui/spinnerPresets.test.ts b/src/ui/spinnerPresets.test.ts
index 22823c9..f0ebbe6 100644
--- a/src/ui/spinnerPresets.test.ts
+++ b/src/ui/spinnerPresets.test.ts
@@ -25,6 +25,5 @@ describe("spinnerPresets", () => {
expect(getSpinner("radar").id).toBe("radar");
expect(getSpinner("baton").id).toBe("baton");
expect(getSpinner("crt").id).toBe("crt");
- expect(getSpinner("matrix-dot").id).toBe("matrix-dot");
});
});
diff --git a/src/ui/spinnerPresets.ts b/src/ui/spinnerPresets.ts
index b8faf56..f9f1dc8 100644
--- a/src/ui/spinnerPresets.ts
+++ b/src/ui/spinnerPresets.ts
@@ -49,23 +49,6 @@ export const SPINNERS: readonly SpinnerPreset[] = [
frames: ["โโโโโโโ", "โโโโโโโ", "โโโโโโโ", "โโโโโโโ", "โโโโโโโ", "โโโโโโโ"],
intervalMs: 80,
},
- {
- id: "matrix-dot",
- name: "Dot Matrix LED",
- description: "25-dot LED matrix wave and pulse grid",
- frames: [
- "โ โ โ โ โ",
- "โ โ โ โ โ",
- "โ โ โ โ โ",
- "โ โ โ โ โ",
- "โ โ โ โ โ",
- "โ โ โ โ โ",
- "โ โ โ โ โ",
- "โ โ โ โ โ",
- "โ โ โ โ โ",
- ],
- intervalMs: 70,
- },
] as const;
export const DEFAULT_SPINNER = SPINNERS.find((s) => s.id === "meter") ?? SPINNERS[0]!;