/** * SourceManager — exposes podcast sources as SettingItems for the depth-stack. * * • "Add Source" — an editor item; drilling in shows a name/URL add form. * • Each source — a toggle item (Space toggles enabled) whose display shows * the source type and on/off state. * * Advanced per-API-source options (country/language/explicit) are flattened to * simple toggles/cycles reachable by drilling into the source's editor. * Movement flows through nav.action — no own useKeyboard (avoids the old * right-pane key conflicts). */ import { createSignal, For, Show, onMount } from "solid-js"; import { Renderable } from "@opentui/core"; import { useFeedStore } from "@/stores/feed"; import { useTheme } from "@/context/ThemeContext"; import { useInputFocusNav } from "@/hooks/useInputFocusNav"; import { useDialog } from "@/ui/dialog"; import { useToast } from "@/ui/toast"; import { resolveSourceCredentials, savePodcastIndexCredentials, } from "@/utils/source-credentials"; import { SourceType } from "@/types/source"; import type { PodcastSource } from "@/types/source"; import type { SettingItem } from "./types"; export function useSourceItems(): SettingItem[] { const feedStore = useFeedStore(); const dialog = useDialog(); const typeBadge = (s: PodcastSource) => s.type === SourceType.API ? "[API]" : s.type === SourceType.RSS ? "[RSS]" : "[?]"; const items: SettingItem[] = [ { id: "add", label: "Add Source", kind: "editor", display: () => "+", help: () => `Add a custom RSS feed by URL.\nDrill in (Enter/l) to open the add-source form.\nType: editor`, renderEditor: () => , }, ]; for (const s of feedStore.sources()) { items.push({ id: `src:${s.id}`, label: s.name, kind: "toggle", display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`, help: () => s.id === "podcastindex" ? `Source: ${s.name} (open podcast directory)\nEnabled: ${s.enabled}\nSpace to ${s.enabled ? "disable" : "enable"}: enabling asks for API keys.\nKeys are masked in the UI and stored in the macOS keychain\n(encrypted at rest), falling back to config.json when the\nkeychain is unavailable; they are kept when disabled.` : `Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`, toggle: () => { // Enabling Podcast Index requires credentials: ask first // (prefilled with the stored key, masked) instead of flipping // the source into a key-less "on" state. Disabling never // clears the stored credentials. if (s.id === "podcastindex" && !s.enabled) { dialog.push(() => ); return; } feedStore.toggleSource(s.id); }, }); } return items; } function AddSourceForm() { const feedStore = useFeedStore(); const { theme } = useTheme(); const [name, setName] = createSignal(""); const [url, setUrl] = createSignal(""); const [error, setError] = createSignal(null); // Yield navigation keybinds to the Shell router while either input is focused. const nameRef = useInputFocusNav(); const urlRef = useInputFocusNav(); const submit = () => { const u = url().trim(); if (!u) { setError("URL is required"); return; } try { new URL(u); } catch { setError("Invalid URL format"); return; } feedStore.addSource({ name: name().trim() || "Custom Source", type: SourceType.RSS, baseUrl: u, enabled: true, description: `Custom RSS feed: ${u}`, }); setName(""); setUrl(""); setError(null); }; return ( Add Source Name: URL: { setUrl(v); setError(null); }} placeholder="https://example.com/feed.rss" width={35} textColor={theme.text} focusedTextColor={theme.accent} cursorColor={theme.accent} /> [+] Add {(e) => {e()}} 0}> Current sources ({feedStore.sources().length}): {(s) => ( {s.enabled ? "●" : "○"} {s.name} )} ); } /** Mask a stored credential for prefill: first 3 chars then "...". */ const maskCredential = (value: string): string => `${value.slice(0, 3)}...`; /** Credentials popup shown when enabling the Podcast Index source. Prefilled * (masked) with stored credentials so re-enabling just needs Enter; leaving * a masked field untouched keeps the stored value. Credentials are saved to * the macOS keychain (encrypted at rest) with a plaintext config.json * fallback when the keychain is unavailable. */ function PodcastIndexCredentialsDialog() { const feedStore = useFeedStore(); const { theme } = useTheme(); const dialog = useDialog(); const toast = useToast(); const source = feedStore.sources().find((s) => s.id === "podcastindex"); const [key, setKey] = createSignal(""); const [secret, setSecret] = createSignal(""); const [error, setError] = createSignal(null); const [saving, setSaving] = createSignal(false); // Yield navigation keybinds to the Shell router while an input is focused. const keyRef = useInputFocusNav(); const secretRef = useInputFocusNav(); let keyEl: Renderable | null | undefined; let secretEl: Renderable | null | undefined; onMount(() => { // Prefill stored credentials (masked) when re-enabling after a // disable — toggling off never clears them. Masked either way, so a // plaintext-stored key never appears in full in the UI. if (source) { resolveSourceCredentials(source) .then((stored) => { if (stored?.apiKey) setKey(maskCredential(stored.apiKey)); if (stored?.apiSecret) setSecret(maskCredential(stored.apiSecret)); }) .catch(() => {}); } setTimeout(() => keyEl?.focus(), 1); }); const save = async () => { if (saving()) return; const stored = source ? await resolveSourceCredentials(source).catch(() => null) : null; const keyValue = key().trim(); const secretValue = secret().trim(); // A field still showing its masked prefill means "keep what's stored". const apiKey = stored?.apiKey && keyValue === maskCredential(stored.apiKey) ? stored.apiKey : keyValue; const apiSecret = stored?.apiSecret && secretValue === maskCredential(stored.apiSecret) ? stored.apiSecret : secretValue; if (!apiKey || !apiSecret) { setError( "Both API key and secret are required (free at podcastindex.org)", ); return; } setSaving(true); const ok = await savePodcastIndexCredentials(apiKey, apiSecret).catch( () => false, ); setSaving(false); if (!ok) { // Keychain unavailable (non-macOS, locked, sandboxed): plaintext // fallback on the source so the fallback search still works. feedStore.updateSource("podcastindex", { hasCredentials: true, credentialStorage: "plaintext", apiKey, apiSecret, enabled: true, }); toast.show({ title: "Credentials stored in config.json", message: "macOS keychain unavailable — API keys saved unencrypted.", variant: "warning", }); dialog.pop(); return; } feedStore.updateSource("podcastindex", { hasCredentials: true, credentialStorage: "keychain", enabled: true, }); dialog.pop(); }; return ( Free key + secret from https://podcastindex.org/. Used as a fallback when other sources return fewer than 3 results. API Key: { keyRef(el); keyEl = el; }} value={key()} onInput={setKey} onSubmit={() => secretEl?.focus()} placeholder="e.g. UXKCGDSYGUUEVQJSYDZH" width={30} textColor={theme.text} focusedTextColor={theme.accent} cursorColor={theme.accent} /> API Secret: { secretRef(el); secretEl = el; }} value={secret()} onInput={setSecret} onSubmit={() => save()} placeholder="e.g. yzJe2eE7XV-3eY576dyRZ6wXyAbndh6LUrCZ8KN|" width={40} textColor={theme.text} focusedTextColor={theme.accent} cursorColor={theme.accent} /> {(e) => {e()}} Storing credentials... [Enter] save · [Esc] cancel — keys stay stored when disabled. ); }