Skip to content

Commit dc933ab

Browse files
phildenhoffclaude
andauthored
Split Web & Tauri platform controls in UI (#104)
* feat(platform): create platform adapter module Adds src/lib/platform/ with typed adapter interfaces for dialogs, clipboard, file-opener, window, and settings. Settings implementations are moved here from settings-manager/; all sub-adapters have tauri.ts and web.ts implementations assembled by createTauriPlatform() and createWebPlatform() in create.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(platform): wire PlatformAdapter into entry point and settings store - main.tsx: create platform via isTauri() check, wrap app in PlatformProvider, pass platform.settings to settings store init - settings store: init() now accepts a SettingsManager param; removes module-level createSettingsManager() and isTauri() call; imports updated to @/lib/platform/settings/types - App.tsx: replace isTauri() guard with capabilities.supportsAutoUpdates, replace setupAppWindow() with platform.window.showMainWindow() - Delete src/lib/settings-manager/ (consolidated into platform/settings/) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(platform): migrate components and hooks to usePlatform() - Books.tsx: replace openPath/revealItemInDir/clipboard imports with platform.fileOpener and platform.clipboard via usePlatform() - Sidebar.tsx: replace isTauri() guard with capabilities.supportsAutoUpdates via usePlatform() - use-hardcover-book-actions.ts: replace openPath import with platform.fileOpener.openPath() via usePlatform() - LibrarySelectModal.tsx: replace selectLibraryFolderDialog with platform.dialogs.openDirectory() via usePlatform() - firstTimeSetup.tsx: replace selectLibraryFolderDialog with platform.dialogs.openDirectory() via usePlatform() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(platform): lift dialog out of library store, delete dead code - library store: remove dialogOpen import, replace promptToAddBook() with listValidFileTypes() and getImportableBookMetadata(filePath) actions so the store is a pure data layer - Sidebar.tsx: orchestrate dialog in selectAndEditBookFile — calls listValidFileTypes(), platform.dialogs.openFile(), then getImportableBookMetadata() with the result - library/index.ts: remove pickLibrary, commitAddBook, promptToAddBook exports (now dead) - Delete: _internal/addBook.ts, _internal/pickLibrary.ts, lib/utils/library.ts, lib/path.ts (all unused) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(platform): resolve lint errors in platform adapter - types.ts: use T[] array syntax instead of Array<T> - dialogs/web.ts, file-opener/web.ts: remove unnecessary async keywords, return Promise.resolve() directly to satisfy @typescript-eslint/require-await - context.tsx → context.ts: rename to .ts since no JSX is used; avoids react-refresh/only-export-components warning from mixing hook + component exports in a .tsx file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(platform): enforce capabilities in UI and harden settings manager Address code-review findings on the platform-adapter abstraction: - Gate UI controls on platform capabilities instead of always rendering them: Add Book (canPickLocalFiles), Read (canOpenLocalPaths), format reveal (canRevealInFileManager), and copy (canCopyToClipboard). Removes silent no-op buttons on web; Tauri behavior is unchanged. - Web fileOpener.openPath now opens with "noopener,noreferrer" to prevent reverse-tabnabbing on external URLs. - Move the settings SettingsManager from an uninitialized module global into zustand store state; persistSetting reads it via get() and throws a clear error if used before init(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b927a73 commit dc933ab

29 files changed

Lines changed: 372 additions & 276 deletions

File tree

src/App.tsx

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { safeAsyncEventHandler } from "$lib/async";
22
import { IS_DEV } from "@/lib/env";
33
import { checkForUpdates } from "@/lib/services/app-updates";
4-
import { isTauri } from "@tauri-apps/api/core";
4+
import { usePlatform } from "@/lib/platform/context";
55
import { theme } from "$lib/theme";
66
import { ColorSchemeScript, MantineProvider } from "@mantine/core";
77
import { notifications, Notifications } from "@mantine/notifications";
@@ -25,15 +25,17 @@ declare module "@tanstack/react-router" {
2525
export const App = () => {
2626
const hydrated = useSettings((state) => state.hydrated);
2727
const libraryPath = useActiveLibraryPath();
28+
const platform = usePlatform();
2829

2930
useEffect(() => {
3031
if (hydrated) {
31-
safeAsyncEventHandler(setupAppWindow)();
32+
safeAsyncEventHandler(() => platform.window.showMainWindow())();
3233
}
33-
}, [hydrated]);
34+
}, [hydrated, platform]);
3435

3536
useEffect(() => {
36-
if (!hydrated || !isTauri() || IS_DEV) return;
37+
if (!hydrated || !platform.capabilities.supportsAutoUpdates || IS_DEV)
38+
return;
3739

3840
const {
3941
autoUpdateCheckingEnabled,
@@ -67,7 +69,7 @@ export const App = () => {
6769
});
6870
}
6971
})();
70-
}, [hydrated]);
72+
}, [hydrated, platform]);
7173

