Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions docs/customization-profile-screens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Customization profile screens

A customization manifest can add Library navigation pages with `rel: "screen"` links. Thorium displays the links matching the application locale; when none match, it uses English and language-neutral links in manifest order.

Profile screen documents must follow this contract:

- Use `text/html` (or omit the link type) and provide exactly one `<h1>`.
- Do not add a `<main>` landmark. Thorium owns the page `<main>`; any nested `<main>` is converted to `<section class="profile-content">`.
- Do not use scripts, frames, embedded objects, external stylesheets, `@import`, `@font-face`, `@keyframes`, or `@page`.
- Inline `<style>` rules must target `.custom-profile-screen` or a descendant. A `body[data-theme]` condition may precede that selector.
- Do not set application typography. Thorium enforces its Nunito font and standard heading and text sizes.
- Inline layout and color styles are allowed. Thorium sanitizes the markup before rendering it.

## Style selector rules

Thorium checks every selector in every inline stylesheet, including each member of a comma-separated selector list and rules nested in grouping at-rules such as `@media` and `@supports`. If any selector is outside the profile screen, Thorium rejects the document.

Every selector must start with the exact `.custom-profile-screen` class. It may optionally be preceded by one `body[data-theme]` condition, either without a value or with an exact value such as `body[data-theme="dark"]`. Conditions may be added to the profile root, and descendant or child combinators may then select content inside it.

Allowed examples:

```css
.custom-profile-screen { color: var(--color-primary); }
.custom-profile-screen.compact:hover { background: white; }
.custom-profile-screen .card + .card { margin-block-start: 1rem; }
.custom-profile-screen > section .card { display: grid; }
body[data-theme="dark"] .custom-profile-screen .card { background: black; }
```

Selectors are rejected when they use another ancestor, merely contain or resemble the scope class, or use a sibling or column combinator directly after the profile root. A sibling combinator between descendants is allowed because its target remains inside the profile screen.

Rejected examples:

```css
body .custom-profile-screen { color: red; }
body[data-other] .custom-profile-screen { color: red; }
:is(.custom-profile-screen, body) { color: red; }
.custom-profile-screen-other { color: red; }
.custom-profile-screen + .thorium-content { display: none; }
.custom-profile-screen, body { color: red; }
```

Only selector scope is validated here. The other stylesheet restrictions still apply: global or resource-loading at-rules such as `@import`, `@font-face`, `@keyframes`, and `@page` are rejected, as are executable legacy CSS values. A parser error also rejects the document.

### Selector validation algorithm

