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 @@ - - - - - torlink - - ๐“ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - All - โ•ญโ”€ - Search - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ - Games - - โฏ - Search or paste a magnet linkโ€ฆ - - Movies - โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ - TV - Anime - โ•ญโ”€ - Latest (5) - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ - - newest across all sources - - Downloads - - - Seeding - - # - Name - Size - Seed:Lch - Src - - - โฏ - 1 - Oppenheimer (2023) [1080pโ€ฆ - 1.96 GB - 1240:88 - YTS - - - 2 - Dune: Part Two (2024) [21โ€ฆ - 7.82 GB - 910:41 - YTS - - - 3 - Breaking Bad S05E14 1080pโ€ฆ - 1.49 GB - 540:31 - EZTV - - - 4 - [Erai-raws] Jujutsu Kaiseโ€ฆ - 1.21 GB - 320:12 - NYAA - - - 5 - Frieren - 28 [1080p] - 1.30 GB - - - SUB - - โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ - โ†‘โ†“โ†โ†’ - Move - d - Download - y - Copy - s - Sort - / - Search - tab - Switch - ? - Keys - - \ 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 @@ - - - - - torlink - - ๐“ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - All - โ•ญโ”€ - Downloads (1) - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ - Games - - โฏ - โ†“ - Dune: Part Two (2024) [2160p BluRay] - 7.82 GB - YTS - - Movies - - - - - - - - - - - - - - - - โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ - 64% 7.7 MB/s โ€ข41 6m - - TV - - - Anime - - Recently downloaded (2) - - - โœ“ - Elden Ring: Shadow of tโ€ฆ - 50.29 GB - 1hr ago - FG - - - Downloads - (1) - - โœ“ - Breaking Bad S05E14 108โ€ฆ - 1.49 GB - 1d 1hr ago - EZTV - - Seeding - - - - - โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ - p - Pause - c - Cancel - e - Folder - s - Export - tab - Switch - ? - Keys - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ 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 @@ - - - - - torlink - - ๐“ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - A curated, terminal-native torrent downloader. - games ยท movies ยท tv ยท anime - โ•ญโ”€ - Search - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ - - โฏ - - S - earch or paste a magnet linkโ€ฆ - - โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ - โ†ต - search ยท empty - โ†ต - browse ยท - ^c - quit - - \ 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 + } +}