diff --git a/src/bindings.ts b/src/bindings.ts index 052387af..57624f64 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -321,6 +321,38 @@ async clbQueryOpdsStatus() : Promise> else return { status: "error", error: e as any }; } }, +async clbQueryOpdsCredentialStatus() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_query_opds_credential_status") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbCmdConfigureOpdsCredentials(username: string, password: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_configure_opds_credentials", { username, password }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbCmdGenerateOpdsCredentials(username: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_generate_opds_credentials", { username }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbCmdClearOpdsCredentials() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_clear_opds_credentials") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async clbCmdOpenSettings() : Promise { await TAURI_INVOKE("clb_cmd_open_settings"); } @@ -434,6 +466,7 @@ export type DetectionSource = * Calibre's default `~/Calibre Library` folder. */ "default-folder" +export type GeneratedOpdsCredentials = { username: string; password: string } /** * Book identifiers, such as ISBN, DOI, Google Books ID, etc. */ @@ -523,13 +556,14 @@ export type LocalOrRemoteUrl = { kind: LocalOrRemote; url: string; local_path: s export type MetadataProvider = "hardcover" | "loc" | "dnb" | "k10plus" | "openlibrary" export type NewAuthor = { name: string; sortable_name: string | null } export type OpdsBindTarget = { type: "allLocalNetworks" } | { type: "interface"; id: string } -export type OpdsErrorCode = "invalidPort" | "libraryNotReady" | "configurationConflict" | "interfaceUnavailable" | "interfaceEnumerationFailed" | "portUnavailable" | "listenerFailed" | "unexpected" +export type OpdsCredentialStatus = { configured: boolean; username: string | null } +export type OpdsErrorCode = "invalidPort" | "libraryNotReady" | "configurationConflict" | "interfaceUnavailable" | "interfaceEnumerationFailed" | "portUnavailable" | "listenerFailed" | "invalidCredentials" | "credentialsRequired" | "credentialStorageFailed" | "unexpected" export type OpdsInterfaceKind = "lan" | "vpn" | "loopback" | "other" export type OpdsInterfaceState = "up" | "down" export type OpdsLifecycleState = "stopped" | "starting" | "running" | "waitingForInterface" | "error" | "stopping" export type OpdsNetworkInterface = { id: string; label: string; kind: OpdsInterfaceKind; state: OpdsInterfaceState; addresses: string[]; shareable: boolean } export type OpdsServiceStatus = { state: OpdsLifecycleState; target: OpdsBindTarget | null; port: number | null; activeLibraryId: string | null; urls: string[]; error: OpdsStatusError | null } -export type OpdsStartConfig = { target: OpdsBindTarget; port: number } +export type OpdsStartConfig = { target: OpdsBindTarget; port: number; authenticationEnabled: boolean } export type OpdsStatusError = { code: OpdsErrorCode; message: string } export type ProviderStatus = { provider: MetadataProvider; is_valid: boolean; message: string } export type RemoteFile = { url: string } diff --git a/src/components/icons/F7AntennaRadiowavesLeftRight.tsx b/src/components/icons/F7AntennaRadiowavesLeftRight.tsx new file mode 100644 index 00000000..60dcf4b6 --- /dev/null +++ b/src/components/icons/F7AntennaRadiowavesLeftRight.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from "react"; + +export function F7AntennaRadiowavesLeftRight(props: SVGProps) { + return ( + + Sharing + + + ); +} diff --git a/src/components/organisms/SettingsPanes.tsx b/src/components/organisms/SettingsPanes.tsx index 46ba67ed..ea07ec70 100644 --- a/src/components/organisms/SettingsPanes.tsx +++ b/src/components/organisms/SettingsPanes.tsx @@ -2,11 +2,13 @@ import type { KeyboardEvent, ReactNode } from "react"; import { useCallback, useId, useRef, useState } from "react"; import { commands } from "@/bindings"; import { F7BookFill } from "@/components/icons/F7BookFill"; +import { F7AntennaRadiowavesLeftRight } from "@/components/icons/F7AntennaRadiowavesLeftRight"; import { F7Gear } from "@/components/icons/F7Gear"; import { FluentLibraryFilled } from "@/components/icons/FluentLibraryFilled"; import type { AddLibraryResult } from "@/components/molecules/SwitchLibraryForm"; import { SwitchLibraryForm } from "@/components/molecules/SwitchLibraryForm"; import classes from "@/components/organisms/SettingsPanes.module.css"; +import { SharingSettings } from "@/components/organisms/SharingSettings"; import { Button, SegmentedControl, Switch, TextInput } from "@/components/ui"; import { ADOPT_INVALID_ERROR } from "@/lib/first-run/machine"; import { useAppUpdates } from "@/lib/hooks/use-app-updates"; @@ -19,7 +21,12 @@ import { createLibrary, setActiveLibrary } from "@/stores/settings/actions"; import { useAnySourceEnabled } from "@/stores/settings/metadata-providers"; import { useSettings } from "@/stores/settings/store"; -const SETTINGS_TABS = ["general", "library", "integrations"] as const; +const SETTINGS_TABS = [ + "general", + "library", + "sharing", + "integrations", +] as const; type SettingsTab = (typeof SETTINGS_TABS)[number]; const TAB_META: Record< @@ -34,6 +41,10 @@ const TAB_META: Record< label: "Library", icon: (className) => , }, + sharing: { + label: "Sharing", + icon: (className) => , + }, integrations: { label: "Metadata", icon: (className) => , @@ -145,6 +156,7 @@ export const SettingsPanes = ({ onRequestClose }: SettingsPanesProps) => {
{activeTab === "general" && } {activeTab === "library" && } + {activeTab === "sharing" && } {activeTab === "integrations" && }
diff --git a/src/components/organisms/SharingSettings.module.css b/src/components/organisms/SharingSettings.module.css new file mode 100644 index 00000000..213dddb6 --- /dev/null +++ b/src/components/organisms/SharingSettings.module.css @@ -0,0 +1,311 @@ +.stack { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.sectionTitle { + margin: 0; + font-size: 1rem; + font-weight: 600; + line-height: 1.3; + color: var(--ctd-ink); +} + +.note, +.unsupported p { + max-width: 65ch; + margin: 0.25rem 0 0; + font-size: 0.75rem; + line-height: 1.45; + color: var(--ctd-ink-soft); +} + +.unsupported { + padding: 0.25rem 0.125rem; +} + +.unsupported h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.group, +.details { + border: 1px solid var(--ctd-border); + border-radius: 0.5rem; + background: var(--ctd-surface); +} + +:global([data-theme="dark"]) .group, +:global([data-theme="dark"]) .details { + background: var(--ctd-surface-strong); +} + +.fieldRow, +.switchRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 2.625rem; + padding: 0.5rem 0.875rem; +} + +.group > * + * { + border-top: 1px solid var(--ctd-border); +} + +.fieldRow > label, +.switchRow label { + font-size: 0.8125rem; + color: var(--ctd-ink); +} + +.switchRow p { + margin: 0.125rem 0 0; + font-size: 0.75rem; + color: var(--ctd-ink-soft); +} + +.portInput.portInput { + width: 6rem; + text-align: right; +} + +.credentials { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + padding: 0.75rem 0.875rem 0.875rem; +} + +.transientNote { + grid-column: 1 / -1; + margin: -0.125rem 0 0; + font-size: 0.6875rem; + line-height: 1.4; + color: var(--ctd-ink-soft); +} + +.passwordActions { + display: flex; + align-items: flex-end; +} + +.generatedPassword { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border: 1px solid var(--ctd-border); + border-radius: 0.375rem; + background: var(--ctd-bg); +} + +.generatedPassword > div { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.generatedPassword strong { + font-size: 0.6875rem; + font-weight: 600; + color: var(--ctd-ink-soft); +} + +.generatedPassword code { + overflow: hidden; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.generatedPassword button svg { + width: 0.875rem; + height: 0.875rem; +} + +.warning, +.error { + margin: 0; + font-size: 0.75rem; + line-height: 1.45; +} + +.warning { + color: var(--ctd-ink-soft); +} + +.error { + color: var(--ctd-danger-text); +} + +.warningBlock, +.statusMessage { + padding: 0.75rem 0.875rem; + border: 1px solid var(--ctd-border); + border-radius: 0.5rem; + background: color-mix(in oklab, var(--ctd-surface-strong) 72%, transparent); +} + +.warningBlock strong, +.statusMessage strong { + font-size: 0.75rem; + font-weight: 600; + color: var(--ctd-ink); +} + +.warningBlock p, +.statusMessage p { + margin: 0.1875rem 0 0; + font-size: 0.75rem; + line-height: 1.45; + color: var(--ctd-ink-soft); +} + +.actions { + display: flex; + justify-content: flex-end; +} + +.statusHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.status { + display: inline-flex; + align-items: center; + gap: 0.375rem; + margin-bottom: 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--ctd-ink-soft); +} + +.status > span { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: currentColor; +} + +.status[data-state="running"] { + color: var(--ctd-accent); +} + +.status[data-state="error"] { + color: var(--ctd-danger-text); +} + +.details { + display: grid; + grid-template-columns: 1fr 1fr; +} + +.details > div { + min-width: 0; + padding: 0.625rem 0.875rem; +} + +.details > div:nth-child(even) { + border-left: 1px solid var(--ctd-border); +} + +.details > div:nth-child(n + 3) { + border-top: 1px solid var(--ctd-border); +} + +.details dt, +.details dd { + margin: 0; +} + +.details dt, +.listTitle { + font-size: 0.6875rem; + font-weight: 600; + color: var(--ctd-ink-soft); +} + +.details dd { + margin-top: 0.125rem; + overflow: hidden; + font-size: 0.8125rem; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ctd-ink); +} + +.details dd.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6875rem; +} + +.listTitle { + margin: 0 0 0.375rem 0.125rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.urlList { + margin: 0; + padding: 0; + border: 1px solid var(--ctd-border); + border-radius: 0.5rem; + list-style: none; + overflow: hidden; +} + +.urlList li { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 2.5rem; + padding: 0.375rem 0.5rem 0.375rem 0.75rem; + background: var(--ctd-surface); +} + +.urlList li + li { + border-top: 1px solid var(--ctd-border); +} + +.urlList code { + min-width: 0; + flex: 1; + overflow: hidden; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6875rem; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ctd-ink); +} + +.urlList button svg { + width: 0.875rem; + height: 0.875rem; +} + +@media (max-width: 34rem) { + .credentials, + .details { + grid-template-columns: 1fr; + } + + .details > div:nth-child(even) { + border-left: 0; + } + + .details > div + div { + border-top: 1px solid var(--ctd-border); + } +} diff --git a/src/components/organisms/SharingSettings.tsx b/src/components/organisms/SharingSettings.tsx new file mode 100644 index 00000000..e337d2e2 --- /dev/null +++ b/src/components/organisms/SharingSettings.tsx @@ -0,0 +1,390 @@ +import type { OpdsLifecycleState, OpdsServiceStatus } from "@/bindings"; +import { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { TablerCopy } from "@/components/icons/TablerCopy"; +import classes from "@/components/organisms/SharingSettings.module.css"; +import { Button, Select, Switch, TextInput } from "@/components/ui"; +import { useOpdsSharing } from "@/lib/hooks/use-opds-sharing"; +import { + hasValidationErrors, + isOperationalStatus, + passwordFreeSettings, + selectedInterfaceUnavailable, + sharingTargetOptions, + targetFromValue, + targetValue, + validateSharing, +} from "@/lib/opds-sharing"; +import type { SharingSettings as SharingPreferences } from "@/lib/platform/settings/types"; +import { usePlatform } from "@/lib/platform/context"; +import { useSettings } from "@/stores/settings/store"; + +const STATUS_LABELS: Record = { + stopped: "Not sharing", + starting: "Starting", + running: "Sharing", + waitingForInterface: "Waiting for network", + error: "Needs attention", + stopping: "Stopping", +}; + +const targetLabel = (status: OpdsServiceStatus): string => { + if (!status.target) return "Not selected"; + return status.target.type === "allLocalNetworks" + ? "All local networks" + : status.target.id; +}; + +export const SharingSettings = () => { + const platform = usePlatform(); + const supported = platform.capabilities.supportsLocalOpdsServer; + const sharing = useSettings((state) => state.sharing); + const activeLibraryId = useSettings((state) => state.activeLibraryId); + const libraries = useSettings((state) => state.libraryPaths); + const setSharing = useSettings((state) => state.setSharing); + const controller = useOpdsSharing(supported); + const [portInput, setPortInput] = useState(String(sharing.port)); + const [password, setPassword] = useState(""); + const [generatedPassword, setGeneratedPassword] = useState( + null, + ); + const [copiedUrl, setCopiedUrl] = useState(null); + const authSwitchId = useId(); + + useEffect(() => setPortInput(String(sharing.port)), [sharing.port]); + + const currentLibrary = libraries.find( + (library) => library.id === activeLibraryId, + ); + const options = useMemo( + () => sharingTargetOptions(controller.interfaces, sharing.target), + [controller.interfaces, sharing.target], + ); + const unavailable = selectedInterfaceUnavailable( + controller.interfaces, + sharing.target, + ); + const draft = useMemo( + () => ({ ...sharing, port: Number(portInput) }), + [sharing, portInput], + ); + const validation = validateSharing({ + settings: draft, + password, + credentialsConfigured: controller.credentials.configured, + configuredUsername: controller.credentials.username, + }); + const operational = isOperationalStatus(controller.status); + const passwordConfigured = + controller.credentials.configured && + controller.credentials.username === sharing.username.trim(); + + const persist = useCallback( + (patch: Partial) => { + const current = useSettings.getState().sharing; + return setSharing(passwordFreeSettings({ ...current, ...patch })); + }, + [setSharing], + ); + + const handleStart = useCallback(async () => { + if (hasValidationErrors(validation)) return; + await persist({ port: draft.port }); + if (await controller.start(passwordFreeSettings(draft), password)) { + setPassword(""); + setGeneratedPassword(null); + } + }, [controller, draft, password, persist, validation]); + + const generatePassword = useCallback(async () => { + if (!sharing.username.trim()) return; + const generated = await controller.generatePassword(sharing.username); + if (!generated) return; + setPassword(""); + setGeneratedPassword(generated); + }, [controller, sharing.username]); + + const copy = useCallback( + async (url: string) => { + await platform.clipboard.writeText(url); + setCopiedUrl(url); + window.setTimeout(() => setCopiedUrl(null), 1_500); + }, + [platform], + ); + + if (!supported) { + return ( +
+

Sharing requires the desktop app

+

+ The web version cannot open a local network server. Open Citadel on + macOS or another supported desktop to share this library. +

+
+ ); + } + + if (controller.isLoading && controller.status === null) { + return

Checking sharing status…

; + } + + if (operational && controller.status) { + return ( + void controller.stop()} + /> + ); + } + + return ( +
+
+

Local network sharing

+

+ Start an OPDS catalog for the active library. Sharing stops when + Citadel quits and must be started again after each launch. +

+
+ +
+
+ +