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 4c95cf6..903c8c7 100644
--- a/package.json
+++ b/package.json
@@ -1,14 +1,13 @@
{
"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": {
- "torhunt": "./dist/cli.cjs"
+ "torhunt": "dist/cli.cjs"
},
"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..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,
@@ -160,27 +161,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 +179,7 @@ save(
โต
search
{` ${ICON.dot} `}
- empty
- โต
+ โฅ
browse
{` ${ICON.dot} `}
^c
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/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/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/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
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index fae2f07..a4647cb 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,
@@ -247,8 +248,18 @@ 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) {
+ sendNotification("torhunt โ Download Complete", cleanText(name));
+ }
if (queue.activeCount === 0 && queue.getItems().length === 0) {
releaseKeepAwake();
if (config.onComplete === "sleep") {
@@ -262,6 +273,7 @@ export function App({
return () => {
queue.off("change", updatePowerState);
+ queue.off("started", onStarted);
queue.off("completed", onCompleted);
releaseKeepAwake();
};
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/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}
);
diff --git a/src/ui/spinnerPresets.test.ts b/src/ui/spinnerPresets.test.ts
index 0d1fa0a..f0ebbe6 100644
--- a/src/ui/spinnerPresets.test.ts
+++ b/src/ui/spinnerPresets.test.ts
@@ -24,5 +24,6 @@ describe("spinnerPresets", () => {
expect(getSpinner("non-existent")).toEqual(DEFAULT_SPINNER);
expect(getSpinner("radar").id).toBe("radar");
expect(getSpinner("baton").id).toBe("baton");
+ expect(getSpinner("crt").id).toBe("crt");
});
});
diff --git a/src/ui/spinnerPresets.ts b/src/ui/spinnerPresets.ts
index 7101b8c..f9f1dc8 100644
--- a/src/ui/spinnerPresets.ts
+++ b/src/ui/spinnerPresets.ts
@@ -42,6 +42,13 @@ export const SPINNERS: readonly SpinnerPreset[] = [
frames: ["โฐโฑโฑ", "โฐโฐโฑ", "โฐโฐโฐ", "โฑโฐโฐ", "โฑโฑโฐ", "โฑโฑโฑ"],
intervalMs: 100,
},
+ {
+ id: "crt",
+ name: "CRT Scanline",
+ description: "Retro CRT monitor phosphor sweep",
+ frames: ["โโโโโโโ", "โโโโโโโ", "โโโโโโโ", "โโโโโโโ", "โโโโโโโ", "โโโโโโโ"],
+ intervalMs: 80,
+ },
] as const;
export const DEFAULT_SPINNER = SPINNERS.find((s) => s.id === "meter") ?? SPINNERS[0]!;
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..4a04f66
--- /dev/null
+++ b/src/util/notify.ts
@@ -0,0 +1,71 @@
+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, "").replace(/[\$()]/g, "");
+ const safeMessage = message.replace(/[<>&"'\\]/g, "").replace(/[\$()]/g, "");
+
+ try {
+ if (platform === "win32") {
+ // Windows 10/11 native WinRT 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 = 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], {
+ windowsHide: true,
+ stdio: "ignore",
+ });
+ child.on("error", () => {});
+ child.unref();
+ } else if (platform === "darwin") {
+ // macOS: Native AppleScript notification with system sound tone
+ const child = spawn(
+ "osascript",
+ ["-e", `display notification "${safeMessage}" with title "${safeTitle}" sound name "Glass"`],
+ { 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], { detached: true, stdio: "ignore" });
+ fallback.on("error", () => {});
+ fallback.unref();
+ });
+ child.unref();
+ }
+ } catch {
+ // Ignore if OS notification daemon is unavailable
+ }
+}