Reader settings per login session
Each sign-in keeps its own reader mode, zoom, page tone and skipping once it changes one; sessions that never changed follow the latest set, and a new sign-in starts from the session used last. Settings -> Reading shows every session's state, lets a session follow the latest again or become the latest, and turns the feature off for one shared set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03767d5704
commit
e3e57b60bd
9 files changed
+326
-27
No files matched your search
@@ -11,6 +11,7 @@
|
|||||||
!reading-analytics.js
|
!reading-analytics.js
|
||||||
!reading-access.js
|
!reading-access.js
|
||||||
!moments.js
|
!moments.js
|
||||||
|
!reader-sessions.js
|
||||||
!public/
|
!public/
|
||||||
!public/**
|
!public/**
|
||||||
!greatest/
|
!greatest/
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ RUN apk add --no-cache tzdata openssl
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG VCS_REF=development
|
ARG VCS_REF=development
|
||||||
LABEL org.opencontainers.image.title="MangaCircus" org.opencontainers.image.revision=$VCS_REF
|
LABEL org.opencontainers.image.title="MangaCircus" org.opencontainers.image.revision=$VCS_REF
|
||||||
COPY package.json server.js library.js reading-direction.js chapter-identity.js chapter-dates.js reading-analytics.js reading-access.js catalog.js backup.js moments.js ./
|
COPY package.json server.js library.js reading-direction.js chapter-identity.js chapter-dates.js reading-analytics.js reading-access.js catalog.js backup.js moments.js reader-sessions.js ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
COPY greatest ./greatest
|
COPY greatest ./greatest
|
||||||
COPY discovery ./discovery
|
COPY discovery ./discovery
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ Settings has **User** and **Admin** menu groups. User sections cover Reading, Re
|
|||||||
Notifications and Account. Admin sections cover Scraper, Server & library, Users & access, Collections &
|
Notifications and Account. Admin sections cover Scraper, Server & library, Users & access, Collections &
|
||||||
metadata and Service health. Only admins see administration ([settings navigation](docs/settings-navigation.md)).
|
metadata and Service health. Only admins see administration ([settings navigation](docs/settings-navigation.md)).
|
||||||
|
|
||||||
|
Reader settings (mode, zoom, page tone, skipping) are kept per sign-in: each device or browser keeps its own once you
|
||||||
|
change one there, and a new sign-in starts from the one you used last. **Settings → Reading → Reader settings per
|
||||||
|
session** shows every session's state and turns this off for one shared set ([details](docs/architecture.md#67-reader-settings-per-session)).
|
||||||
|
|
||||||
### Settings (environment variables)
|
### Settings (environment variables)
|
||||||
|
|
||||||
These configure the reader process. In Docker most are set for you by `docker-compose.yml`; the variables
|
These configure the reader process. In Docker most are set for you by `docker-compose.yml`; the variables
|
||||||
|
|||||||
+26
-5
@@ -130,11 +130,14 @@ This keeps ids human-readable and derivable without a database. The price: renam
|
|||||||
"readingStory": true, // show private retrospective summaries when history exists
|
"readingStory": true, // show private retrospective summaries when history exists
|
||||||
"continueRow": true, // library: "Continue where you left off" row at the top
|
"continueRow": true, // library: "Continue where you left off" row at the top
|
||||||
"zoom": { "flow": 1, "page": 1.5, "book": 1, "strip": 0.8 }, // page zoom per mode
|
"zoom": { "flow": 1, "page": 1.5, "book": 1, "strip": 0.8 }, // page zoom per mode
|
||||||
"webhook": ""
|
"webhook": "",
|
||||||
}
|
"readerPerSession": true // reader settings per login session (section 6.7)
|
||||||
|
},
|
||||||
|
"readerFrom": "3f9c…" // public id of the session the latest reader settings came from
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": { "<sha256 of token>": { "u": "admin", "t": 1790796794000 } },
|
"sessions": { "<sha256 of token>": { "u": "admin", "t": 1790796794000, "seen": 1790796794000,
|
||||||
|
"agent": "<User-Agent>", "reader": { …locked reader settings, only after a change… } } },
|
||||||
"progress": { "admin": { "Moon Garden": { "1/2": {
|
"progress": { "admin": { "Moon Garden": { "1/2": {
|
||||||
"v": "c0002 Roots [MangaDex]", // version read
|
"v": "c0002 Roots [MangaDex]", // version read
|
||||||
"p": 4, "n": 14, // page index in that version (all pages), page count
|
"p": 4, "n": 14, // page index in that version (all pages), page count
|
||||||
@@ -177,7 +180,7 @@ This keeps ids human-readable and derivable without a database. The price: renam
|
|||||||
|
|
||||||
| Path | Handling |
|
| Path | Handling |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `/api/*` | `routes["<METHOD> <path>"]`. Handlers get `{ u, b, q, req, res }` and return an object, which is sent as JSON. Throwing `HttpError(code, message, extra)` sends `{ error, ...extra }` with that status (for example `nextAllowedAt` on a discovery `429`) |
|
| `/api/*` | `routes["<METHOD> <path>"]`. Handlers get `{ u, b, q, req, res, sess, sk }` (`sess` = the login session record, `sk` its key) and return an object, which is sent as JSON. Throwing `HttpError(code, message, extra)` sends `{ error, ...extra }` with that status (for example `nextAllowedAt` on a discovery `429`) |
|
||||||
| `/lib/*` | Image files from `LIBRARY`. Requires a session, allows image extensions only, and the path must resolve inside `LIBRARY`. `ETag` + `private, max-age=86400` |
|
| `/lib/*` | Image files from `LIBRARY`. Requires a session, allows image extensions only, and the path must resolve inside `LIBRARY`. `ETag` + `private, max-age=86400` |
|
||||||
| everything else | Static files from `public/` (`/` → `index.html`). `ETag` + `no-cache` |
|
| everything else | Static files from `public/` (`/` → `index.html`). `ETag` + `no-cache` |
|
||||||
|
|
||||||
@@ -194,7 +197,9 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
|
|||||||
| `POST /api/login` `{name, password}` | public | Starts a session (cookie `sid`) |
|
| `POST /api/login` `{name, password}` | public | Starts a session (cookie `sid`) |
|
||||||
| `POST /api/logout` | user | Ends the session |
|
| `POST /api/logout` | user | Ends the session |
|
||||||
| `GET /api/me` | user | Name, admin flag, settings, unread count |
|
| `GET /api/me` | user | Name, admin flag, settings, unread count |
|
||||||
| `POST /api/settings` `{…partial}` | user | Validated update of the settings keys above |
|
| `POST /api/settings` `{…partial}` | user | Validated update of the settings keys above; reader keys go to the current session (6.7) |
|
||||||
|
| `GET /api/reader-sessions` | user | `{ enabled, current, latestFrom, latest, sessions: [{ id, device, created, seen, current, locked, reader }] }` |
|
||||||
|
| `POST /api/reader-sessions` `{id, action}` | user | `follow`: the session drops its own reader settings and follows the latest; `latest`: its settings become the latest |
|
||||||
| `POST /api/password` `{old, new}` | user | Change password |
|
| `POST /api/password` `{old, new}` | user | Change password |
|
||||||
| `GET /api/library` | user | Manga list with covers, counts, the user's read count, unread notifications, last read, and optional personal Reading Story aggregates |
|
| `GET /api/library` | user | Manga list with covers, counts, the user's read count, unread notifications, last read, and optional personal Reading Story aggregates |
|
||||||
| `GET /api/manga?m=` | user | Manga details, books, chapters with ranked versions, progress, optional chapter-time summaries, and `continue` target |
|
| `GET /api/manga?m=` | user | Manga details, books, chapters with ranked versions, progress, optional chapter-time summaries, and `continue` target |
|
||||||
@@ -269,6 +274,22 @@ The reader alone talks to AniList for discovery ([discovery.md](discovery.md)).
|
|||||||
(all reasons, strongest first) next to `reason`; seed evidence carries `fetchedAt`.
|
(all reasons, strongest first) next to `reason`; seed evidence carries `fetchedAt`.
|
||||||
- `DISCOVERY_ANILIST_URL` overrides the AniList endpoint (tests point it at a local fixture server).
|
- `DISCOVERY_ANILIST_URL` overrides the AniList endpoint (tests point it at a local fixture server).
|
||||||
|
|
||||||
|
### 6.7 Reader settings per session
|
||||||
|
|
||||||
|
The reader keys (`startMode`, `lastMode`, `adapt`, `skipExtras`, `skipCovers`, `flowPageGap`, `pageTone`, `zoom`)
|
||||||
|
can differ per login session, so a phone and a desktop keep their own mode and zoom (`reader-sessions.js`):
|
||||||
|
|
||||||
|
- `user.settings` holds the **latest** reader set. A session without its own set follows it.
|
||||||
|
- The first reader change in a session copies the latest set into `session.reader` with the change applied: the session
|
||||||
|
is now **locked** and keeps that set whatever other sessions do. The change also becomes the latest set.
|
||||||
|
- Loading a page (`GET /api/me`) from a locked session makes its set the latest again, so a new sign-in starts from the
|
||||||
|
session used last. A new sign-in has no set of its own until it changes something.
|
||||||
|
- Other settings (Reading Story, dates, discovery, webhook, …) stay per user.
|
||||||
|
- `readerPerSession: false` (Settings → Reading) brings back one shared set: changes go to `user.settings` and apply to
|
||||||
|
every session. Locked sets are kept and apply again when the switch is turned back on.
|
||||||
|
|
||||||
|
Settings → Reading → *Reader settings per session* lists the user's sessions (device from the User-Agent, sign-in and last
|
||||||
|
activity, own or following) with "Follow latest again" and "Use for new sessions".
|
||||||
## 7. Frontend
|
## 7. Frontend
|
||||||
|
|
||||||
### 7.1 Pages
|
### 7.1 Pages
|
||||||
|
|||||||
@@ -387,6 +387,8 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
|||||||
.login input { width: 100%; }
|
.login input { width: 100%; }
|
||||||
.saved { color: #7fd3a0; font-size: 13px; }
|
.saved { color: #7fd3a0; font-size: 13px; }
|
||||||
.reading-data { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line); }
|
.reading-data { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||||
|
.reader-sessions { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||||
|
.reader-sessions li { display: grid; gap: 4px; padding: 10px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; }
|
||||||
.reading-data > summary { cursor: pointer; font-weight: 600; }
|
.reading-data > summary { cursor: pointer; font-weight: 600; }
|
||||||
.reading-danger { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
.reading-danger { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||||
.reading-danger p { margin: 3px 0 8px; }
|
.reading-danger p { margin: 3px 0 8px; }
|
||||||
|
|||||||
@@ -15,11 +15,61 @@
|
|||||||
const saved = h('span', { class: 'saved', hidden: true }, 'Saved');
|
const saved = h('span', { class: 'saved', hidden: true }, 'Saved');
|
||||||
shell.titleRow.append(saved);
|
shell.titleRow.append(saved);
|
||||||
let savedTimer = 0;
|
let savedTimer = 0;
|
||||||
|
let refreshSessions = () => {};
|
||||||
async function save(patch) {
|
async function save(patch) {
|
||||||
Object.assign(S, (await api('api/settings', patch)).settings);
|
Object.assign(S, (await api('api/settings', patch)).settings);
|
||||||
saved.hidden = false;
|
saved.hidden = false;
|
||||||
clearTimeout(savedTimer);
|
clearTimeout(savedTimer);
|
||||||
savedTimer = setTimeout(() => { saved.hidden = true; }, 1500);
|
savedTimer = setTimeout(() => { saved.hidden = true; }, 1500);
|
||||||
|
refreshSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which login session holds which reader settings (reader-sessions.js on the server).
|
||||||
|
function readerSessionsCard() {
|
||||||
|
const TONE = { original: 'Original', inverted: 'Inverted', sepia: 'Sepia', dim: 'Dim', 'extra-dim': 'Extra dim' };
|
||||||
|
const START = { last: 'last mode', flow: 'always Flow', page: 'always Page', book: 'always Book' };
|
||||||
|
const summary = r => [`${LABEL[r.lastMode] || r.lastMode} (opens: ${START[r.startMode] || r.startMode})`, `tone ${TONE[r.pageTone] || r.pageTone}`,
|
||||||
|
'zoom ' + (Object.keys(r.zoom || {}).length ? Object.entries(r.zoom).map(([k, z]) => `${LABEL[k] || k} ${Math.round(z * 100)}%`).join(', ') : 'fit'),
|
||||||
|
r.skipExtras ? 'skips extras' : null, r.skipCovers ? 'skips covers' : null, r.flowPageGap === false ? 'no Flow gap' : null].filter(Boolean).join(' · ');
|
||||||
|
const state = h('div', {}, h('p', { class: 'muted' }, 'Loading sessions…'));
|
||||||
|
const message = h('span', { class: 'small' });
|
||||||
|
async function act(id, action, current) {
|
||||||
|
try {
|
||||||
|
draw(await api('api/reader-sessions', { id, action }));
|
||||||
|
// This page's controls show this session's settings; reload them when they changed.
|
||||||
|
if (current && action === 'follow') location.reload();
|
||||||
|
} catch (error) { message.textContent = error.message; message.className = 'small error'; }
|
||||||
|
}
|
||||||
|
function draw(o) {
|
||||||
|
const current = o.sessions.find(s => s.current), source = o.sessions.find(s => s.id === o.latestFrom);
|
||||||
|
const latestText = !o.latestFrom ? 'one shared set (changed while per-session settings were off, or never changed)'
|
||||||
|
: source ? (source.current ? 'this session' : `${source.device}, active ${ago(source.seen)}`) : 'a session that has since signed out';
|
||||||
|
const intent = !o.enabled ? 'Off: every session uses the same reader settings, and a change applies everywhere.'
|
||||||
|
: current?.locked ? 'This session has its own reader settings. Changes here stay here and become the starting point for new sessions.'
|
||||||
|
: 'This session follows the latest reader settings. Your first change here gives it its own copy, which stays put when other sessions change theirs.';
|
||||||
|
state.replaceChildren(
|
||||||
|
h('p', {}, h('b', {}, 'Now: '), intent),
|
||||||
|
o.enabled ? h('p', { class: 'muted small' }, 'New sessions start from the latest settings: ', h('b', {}, summary(o.latest)), ` — from ${latestText}.`)
|
||||||
|
: h('p', { class: 'muted small' }, 'Shared settings: ', h('b', {}, summary(o.latest))),
|
||||||
|
h('ul', { class: 'reader-sessions' }, o.sessions.map(s => h('li', {},
|
||||||
|
h('div', {}, h('b', {}, s.device), s.current ? ' (this session)' : '', ' · ',
|
||||||
|
h('span', { class: 'muted small' }, `signed in ${new Date(s.created).toLocaleDateString()}, active ${ago(s.seen)}`)),
|
||||||
|
h('div', { class: 'small' }, !o.enabled ? 'Uses the shared settings' + (s.locked ? ' (its own copy is kept for when this is on again)' : '')
|
||||||
|
: s.locked ? 'Own settings: ' + summary(s.reader) : 'Follows the latest settings'),
|
||||||
|
o.enabled ? h('div', { class: 'row' },
|
||||||
|
s.locked ? h('button', { class: 'btn small', onclick: () => act(s.id, 'follow', s.current) }, 'Follow latest again') : null,
|
||||||
|
s.locked && s.id !== o.latestFrom ? h('button', { class: 'btn small', onclick: () => act(s.id, 'latest', s.current) }, 'Use for new sessions') : null) : null))),
|
||||||
|
message);
|
||||||
|
}
|
||||||
|
refreshSessions = () => api('api/reader-sessions').then(draw, error => { message.textContent = error.message; message.className = 'small error'; });
|
||||||
|
refreshSessions();
|
||||||
|
return h('section', { class: 'card' },
|
||||||
|
h('h2', {}, 'Reader settings per session'),
|
||||||
|
// The controls above switch between this session's set and the shared one, so reload them.
|
||||||
|
h('label', { class: 'check' },
|
||||||
|
h('input', { type: 'checkbox', checked: !!S.readerPerSession, onchange: async e => { await save({ readerPerSession: e.target.checked }); location.reload(); } }),
|
||||||
|
h('span', {}, 'Keep reader settings per session', h('div', { class: 'muted small' }, 'Each sign-in (device or browser) keeps its own reader mode, zoom, tone and the other reader preferences above once you change one there. A new sign-in copies the settings of the session you used last. Turn this off for one shared set.'))),
|
||||||
|
state);
|
||||||
}
|
}
|
||||||
const check = (key, text, hint) => h('label', { class: 'check' },
|
const check = (key, text, hint) => h('label', { class: 'check' },
|
||||||
h('input', { type: 'checkbox', checked: !!S[key], onchange: e => save({ [key]: e.target.checked }) }),
|
h('input', { type: 'checkbox', checked: !!S[key], onchange: e => save({ [key]: e.target.checked }) }),
|
||||||
@@ -90,6 +140,8 @@
|
|||||||
h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'),
|
h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'),
|
||||||
h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.'))));
|
h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.'))));
|
||||||
|
|
||||||
|
section('user/reading').append(readerSessionsCard());
|
||||||
|
|
||||||
section('user/reading').append(h('section', { class: 'card' },
|
section('user/reading').append(h('section', { class: 'card' },
|
||||||
h('h2', {}, 'Library page'),
|
h('h2', {}, 'Library page'),
|
||||||
check('continueRow', 'Show "Continue where you left off" at the top',
|
check('continueRow', 'Show "Continue where you left off" at the top',
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
'use strict';
|
||||||
|
/* Reader settings per login session (docs/architecture.md, "Reader settings per session").
|
||||||
|
*
|
||||||
|
* The user's own reader settings (`user.settings`) are the "latest" set. A session that never changed a
|
||||||
|
* reader setting follows the latest set; the first change copies the latest set into the session and
|
||||||
|
* locks it there, and also makes it the new latest set. Using a locked session makes its set the latest
|
||||||
|
* again, so a new login starts from the last active session. With `readerPerSession: false` there is
|
||||||
|
* one set per user, as before; locked sets are kept and return when the switch is turned on again. */
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const READER_KEYS = ['startMode', 'lastMode', 'adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'pageTone', 'zoom'];
|
||||||
|
|
||||||
|
const perSession = settings => settings.readerPerSession !== false;
|
||||||
|
const publicId = key => crypto.createHash('sha256').update('reader-session:' + key).digest('hex').slice(0, 16);
|
||||||
|
|
||||||
|
function pick(settings) {
|
||||||
|
const out = {};
|
||||||
|
for (const k of READER_KEYS) if (settings[k] !== undefined) out[k] = k === 'zoom' ? { ...settings[k] } : settings[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `base` is the user's merged settings (defaults + user.settings); the session's locked set wins over it.
|
||||||
|
function effective(base, session) {
|
||||||
|
if (!perSession(base) || !session?.reader) return base;
|
||||||
|
return { ...base, ...pick(session.reader), zoom: { ...session.reader.zoom } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits a validated settings patch: reader keys go to the session (locking it) and to the latest set.
|
||||||
|
function applyPatch({ user, base, session, key, patch }) {
|
||||||
|
const s = (user.settings = user.settings || {});
|
||||||
|
const reader = pick(patch), rest = Object.fromEntries(Object.entries(patch).filter(([k]) => !READER_KEYS.includes(k)));
|
||||||
|
Object.assign(s, rest);
|
||||||
|
if (!Object.keys(reader).length) return;
|
||||||
|
if (perSession({ ...base, ...rest }) && session) {
|
||||||
|
session.reader = { ...pick(effective(base, session)), ...reader };
|
||||||
|
Object.assign(s, pick(session.reader));
|
||||||
|
user.readerFrom = publicId(key);
|
||||||
|
} else {
|
||||||
|
Object.assign(s, reader);
|
||||||
|
user.readerFrom = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request from a locked session makes its set the latest one. Returns true when something changed.
|
||||||
|
function noteActive(user, base, session, key) {
|
||||||
|
if (!perSession(base) || !session?.reader) return false;
|
||||||
|
const id = publicId(key);
|
||||||
|
if (user.readerFrom === id) return false;
|
||||||
|
user.settings = { ...(user.settings || {}), ...pick(session.reader) };
|
||||||
|
user.readerFrom = id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceLabel(agent) {
|
||||||
|
const a = String(agent || '');
|
||||||
|
if (!a) return 'Unknown device';
|
||||||
|
const os = /iPhone/.test(a) ? 'iPhone' : /iPad/.test(a) ? 'iPad' : /Android/.test(a) ? 'Android'
|
||||||
|
: /Windows/.test(a) ? 'Windows' : /Mac OS X|Macintosh/.test(a) ? 'Mac' : /CrOS/.test(a) ? 'ChromeOS' : /Linux/.test(a) ? 'Linux' : '';
|
||||||
|
const browser = /Edg\//.test(a) ? 'Edge' : /OPR\//.test(a) ? 'Opera' : /Firefox\//.test(a) ? 'Firefox'
|
||||||
|
: /Chrome\//.test(a) ? 'Chrome' : /Safari\//.test(a) ? 'Safari' : '';
|
||||||
|
return [browser, os].filter(Boolean).join(' on ') || a.slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What Settings shows: every login session of the user with its state and effective reader set.
|
||||||
|
function overview(user, base, sessions, currentKey) {
|
||||||
|
const list = Object.entries(sessions).filter(([, s]) => s.u === user.name).map(([key, s]) => ({
|
||||||
|
id: publicId(key), device: deviceLabel(s.agent), created: s.t, seen: s.seen || s.t,
|
||||||
|
current: key === currentKey, locked: !!s.reader, reader: pick(effective(base, s)),
|
||||||
|
})).sort((a, b) => b.current - a.current || b.seen - a.seen);
|
||||||
|
return { enabled: perSession(base), current: currentKey ? publicId(currentKey) : null,
|
||||||
|
latestFrom: user.readerFrom || null, latest: pick(base), sessions: list };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings actions on one of the user's sessions: 'follow' drops its lock, 'latest' makes its set the latest.
|
||||||
|
function act(user, base, sessions, id, action) {
|
||||||
|
const entry = Object.entries(sessions).find(([key, s]) => s.u === user.name && publicId(key) === id);
|
||||||
|
if (!entry) return false;
|
||||||
|
const [key, session] = entry;
|
||||||
|
if (action === 'follow') { delete session.reader; if (user.readerFrom === id) user.readerFrom = null; return true; }
|
||||||
|
if (action === 'latest') {
|
||||||
|
user.settings = { ...(user.settings || {}), ...pick(effective(base, session)) };
|
||||||
|
user.readerFrom = session.reader ? publicId(key) : null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { READER_KEYS, publicId, pick, effective, applyPatch, noteActive, deviceLabel, overview, act };
|
||||||
@@ -40,6 +40,7 @@ const { BackupService, BackupError } = require('./backup');
|
|||||||
const moments = require('./moments');
|
const moments = require('./moments');
|
||||||
const { Tracer, requestTrace } = require('./perf/trace');
|
const { Tracer, requestTrace } = require('./perf/trace');
|
||||||
const { ReadAhead } = require('./perf/read-ahead');
|
const { ReadAhead } = require('./perf/read-ahead');
|
||||||
|
const readerSessions = require('./reader-sessions');
|
||||||
|
|
||||||
const PORT = +process.env.PORT || 8080;
|
const PORT = +process.env.PORT || 8080;
|
||||||
const LIB = path.resolve(process.env.LIBRARY || 'library');
|
const LIB = path.resolve(process.env.LIBRARY || 'library');
|
||||||
@@ -119,8 +120,10 @@ function checkPw(pw, stored) {
|
|||||||
}
|
}
|
||||||
const DUMMY_HASH = hashPw('x');
|
const DUMMY_HASH = hashPw('x');
|
||||||
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true,
|
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true,
|
||||||
continueRow: true, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '' };
|
continueRow: true, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '', readerPerSession: true };
|
||||||
const settingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
|
const userSettingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
|
||||||
|
// With a login session, its locked reader settings (reader-sessions.js) win over the user's latest set.
|
||||||
|
const settingsOf = (u, sess) => readerSessions.effective(userSettingsOf(u), sess);
|
||||||
|
|
||||||
function createUser(name, pw, admin, replace) {
|
function createUser(name, pw, admin, replace) {
|
||||||
name = String(name || '').trim().toLowerCase(); pw = String(pw || '');
|
name = String(name || '').trim().toLowerCase(); pw = String(pw || '');
|
||||||
@@ -132,13 +135,21 @@ function createUser(name, pw, admin, replace) {
|
|||||||
save();
|
save();
|
||||||
return db.users[name];
|
return db.users[name];
|
||||||
}
|
}
|
||||||
function startSession(res, user) {
|
function startSession(req, res, user) {
|
||||||
const token = crypto.randomBytes(32).toString('base64url');
|
const token = crypto.randomBytes(32).toString('base64url');
|
||||||
db.sessions[sha(token)] = { u: user.name, t: Date.now() };
|
db.sessions[sha(token)] = { u: user.name, t: Date.now(), agent: String(req.headers['user-agent'] || '').slice(0, 300) };
|
||||||
save();
|
save();
|
||||||
res.setHeader('Set-Cookie', `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${SECURE_COOKIE ? '; Secure' : ''}`);
|
res.setHeader('Set-Cookie', `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${SECURE_COOKIE ? '; Secure' : ''}`);
|
||||||
}
|
}
|
||||||
const tokenOf = req => { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || ''); return m ? m[1] : null; };
|
const tokenOf = req => { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || ''); return m ? m[1] : null; };
|
||||||
|
function sessionKeyOf(req) { const t = tokenOf(req); return t ? sha(t) : null; }
|
||||||
|
// Last-seen time (coarse, to keep db.json writes rare) and device label of a login session.
|
||||||
|
function touchSession(u, sess, key, req) {
|
||||||
|
let changed = false;
|
||||||
|
if (!sess.agent && req.headers['user-agent']) { sess.agent = String(req.headers['user-agent']).slice(0, 300); changed = true; }
|
||||||
|
if (Date.now() - (sess.seen || 0) > 5 * 60e3) { sess.seen = Date.now(); changed = true; }
|
||||||
|
if (changed) save();
|
||||||
|
}
|
||||||
function userOf(req) { const t = tokenOf(req), s = t && db.sessions[sha(t)]; return (s && db.users[s.u]) || null; }
|
function userOf(req) { const t = tokenOf(req), s = t && db.sessions[sha(t)]; return (s && db.users[s.u]) || null; }
|
||||||
for (const [k, s] of Object.entries(db.sessions)) if (Date.now() - s.t > 365 * 864e5 || !db.users[s.u]) delete db.sessions[k];
|
for (const [k, s] of Object.entries(db.sessions)) if (Date.now() - s.t > 365 * 864e5 || !db.users[s.u]) delete db.sessions[k];
|
||||||
|
|
||||||
@@ -471,11 +482,11 @@ const PUBLIC = new Set(['/api/auth', '/api/login', '/api/signup']);
|
|||||||
const routes = {
|
const routes = {
|
||||||
'GET /api/health': () => ({ ok: !scanState.error, service: 'reader', uptime: Math.round(process.uptime()), scan: scanState }),
|
'GET /api/health': () => ({ ok: !scanState.error, service: 'reader', uptime: Math.round(process.uptime()), scan: scanState }),
|
||||||
'GET /api/collections': ({ u }) => ({ collections: routes['GET /api/library']({ u }).collections }),
|
'GET /api/collections': ({ u }) => ({ collections: routes['GET /api/library']({ u }).collections }),
|
||||||
'GET /api/collection': ({ u, q }) => {
|
'GET /api/collection': ({ u, q, sess }) => {
|
||||||
const view = routes['GET /api/library']({ u }).collections.find(c => c.id === q.get('id')) || bad(404, 'Collection not found');
|
const view = routes['GET /api/library']({ u }).collections.find(c => c.id === q.get('id')) || bad(404, 'Collection not found');
|
||||||
const next = view.members.find(m => m.read < m.chapters) || view.members[0];
|
const next = view.members.find(m => m.read < m.chapters) || view.members[0];
|
||||||
const manga = mangaOf(next.id);
|
const manga = mangaOf(next.id);
|
||||||
return { ...view, continue: { m: next.id, ...continueOf(manga, progressOf(u, manga), settingsOf(u)) },
|
return { ...view, continue: { m: next.id, ...continueOf(manga, progressOf(u, manga), settingsOf(u, sess)) },
|
||||||
relations: catalog.state.relations.filter(r => r.state === 'confirmed' && view.members.some(m => m.id === r.from || m.id === r.to)) };
|
relations: catalog.state.relations.filter(r => r.state === 'confirmed' && view.members.some(m => m.id === r.from || m.id === r.to)) };
|
||||||
},
|
},
|
||||||
'GET /api/admin/catalog': ({ u }) => { admin(u); return { ...catalog.state, types: TYPES, roles: ROLES, mangas: lib.mangas.map(m => ({ id: m.id, title: m.title })) }; },
|
'GET /api/admin/catalog': ({ u }) => { admin(u); return { ...catalog.state, types: TYPES, roles: ROLES, mangas: lib.mangas.map(m => ({ id: m.id, title: m.title })) }; },
|
||||||
@@ -523,16 +534,16 @@ const routes = {
|
|||||||
},
|
},
|
||||||
'GET /api/auth': () => ({ firstRun: !Object.keys(db.users).length, signup: OPEN_SIGNUP }),
|
'GET /api/auth': () => ({ firstRun: !Object.keys(db.users).length, signup: OPEN_SIGNUP }),
|
||||||
|
|
||||||
'POST /api/signup': ({ b, res }) => {
|
'POST /api/signup': ({ req, b, res }) => {
|
||||||
const first = !Object.keys(db.users).length;
|
const first = !Object.keys(db.users).length;
|
||||||
if (!first && !OPEN_SIGNUP) bad(403, 'Sign-up is closed. Ask an admin for an account.');
|
if (!first && !OPEN_SIGNUP) bad(403, 'Sign-up is closed. Ask an admin for an account.');
|
||||||
startSession(res, createUser(b.name, b.password, first));
|
startSession(req, res, createUser(b.name, b.password, first));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
'POST /api/login': ({ b, res }) => {
|
'POST /api/login': ({ req, b, res }) => {
|
||||||
const user = db.users[String(b.name || '').trim().toLowerCase()], pw = String(b.password || '');
|
const user = db.users[String(b.name || '').trim().toLowerCase()], pw = String(b.password || '');
|
||||||
if (!checkPw(pw, user ? user.hash : DUMMY_HASH) || !user) bad(403, 'Wrong user name or password.');
|
if (!checkPw(pw, user ? user.hash : DUMMY_HASH) || !user) bad(403, 'Wrong user name or password.');
|
||||||
startSession(res, user);
|
startSession(req, res, user);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
'POST /api/logout': ({ req, res }) => {
|
'POST /api/logout': ({ req, res }) => {
|
||||||
@@ -541,7 +552,18 @@ const routes = {
|
|||||||
res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
'GET /api/me': ({ u }) => ({ name: u.name, admin: !!u.admin, settings: settingsOf(u), unread: unreadOf(u) }),
|
// Every page loads /api/me, so a page view marks its session as the last active one for reader settings.
|
||||||
|
'GET /api/me': ({ u, sess, sk }) => {
|
||||||
|
if (sess && readerSessions.noteActive(u, userSettingsOf(u), sess, sk)) save();
|
||||||
|
return { name: u.name, admin: !!u.admin, settings: settingsOf(u, sess), unread: unreadOf(u) };
|
||||||
|
},
|
||||||
|
'GET /api/reader-sessions': ({ u, sk }) => readerSessions.overview(u, userSettingsOf(u), db.sessions, sk),
|
||||||
|
'POST /api/reader-sessions': ({ u, b, sk }) => {
|
||||||
|
if (!['follow', 'latest'].includes(b.action)) bad(400, 'Unknown action');
|
||||||
|
if (!readerSessions.act(u, userSettingsOf(u), db.sessions, String(b.id || ''), b.action)) bad(404, 'Session not found');
|
||||||
|
save();
|
||||||
|
return readerSessions.overview(u, userSettingsOf(u), db.sessions, sk);
|
||||||
|
},
|
||||||
|
|
||||||
'GET /api/reading-schedule': () => publicSchedule(readingSchedule),
|
'GET /api/reading-schedule': () => publicSchedule(readingSchedule),
|
||||||
'GET /api/reading-access': ({ u, q }) => { mangaOf(q.get('m')); return readingAccess.status(u.name, true); },
|
'GET /api/reading-access': ({ u, q }) => { mangaOf(q.get('m')); return readingAccess.status(u.name, true); },
|
||||||
@@ -558,22 +580,23 @@ const routes = {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
'POST /api/settings': ({ u, b }) => {
|
'POST /api/settings': ({ u, b, sess, sk }) => {
|
||||||
if (typeof b.webhook === 'string' && b.webhook.trim() && !/^https?:\/\/\S+$/i.test(b.webhook.trim())) bad(400, 'The webhook must be an http(s) URL.');
|
if (typeof b.webhook === 'string' && b.webhook.trim() && !/^https?:\/\/\S+$/i.test(b.webhook.trim())) bad(400, 'The webhook must be an http(s) URL.');
|
||||||
const s = (u.settings = u.settings || {});
|
const base = userSettingsOf(u), s = {};
|
||||||
if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode;
|
if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode;
|
||||||
if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode;
|
if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode;
|
||||||
if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone;
|
if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone;
|
||||||
if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate;
|
if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate;
|
||||||
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate', 'continueRow',
|
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate', 'continueRow',
|
||||||
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature']) if (typeof b[k] === 'boolean') s[k] = b[k];
|
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature', 'readerPerSession']) if (typeof b[k] === 'boolean') s[k] = b[k];
|
||||||
if (b.zoom && typeof b.zoom === 'object') {
|
if (b.zoom && typeof b.zoom === 'object') {
|
||||||
s.zoom = {};
|
s.zoom = {};
|
||||||
for (const k of ['flow', 'page', 'book', 'strip']) if (+b.zoom[k] > 0) s.zoom[k] = Math.min(8, Math.max(0.1, +b.zoom[k]));
|
for (const k of ['flow', 'page', 'book', 'strip']) if (+b.zoom[k] > 0) s.zoom[k] = Math.min(8, Math.max(0.1, +b.zoom[k]));
|
||||||
}
|
}
|
||||||
if (typeof b.webhook === 'string') s.webhook = b.webhook.trim().slice(0, 500);
|
if (typeof b.webhook === 'string') s.webhook = b.webhook.trim().slice(0, 500);
|
||||||
|
readerSessions.applyPatch({ user: u, base, session: sess, key: sk, patch: s });
|
||||||
save();
|
save();
|
||||||
return { settings: settingsOf(u) };
|
return { settings: settingsOf(u, sess) };
|
||||||
},
|
},
|
||||||
'POST /api/password': ({ u, b }) => {
|
'POST /api/password': ({ u, b }) => {
|
||||||
if (!checkPw(String(b.old || ''), u.hash)) bad(403, 'The current password is wrong.');
|
if (!checkPw(String(b.old || ''), u.hash)) bad(403, 'The current password is wrong.');
|
||||||
@@ -701,8 +724,8 @@ const routes = {
|
|||||||
return { results: await searchGreatestMangaDex(entry) };
|
return { results: await searchGreatestMangaDex(entry) };
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /api/manga': ({ u, q }) => {
|
'GET /api/manga': ({ u, q, sess }) => {
|
||||||
const m = mangaOf(q.get('m')), prog = progressOf(u, m), s = settingsOf(u), showStory = s.readingStory;
|
const m = mangaOf(q.get('m')), prog = progressOf(u, m), s = settingsOf(u, sess), showStory = s.readingStory;
|
||||||
const records = mangaReadingOf(u, m);
|
const records = mangaReadingOf(u, m);
|
||||||
return {
|
return {
|
||||||
chapterDates: { read: s.readDate, release: s.releaseDate },
|
chapterDates: { read: s.readDate, release: s.releaseDate },
|
||||||
@@ -719,7 +742,7 @@ const routes = {
|
|||||||
dates: s.releaseDate ? chapterDates(ch.versions) : null,
|
dates: s.releaseDate ? chapterDates(ch.versions) : null,
|
||||||
})),
|
})),
|
||||||
reading: showStory ? mangaInsight(records, m.chapters.map(ch => ch.id)) : null,
|
reading: showStory ? mangaInsight(records, m.chapters.map(ch => ch.id)) : null,
|
||||||
continue: continueOf(m, prog, settingsOf(u)),
|
continue: continueOf(m, prog, s),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -760,8 +783,8 @@ const routes = {
|
|||||||
return { ok: true, deleted: m.id };
|
return { ok: true, deleted: m.id };
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /api/chapter': ({ u, q }) => {
|
'GET /api/chapter': ({ u, q, sess }) => {
|
||||||
const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u), prog = progressOf(u, m);
|
const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u, sess), prog = progressOf(u, m);
|
||||||
if (!readingAccess.status(u.name).allowed) bad(423, 'Reading is outside the deployment schedule. Complete the five-question challenge to unlock it.');
|
if (!readingAccess.status(u.name).allowed) bad(423, 'Reading is outside the deployment schedule. Complete the five-question challenge to unlock it.');
|
||||||
const v = ch.versions.find(x => x.id === q.get('v')) || ranked(m, ch)[0].v;
|
const v = ch.versions.find(x => x.id === q.get('v')) || ranked(m, ch)[0].v;
|
||||||
// Opening a chapter clears its "new chapter" notification.
|
// Opening a chapter clears its "new chapter" notification.
|
||||||
@@ -1102,7 +1125,9 @@ async function handleApi(req, res, u, route, q) {
|
|||||||
if (!/^application\/json/.test(req.headers['content-type'] || '') || req.headers['sec-fetch-site'] === 'cross-site') bad(403, 'Bad request origin');
|
if (!/^application\/json/.test(req.headers['content-type'] || '') || req.headers['sec-fetch-site'] === 'cross-site') bad(403, 'Bad request origin');
|
||||||
b = (await readBody(req)) || {};
|
b = (await readBody(req)) || {};
|
||||||
}
|
}
|
||||||
sendJson(res, 200, await route({ req, res, u, b, q }));
|
const sk = u && sessionKeyOf(req), sess = sk && db.sessions[sk];
|
||||||
|
if (sess) touchSession(u, sess, sk, req);
|
||||||
|
sendJson(res, 200, await route({ req, res, u, b, q, sess, sk }));
|
||||||
}
|
}
|
||||||
function serveFile(req, res, root, rel, cacheControl, only, trace) {
|
function serveFile(req, res, root, rel, cacheControl, only, trace) {
|
||||||
let file;
|
let file;
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use strict';
|
||||||
|
// Reader settings per login session: a session follows the latest set until it changes one, then keeps its own.
|
||||||
|
const test = require('node:test'), assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const readerSessions = require('../reader-sessions');
|
||||||
|
|
||||||
|
async function freePort() {
|
||||||
|
const listener = net.createServer();
|
||||||
|
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = listener.address().port;
|
||||||
|
await new Promise(resolve => listener.close(resolve));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('device labels name browser and system', () => {
|
||||||
|
assert.equal(readerSessions.deviceLabel('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1'), 'Safari on iPhone');
|
||||||
|
assert.equal(readerSessions.deviceLabel('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0 Safari/537.36 Edg/130.0'), 'Edge on Windows');
|
||||||
|
assert.equal(readerSessions.deviceLabel(''), 'Unknown device');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sessions lock their reader settings on the first change, new ones copy the last active one', { timeout: 20000 }, async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-reader-sessions-'));
|
||||||
|
let child;
|
||||||
|
try {
|
||||||
|
const library = path.join(root, 'library'), data = path.join(root, 'data');
|
||||||
|
fs.mkdirSync(library); fs.mkdirSync(data);
|
||||||
|
const port = await freePort(), output = [];
|
||||||
|
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
|
||||||
|
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
|
||||||
|
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '',
|
||||||
|
GREATEST_COVER_WARM: '0' } });
|
||||||
|
child.stdout.on('data', d => output.push(String(d)));
|
||||||
|
child.stderr.on('data', d => output.push(String(d)));
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
let ready = false;
|
||||||
|
for (let n = 0; n < 100 && !ready; n++) {
|
||||||
|
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
|
||||||
|
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
assert.ok(ready, output.join(''));
|
||||||
|
|
||||||
|
const json = async r => { const body = await r.json(); assert.equal(r.status, 200, JSON.stringify(body)); return body; };
|
||||||
|
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) Firefox/130.0', ...(cookie ? { cookie } : {}) },
|
||||||
|
body: JSON.stringify(body) }).then(async r => (url.endsWith('login') || url.endsWith('signup') ? r : json(r)));
|
||||||
|
const me = async cookie => (await json(await fetch(base + '/api/me', { headers: { cookie } }))).settings;
|
||||||
|
const overview = cookie => fetch(base + '/api/reader-sessions', { headers: { cookie } }).then(json);
|
||||||
|
const login = async () => (await post(null, '/api/login', { name: 'reader', password: 'reader-password' })).headers.get('set-cookie').split(';')[0];
|
||||||
|
|
||||||
|
const a = (await post(null, '/api/signup', { name: 'reader', password: 'reader-password' })).headers.get('set-cookie').split(';')[0];
|
||||||
|
// First session: defaults, following the latest set.
|
||||||
|
assert.equal((await me(a)).lastMode, 'page');
|
||||||
|
assert.equal((await overview(a)).sessions[0].locked, false);
|
||||||
|
|
||||||
|
// Session B exists before A changes anything: it follows A's change.
|
||||||
|
const b = await login();
|
||||||
|
await post(a, '/api/settings', { lastMode: 'flow', zoom: { flow: 1.5 } });
|
||||||
|
assert.equal((await me(b)).lastMode, 'flow');
|
||||||
|
assert.equal((await me(b)).zoom.flow, 1.5);
|
||||||
|
|
||||||
|
// B changes its own: B locks, A keeps its own set.
|
||||||
|
await post(b, '/api/settings', { pageTone: 'sepia' });
|
||||||
|
assert.equal((await me(b)).pageTone, 'sepia');
|
||||||
|
assert.equal((await me(b)).lastMode, 'flow');
|
||||||
|
assert.equal((await me(a)).pageTone, 'original');
|
||||||
|
|
||||||
|
// A non-reader setting is shared and does not lock anything.
|
||||||
|
const c = await login();
|
||||||
|
await post(c, '/api/settings', { continueRow: false });
|
||||||
|
assert.equal((await me(a)).continueRow, false);
|
||||||
|
assert.equal((await overview(c)).sessions.find(s => s.current).locked, false);
|
||||||
|
// C follows the last active session, which is A (it loaded /api/me last).
|
||||||
|
assert.equal((await me(c)).pageTone, 'original');
|
||||||
|
await me(b);
|
||||||
|
assert.equal((await me(c)).pageTone, 'sepia');
|
||||||
|
|
||||||
|
// Overview: three sessions, labels, intent.
|
||||||
|
const o = await overview(a);
|
||||||
|
assert.equal(o.enabled, true);
|
||||||
|
assert.equal(o.sessions.length, 3);
|
||||||
|
assert.equal(o.sessions[0].current, true);
|
||||||
|
assert.equal(o.sessions[0].device, 'Firefox on Linux');
|
||||||
|
assert.deepEqual(o.sessions.map(s => s.locked).sort(), [false, true, true]);
|
||||||
|
|
||||||
|
// "Follow latest again" drops A's lock; "Use for new sessions" makes a set the latest.
|
||||||
|
const idA = o.sessions[0].id, idB = o.sessions.find(s => !s.current && s.locked).id;
|
||||||
|
await post(a, '/api/reader-sessions', { id: idA, action: 'follow' });
|
||||||
|
assert.equal((await me(a)).pageTone, 'sepia');
|
||||||
|
assert.equal((await fetch(base + '/api/reader-sessions', { method: 'POST', headers: { 'content-type': 'application/json', cookie: a },
|
||||||
|
body: JSON.stringify({ id: 'nope', action: 'follow' }) })).status, 404);
|
||||||
|
assert.equal((await post(a, '/api/reader-sessions', { id: idB, action: 'latest' })).latestFrom, idB);
|
||||||
|
|
||||||
|
// Off: one shared set; a change applies everywhere and locks nothing. On again: kept locks return.
|
||||||
|
await post(b, '/api/settings', { readerPerSession: false });
|
||||||
|
await post(a, '/api/settings', { pageTone: 'dim' });
|
||||||
|
assert.equal((await me(b)).pageTone, 'dim');
|
||||||
|
assert.equal((await me(c)).pageTone, 'dim');
|
||||||
|
await post(a, '/api/settings', { readerPerSession: true });
|
||||||
|
assert.equal((await me(b)).pageTone, 'sepia');
|
||||||
|
assert.equal((await me(a)).pageTone, 'sepia');
|
||||||
|
} finally {
|
||||||
|
if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user