7274
if (!hydrated) {
7375
// No window is shown until settings are hydrated
@@ -107,17 +109,3 @@ const LibraryStoreInitializer = ({
107109
useInitializeLibraryStore();
108110
return <>{children}</>;
109111
};
110-
111-
/**
112-
* Set the main App Window to be visible.
113-
* Used to avoid a flash-of-white during startup. See:
114-
* https://github.com/tauri-apps/tauri/issues/5170, https://github.com/tauri-apps/tauri/issues/1564,
115-
* and https://github.com/cloudy-org/roseate/commit/21f445011f8becc81300b42fe10d8f4c419c95bd
116-
*/
117-
async function setupAppWindow() {
118-
const { getCurrentWebviewWindow } = await import(
119-
"@tauri-apps/api/webviewWindow"
120-
);
121-
const appWindow = getCurrentWebviewWindow();
122-
safeAsyncEventHandler(async () => appWindow.show())();
123-
}

src/components/organisms/LibrarySelectModal.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { SwitchLibraryForm } from "../molecules/SwitchLibraryForm";
22
import { useSettings } from "@/stores/settings/store";
33
import { createLibrary, setActiveLibrary } from "@/stores/settings/actions";
4-
import { selectLibraryFolderDialog } from "@/lib/utils/library";
4+
import { usePlatform } from "@/lib/platform/context";
5+
import { none, some } from "@/lib/option";
56
import { Modal } from "@mantine/core";
67
import { useCallback } from "react";
78
import { useLibrarySelectModal } from "@/lib/contexts/modal-library-select/hooks";
@@ -11,6 +12,7 @@ export const LibrarySelectModal = () => {
1112
const { close, isOpen: isSwitchLibraryModalOpen } = useLibrarySelectModal();
1213
const libraries = useSettings((state) => state.libraryPaths);
1314
const activeLibraryId = useSettings((state) => state.activeLibraryId);
15+
const platform = usePlatform();
1416

1517
const addNewLibraryByPath = useCallback(
1618
async (form: SwitchLibraryForm) => {
@@ -52,7 +54,12 @@ export const LibrarySelectModal = () => {
5254
libraries={libraries}
5355
onSubmit={(form) => void addNewLibraryByPath(form)}
5456
selectExistingLibrary={selectExistingLibrary}
55-
selectNewLibrary={selectLibraryFolderDialog}
57+
selectNewLibrary={async () => {
58+
const path = await platform.dialogs.openDirectory({
59+
title: "Select Calibre Library Folder",
60+
});
61+
return path !== null ? some(path) : none();
62+
}}
5663
/>
5764
</SwitchLibraryPathModalPure>
5865
);

src/components/organisms/Sidebar.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {
1111
installUpdateIfAvailable,
1212
} from "@/lib/services/app-updates";
1313
import { IS_DEV } from "@/lib/env";
14+
import { usePlatform } from "@/lib/platform/context";
1415
import { useSettings } from "@/stores/settings/store";
15-
import { isTauri } from "@tauri-apps/api/core";
1616
import {
1717
LibraryState,
1818
useAuthors,
@@ -48,6 +48,7 @@ export const Sidebar = () => {
4848
const actions = useLibraryActions();
4949
const authors = useAuthors();
5050

51+
const platform = usePlatform();
5152
const [metadata, setMetadata] = useState<ImportableBookMetadata | null>();
5253
const [isSettingsMenuOpen, setIsSettingsMenuOpen] = useState(false);
5354
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
@@ -88,18 +89,25 @@ export const Sidebar = () => {
8889
const selectAndEditBookFile = useCallback(() => {
8990
if (state !== LibraryState.ready) return;
9091

91-
actions
92-
.promptToAddBook()
93-
.then((importableMetadata) => {
92+
void (async () => {
93+
try {
94+
const extensions = await actions.listValidFileTypes();
95+
const filePath = await platform.dialogs.openFile({
96+
filters: [{ name: "Importable files", extensions }],
97+
});
98+
if (!filePath) return;
99+
100+
const importableMetadata =
101+
await actions.getImportableBookMetadata(filePath);
94102
if (importableMetadata) {
95103
setMetadata(importableMetadata);
96104
openAddBookModal();
97105
}
98-
})
99-
.catch((failure) => {
106+
} catch (failure) {
100107
console.error("failed to import new book: ", failure);
101-
});
102-
}, [actions, state, openAddBookModal]);
108+
}
109+
})();
110+
}, [actions, state, openAddBookModal, platform]);
103111

104112
const addBookByMetadataWithEffects = async (form: AddBookForm) => {
105113
if (!metadata || state !== LibraryState.ready) return;
@@ -118,7 +126,7 @@ export const Sidebar = () => {
118126
};
119127

120128
const checkForUpdatesHandler = useCallback(async () => {
121-
if (!isTauri()) return;
129+
if (!platform.capabilities.supportsAutoUpdates) return;
122130

123131
setIsCheckingForUpdates(true);
124132
notifications.show({
@@ -186,7 +194,7 @@ export const Sidebar = () => {
186194
} finally {
187195
setIsCheckingForUpdates(false);
188196
}
189-
}, []);
197+
}, [platform]);
190198

191199
const installAvailableUpdateHandler = useCallback(async () => {
192200
setIsInstallingUpdate(true);
@@ -309,6 +317,7 @@ export const Sidebar = () => {
309317
/>
310318
)}
311319
<SidebarPure
320+
canAddBook={platform.capabilities.canPickLocalFiles}
312321
currentPathname={location.pathname}
313322
addBookHandler={selectAndEditBookFile}
314323
switchLibraryHandler={openLibrarySelectModalHandler}
@@ -387,6 +396,7 @@ const AddBookModalPure = ({
387396
};
388397

389398
interface SidebarPureProps {
399+
canAddBook: boolean;
390400
currentPathname: string;
391401
addBookHandler: () => void;
392402
switchLibraryHandler: () => void;
@@ -406,6 +416,7 @@ interface SidebarPureProps {
406416
}
407417

408418
const SidebarPure = ({
419+
canAddBook,
409420
currentPathname,
410421
addBookHandler,
411422
switchLibraryHandler,
@@ -448,9 +459,11 @@ const SidebarPure = ({
448459
>
449460
My library
450461
</Title>
451-
<Button variant="filled" onPointerDown={addBookHandler}>
452-
⊕ Add book
453-
</Button>
462+
{canAddBook && (
463+
<Button variant="filled" onPointerDown={addBookHandler}>
464+
⊕ Add book
465+
</Button>
466+
)}
454467
<NavLink
455468
label="Authors"
456469
component={Link}

src/components/pages/Books.tsx

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
import { type UseFormReturnType, useForm } from "@mantine/form";
2424
import { useDisclosure } from "@mantine/hooks";
2525
import { Link } from "@tanstack/react-router";
26-
import { openPath, revealItemInDir } from "@tauri-apps/plugin-opener";
26+
import { usePlatform } from "@/lib/platform/context";
2727
import { useCallback, useEffect, useMemo, useState } from "react";
2828
import { BookCover } from "../atoms/BookCover";
2929
import { F7ListBullet } from "../icons/F7ListBullet";
@@ -33,7 +33,6 @@ import { BookTable } from "../molecules/BookTable";
3333
import { TablerCopy } from "../icons/TablerCopy";
3434
import { F7Pencil } from "../icons/F7Pencil";
3535
import { useBooks, useBooksLoading } from "@/stores/library/store";
36-
import * as clipboard from "@tauri-apps/plugin-clipboard-manager";
3736

3837
interface BookSearchOptions {
3938
search_for_author?: string;
@@ -358,6 +357,7 @@ function Header({
358357
}
359358

360359
const BookDetails = ({ book }: { book: LibraryBook }) => {
360+
const platform = usePlatform();
361361
return (
362362
<Stack h={"100%"} gap="md">
363363
<Group wrap={"nowrap"} align="flex-start">
@@ -380,20 +380,22 @@ const BookDetails = ({ book }: { book: LibraryBook }) => {
380380
</Text>
381381
</Stack>
382382
<Group justify="space-between" w={"100%"}>
383-
<Button
384-
variant="subtle"
385-
onPointerDown={safeAsyncEventHandler(async () => {
386-
const firstFile = book.file_list[0];
387-
if (firstFile === undefined) return;
383+
{platform.capabilities.canOpenLocalPaths && (
384+
<Button
385+
variant="subtle"
386+
onPointerDown={safeAsyncEventHandler(async () => {
387+
const firstFile = book.file_list[0];
388+
if (firstFile === undefined) return;
388389

389-
const isLocal = "Local" in firstFile;
390-
if (!isLocal) return;
390+
const isLocal = "Local" in firstFile;
391+
if (!isLocal) return;
391392

392-
await openPath(firstFile.Local.path);
393-
})}
394-
>
395-
Read
396-
</Button>
393+
await platform.fileOpener.openPath(firstFile.Local.path);
394+
})}
395+
>
396+
Read
397+
</Button>
398+
)}
397399
<Link to={`/books/${book.id}`}>
398400
<Button leftSection={<F7Pencil />}>Edit</Button>
399401
</Link>
@@ -462,22 +464,37 @@ const BookDetails = ({ book }: { book: LibraryBook }) => {
462464
<p style={{ marginTop: 0 }}>
463465
{book.file_list
464466
.filter((item): item is { Local: LocalFile } => "Local" in item)
465-
.map((f1) => (
466-
<span
467-
key={f1.Local.path}
468-
style={{
469-
display: "inline-flex",
470-
textDecoration: "underline",
471-
marginRight: "0.75rem",
472-
fontSize: "0.9rem",
473-
}}
474-
onPointerDown={safeAsyncEventHandler(async () => {
475-
await revealItemInDir(f1.Local.path);
476-
})}
477-
>
478-
{f1.Local.mime_type}
479-
</span>
480-
))}
467+
.map((f1) =>
468+
platform.capabilities.canRevealInFileManager ? (
469+
<span
470+
key={f1.Local.path}
471+
style={{
472+
display: "inline-flex",
473+
textDecoration: "underline",
474+
marginRight: "0.75rem",
475+
fontSize: "0.9rem",
476+
}}
477+
onPointerDown={safeAsyncEventHandler(async () => {
478+
await platform.fileOpener.revealInFileManager(
479+
f1.Local.path,
480+
);
481+
})}
482+
>
483+
{f1.Local.mime_type}
484+
</span>
485+
) : (
486+
<span
487+
key={f1.Local.path}
488+
style={{
489+
display: "inline-flex",
490+
marginRight: "0.75rem",
491+
fontSize: "0.9rem",
492+
}}
493+
>
494+
{f1.Local.mime_type}
495+
</span>
496+
),
497+
)}
481498
</p>
482499
</Stack>
483500
</Stack>
@@ -506,6 +523,7 @@ const BookIdentifiers = ({
506523
}: {
507524
identifier_list: Identifier[];
508525
}) => {
526+
const platform = usePlatform();
509527
return (
510528
<Stack gap={"xs"}>
511529
<Text
@@ -543,15 +561,17 @@ const BookIdentifiers = ({
543561
>
544562
{value}
545563
</a>
546-
<ActionIcon
547-
variant="subtle"
548-
color="gray"
549-
onPointerDown={safeAsyncEventHandler(async () => {
550-
await clipboard.writeText(value);
551-
})}
552-
>
553-
<TablerCopy style={{ width: rem(12) }} />
554-
</ActionIcon>
564+
{platform.capabilities.canCopyToClipboard && (
565+
<ActionIcon
566+
variant="subtle"
567+
color="gray"
568+
onPointerDown={safeAsyncEventHandler(async () => {
569+
await platform.clipboard.writeText(value);
570+
})}
571+
>
572+
<TablerCopy style={{ width: rem(12) }} />
573+
</ActionIcon>
574+
)}
555575
</Text>
556576
</li>
557577
);

src/components/pages/firstTimeSetup.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
11
import { commands } from "@/bindings";
22
import { safeAsyncEventHandler } from "@/lib/async";
3-
import { selectLibraryFolderDialog } from "@/lib/utils/library";
3+
import { usePlatform } from "@/lib/platform/context";
44
import { useLibraryStore } from "@/stores/library/store";
5-
import { unwrap } from "@/lib/option";
65
import { createLibrary } from "@/stores/settings/actions";
76
import { setActiveLibrary } from "@/stores/settings/actions";
87
import { Button, Stack, Text, Title } from "@mantine/core";
98

109
export const FirstTimeSetup = () => {
1110
const actions = useLibraryStore((state) => state.actions);
11+
const platform = usePlatform();
1212

1313
const openFilePicker = async (): Promise<
1414
| { type: "existing library selected"; path: string }
1515
| { type: "new library selected"; path: string }
1616
| { type: "invalid library path selected" }
1717
> => {
18-
const pathOption = await selectLibraryFolderDialog();
19-
if (!pathOption.isSome) return { type: "invalid library path selected" };
18+
const path = await platform.dialogs.openDirectory({
19+
title: "Select Calibre Library Folder",
20+
});
21+
if (path === null) return { type: "invalid library path selected" };
2022

21-
const path = unwrap(pathOption);
2223
const selectedIsValid = await commands.clbQueryIsPathValidLibrary(path);
2324

2425
if (selectedIsValid) {

0 commit comments

Comments
 (0)