The browser first parses the stylesheet into CSSOM rules and gives Thorium a `selectorText` for each retained style rule. Thorium then parses every selector list with [`css-selector-parser`](https://github.com/mdevils/css-selector-parser) in strict Selectors Level 4 mode. The AST is checked directly, without a second normalized model:

1. An optional ancestor must be exactly `body[data-theme]` or `body[data-theme="value"]`, without a namespace, another compound condition, a case-sensitivity modifier, or an operator other than `=`. It must be connected to the profile root by descendant whitespace.
2. The first item in the profile-root compound must be the exact `custom-profile-screen` class. Because the parser decodes CSS identifiers, a valid escaped spelling such as `.custom-profile-\73 creen` is also accepted, while lookalikes remain rejected.
3. With no following compound, the selector targets the profile root and is safe.
4. When another compound follows the root, its first combinator must be descendant whitespace or child (`>`). A sibling (`+` or `~`) or column (`||`) combinator at this position is rejected because it can target content outside the root.
5. Later combinators cannot escape the subtree after the selector has entered it, so they do not affect the containment decision. For example, `.custom-profile-screen .card + .card` is safe, while `.custom-profile-screen + .card` is not.

An empty selector list, a parser exception, an unsafe selector in a list, an unknown CSS rule shape, or an unsupported global at-rule causes validation to fail closed. `css-selector-parser` only provides the AST; the containment checks in `selectorCssParser.ts` remain the Thorium security policy.

Thorium addresses each screen with an internal `/profile/:screenId` route. The identifier is derived from the manifest `href`; a route can only load the localized `rel: "screen"` resource it resolves to in the active manifest.
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@
"ajv-formats": "^3.0.1",
"classnames": "^2.5.1",
"color": "^5.0.3",
"css-selector-parser": "^3.3.0",
"css.escape": "^1.5.1",
"cssesc": "^3.0.0",
"debounce": "^3.0.0",
Expand Down
57 changes: 57 additions & 0 deletions src/renderer/assets/styles/components/profileScreen.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
.profile_screen {
box-sizing: border-box;
width: 100%;
min-width: 0;
color: var(--color-text-primary);
font-family: Nunito, sans-serif !important;
font-size: 12px !important;
line-height: 1.5;

&,
& * {
font-family: Nunito, sans-serif !important;
}

& * {
font-size: 12px !important;
}

h1 {
font-size: 24px !important;
}

h2 {
font-size: 20px !important;
}

h3 {
font-size: 16px !important;
}

h4,
button,
label {
font-size: 14px !important;
}

img,
table {
max-width: 100%;
}
}

.loading {
width: 28px;
height: 28px;
margin: 40px auto;
border: 3px solid var(--color-gray-250);
border-top-color: var(--color-brand-primary);
border-radius: 50%;
animation: profile-screen-loading 800ms linear infinite;
}

@keyframes profile-screen-loading {
to {
transform: rotate(360deg);
}
}
2 changes: 2 additions & 0 deletions src/renderer/assets/styles/components/profileScreen.scss.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export declare const loading: string;
export declare const profile_screen: string;
39 changes: 33 additions & 6 deletions src/renderer/library/analytics/pageView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@
// ==LICENSE-END==

import { TAnalyticsEventParams } from "readium-desktop/common/api/interface/analyticsApi.interface";
import { ICustomizationManifest } from "readium-desktop/common/readium/customization/manifest";
import { isProfileCatalogPathname } from "readium-desktop/renderer/library/customization/route";

export type TLibraryPageTitle = "Home" | "Bookshelf" | "Catalog";
export type TLibraryPageTitle = "Home" | "Bookshelf" | "Catalog" | "Profile Page" | "Profile Catalog";

export interface ILibraryPageView {
pageTitle: TLibraryPageTitle;
routeKey: string;
}

export type TLibraryPageViewParams = TAnalyticsEventParams & {
page_title: TLibraryPageTitle;
Expand All @@ -16,27 +23,47 @@ export type TLibraryPageViewParams = TAnalyticsEventParams & {

const PAGE_LOCATION_ORIGIN = "https://desktop.thoriumreader.com/analytics";

export const libraryPageTitleFromPathname = (pathname: string): TLibraryPageTitle | undefined => {
export const libraryPageViewFromPathname = (
pathname: string,
customizationManifest?: ICustomizationManifest,
): ILibraryPageView | undefined => {
const normalizedPathname = pathname.replace(/\/+$/, "") || "/";

if (normalizedPathname === "/" || normalizedPathname === "/home") {
return "Home";
return { pageTitle: "Home", routeKey: "Home" };
}

if (normalizedPathname === "/library") {
return "Bookshelf";
return { pageTitle: "Bookshelf", routeKey: "Bookshelf" };
}

if (/^\/profile\/[^/]+$/.test(normalizedPathname)) {
return { pageTitle: "Profile Page", routeKey: normalizedPathname };
}

if (normalizedPathname === "/opds" || normalizedPathname.startsWith("/opds/")) {
return "Catalog";
if (isProfileCatalogPathname(normalizedPathname, customizationManifest)) {
const rootIdentifier = normalizedPathname.split("/")[2];
return {
pageTitle: "Profile Catalog",
routeKey: `Profile Catalog:${rootIdentifier}`,
};
}
return { pageTitle: "Catalog", routeKey: "Catalog" };
}

return undefined;
};

export const libraryPageTitleFromPathname = (
pathname: string,
customizationManifest?: ICustomizationManifest,
): TLibraryPageTitle | undefined =>
libraryPageViewFromPathname(pathname, customizationManifest)?.pageTitle;

export const buildLibraryPageViewParams = (
pageTitle: TLibraryPageTitle,
): TLibraryPageViewParams => ({
page_title: pageTitle,
page_location: `${PAGE_LOCATION_ORIGIN}/${pageTitle}`,
page_location: `${PAGE_LOCATION_ORIGIN}/${encodeURIComponent(pageTitle)}`,
});
169 changes: 169 additions & 0 deletions src/renderer/library/components/customization/Customization.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// ==LICENSE-BEGIN==
// Copyright 2017 European Digital Reading Lab. All rights reserved.
// Licensed to the Readium Foundation under one or more contributor license agreements.
// Use of this source code is governed by a BSD-style license
// that can be found in the LICENSE file exposed on Github (readium) in the project repository.
// ==LICENSE-END==

import { encodeURIComponent_RFC3986 } from "@r2-utils-js/_utils/http/UrlUtils";
import DOMPurify from "dompurify";
import * as React from "react";
import { Navigate, useParams } from "react-router-dom";

import { convertMultiLangStringToString } from "readium-desktop/common/language-string";
import { ILibraryRootState } from "readium-desktop/common/redux/states/renderer/libraryRootState";
import {
URL_HOST_COMMON,
URL_PATH_PREFIX_CUSTOMPROFILEZIP,
URL_PROTOCOL_THORIUMHTTPS,
} from "readium-desktop/common/streamerProtocol";
import * as styles from "readium-desktop/renderer/assets/styles/components/profileScreen.scss";
import { useSelector } from "readium-desktop/renderer/common/hooks/useSelector";
import { useTranslator } from "readium-desktop/renderer/common/hooks/useTranslator";
import { decodeProfileRouteParam, resolveProfileScreenLink } from "../../customization/route";
import { profileCssIsSafeAndScoped } from "../../customization/style";
import PublicationAddButton from "../catalog/PublicationAddButton";
import LibraryLayout from "../layout/LibraryLayout";

const secondaryHeader = <span style={{ display: "flex", justifyContent: "end", alignItems: "end", height: "53px", borderBottom: "1px solid var(--color-gray-250)", paddingBottom: "30px" }}><PublicationAddButton /></span>;

type TProfileScreenState =
| { status: "loading" }
| { status: "ready"; html: string }
| { status: "error" };

export function prepareProfileScreenHtml(rawHtmlContent: string): string | undefined {
// Profile packages are controlled and signed, so the page remains in the
// regular DOM instead of a Shadow DOM. This preserves Thorium's theme,
// focus management, accessibility landmarks, and link handling. Isolation
// is enforced by sanitizing the HTML and requiring retained stylesheet
// selectors to remain below .custom-profile-screen. Inline style
// attributes are local to their element, while Thorium's !important rules
// still enforce the application font and standard text sizes.
const sanitizedHtml = DOMPurify.sanitize(rawHtmlContent, {
FORCE_BODY: true,
FORBID_ATTR: ["srcdoc"],
FORBID_TAGS: ["base", "embed", "iframe", "link", "object", "script"],
});
const parsedDocument = new DOMParser().parseFromString(sanitizedHtml, "text/html");

if (Array.from(parsedDocument.body.querySelectorAll("style")).some(
(style) => !profileCssIsSafeAndScoped(style.textContent || ""),
)) {
return undefined;
}

// LibraryLayout already owns the page's main landmark.
for (const nestedMain of Array.from(parsedDocument.body.querySelectorAll("main"))) {
const section = parsedDocument.createElement("section");
for (const attribute of Array.from(nestedMain.attributes)) {
section.setAttribute(attribute.name, attribute.value);
}
section.classList.add("profile-content");
while (nestedMain.firstChild) {
section.appendChild(nestedMain.firstChild);
}
nestedMain.replaceWith(section);
}

if (parsedDocument.body.querySelectorAll("h1").length !== 1) {
return undefined;
}

return parsedDocument.body.innerHTML;
}

const CustomizationPage = () => {
const [__] = useTranslator();
const { screenId } = useParams<{ screenId: string }>();
const contentRef = React.useRef<HTMLDivElement>(null);

const customization = useSelector((state: ILibraryRootState) => state.customization);
const locale = useSelector((state: ILibraryRootState) => state.i18n.locale);
const customizationManifest = customization.manifest;
const decodedHref = screenId ? decodeProfileRouteParam(screenId) : undefined;
const screenLink = screenId
? resolveProfileScreenLink(customizationManifest, locale, screenId)
: undefined;
const title = convertMultiLangStringToString(screenLink?.title, locale) || __("catalog.customization.fallback.screen");
const customizationId = customizationManifest?.identifier;
const customizationBaseUrl = customizationId
? `${URL_PROTOCOL_THORIUMHTTPS}://${URL_HOST_COMMON}/${URL_PATH_PREFIX_CUSTOMPROFILEZIP}/${encodeURIComponent_RFC3986(Buffer.from(customizationId).toString("base64"))}/`
: undefined;

const [screenState, setScreenState] = React.useState<TProfileScreenState>({ status: "loading" });

React.useEffect(() => {
if (!screenLink || !customizationBaseUrl) {
setScreenState({ status: "loading" });
return undefined;
}

const controller = new AbortController();
setScreenState({ status: "loading" });

// URL is handled by Thorium's custom-profile-zip protocol.
const url = customizationBaseUrl + encodeURIComponent_RFC3986(Buffer.from(screenLink.href).toString("base64"));
fetch(url, { signal: controller.signal })
.then((response) => {
if (!response.ok) {
throw new Error(response.statusText || `${response.status}`);
}
return response.text();
})
.then((rawHtmlContent) => {
if (controller.signal.aborted) {
return;
}
const html = rawHtmlContent && prepareProfileScreenHtml(rawHtmlContent);
setScreenState(html ? { status: "ready", html } : { status: "error" });
})
.catch((error) => {
if (controller.signal.aborted || error?.name === "AbortError") {
return;
}
console.error("Error loading profile screen:", error);
setScreenState({ status: "error" });
});

return () => controller.abort();
}, [customizationBaseUrl, screenLink]);

React.useEffect(() => {
if (screenState.status !== "ready") {
return;
}
const heading = contentRef.current?.querySelector<HTMLElement>("h1");
if (heading) {
heading.tabIndex = -1;
heading.focus({ preventScroll: true });
}
}, [screenState]);

if (!decodedHref || !customization.activate.id) {
return <Navigate to="/home" replace />;
}
if (customizationManifest && !screenLink) {
return <Navigate to="/home" replace />;
}

return (
<LibraryLayout
page={title}
secondaryHeader={secondaryHeader}
>
<div
aria-busy={screenState.status === "loading"}
className={`custom-profile-screen ${styles.profile_screen}`}
lang={screenLink?.language || locale}
ref={contentRef}
>
{screenState.status === "loading" ? <div aria-hidden="true" className={styles.loading} /> : <></>}
{screenState.status === "error" ? <p role="alert">{__("opds.network.error")}</p> : <></>}
{screenState.status === "ready" ? <div dangerouslySetInnerHTML={{ __html: screenState.html }} /> : <></>}
</div>
</LibraryLayout>
);
};

export default CustomizationPage;
Loading
Loading