Compare commits

...

10 Commits

Author SHA1 Message Date
Huang Xin f2640359a0 release: version 0.9.72 (#1831) 2025-08-18 17:43:09 +02:00
Huang Xin 079aeaedb0 fix(sync): support custom koreader sync server hosted in LAN, closes #1800 (#1830) 2025-08-18 17:35:49 +02:00
Huang Xin bfacadb964 layout: fix options list align right on Windows (#1829) 2025-08-18 16:10:06 +02:00
Huang Xin 2e98ed44ee tts: fix edge tts, closes #1821 (#1828) 2025-08-18 14:44:38 +02:00
dependabot[bot] 952d2a3553 chore(deps): bump actions/checkout in the github-actions group (#1827)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 13:36:01 +02:00
StepanSad 225aa0ca59 Update translation.json (#1826) 2025-08-17 20:15:14 +08:00
Huang Xin 37f718b84b fix: adjust snap threshold to avoid pagination changes when toggling toolbars, closes #1807 (#1811) 2025-08-14 17:00:52 +02:00
Huang Xin db8f53a6e1 css: background color for dialogs and images, closes #1797 (#1798) 2025-08-13 15:37:52 +02:00
Huang Xin ce345ad67f fix(koplugin): add expected 301 status code (#1791) 2025-08-12 21:33:51 +02:00
Huang Xin 8e53e625a4 fix(koplugin): fix typo of saveSetting, closes #1788 (#1789) 2025-08-12 20:20:43 +02:00
22 changed files with 178 additions and 48 deletions
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
env:
RUSTFLAGS: '-C target-cpu=skylake'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
submodules: 'true'
- name: Install minimal stable with clippy and rustfmt
@@ -38,7 +38,7 @@ jobs:
- platform: 'web'
- platform: 'tauri'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
submodules: 'true'
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
release_version: ${{ steps.get-release-notes.outputs.release_version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v4
- name: get version
@@ -91,7 +91,7 @@ jobs:
runs-on: ${{ matrix.config.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: initialize git submodules
run: git submodule update --init --recursive
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
submodules: 'true'
- uses: amondnet/vercel-action@v25
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.71",
"version": "0.9.72",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -93,7 +93,7 @@
"Slow": "Повільно",
"Solarized": "Сонячний",
"Speak": "Озвучити",
"Subjects": "Теми",
"Subjects": "Жанри",
"System Fonts": "Системні шрифти",
"Theme Color": "Колір теми",
"Theme Mode": "Режим теми",
@@ -469,26 +469,26 @@
"Disable Double Click": "Вимкнути подвійне натискання",
"TTS not supported for this document": "TTS не підтримується в цьому документі",
"Reset Password": "Скинути пароль",
"Show Reading Progress": "Показати прогрес читання",
"Reading Progress Style": "Стиль прогресу читання",
"Show Reading Progress": "Показати проґрес читання",
"Reading Progress Style": "Стиль проґресу читання",
"Page Number": "Номер сторінки",
"Percentage": "Процент",
"Percentage": "Відсотки",
"Deleted local copy of the book: {{title}}": "Видалено локальну копію книги: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Не вдалося видалити резервну копію книги в хмарі: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Не вдалося видалити локальну копію книги: {{title}}",
"Are you sure to delete the local copy of the selected book?": "Ви впевнені, що хочете видалити локальну копію вибраної книги?",
"Remove from Cloud & Device": "Видалити з хмари та пристрою",
"Remove from Cloud Only": "Видалити тільки з хмари",
"Remove from Device Only": "Видалити тільки з пристрою",
"Disconnected": "Відключено",
"Remove from Cloud Only": "Видалити тільки із хмари",
"Remove from Device Only": "Видалити тільки із пристрою",
"Disconnected": "Від'єднано",
"KOReader Sync Settings": "Налаштування синхронізації KOReader",
"Sync Strategy": "Стратегія синхронізації",
"Ask on conflict": "Запитувати при конфлікті",
"Always use latest": "Завжди використовувати останню версію",
"Always use latest": "Завжди використовувати найновішу",
"Send changes only": "Відправити тільки зміни",
"Receive changes only": "Отримати тільки зміни",
"Disabled": "Вимкнено",
"Checksum Method": "Метод контрольної суми",
"Checksum Method": "Метод контролю суми",
"File Content (recommended)": "Вміст файлу (рекомендується)",
"File Name": "Ім'я файлу",
"Device Name": "Ім'я пристрою",
@@ -498,19 +498,19 @@
"Username": "Ім'я користувача",
"Your Username": "Ваше ім'я користувача",
"Password": "Пароль",
"Connect": "Підключитися",
"Connect": "Під'єднатися",
"KOReader Sync": "Синхронізація KOReader",
"Sync Conflict": "Конфлікт синхронізації",
"Sync reading progress from \"{{deviceName}}\"?": "Синхронізувати прогрес читання з \"{{deviceName}}\"?",
"Sync reading progress from \"{{deviceName}}\"?": "Синхронізувати прогрес читання із \"{{deviceName}}\"?",
"another device": "інший пристрій",
"Local Progress": "Локальний прогрес",
"Remote Progress": "Віддалений прогрес",
"Remote Progress": "Прогрес у хмарі",
"Page {{page}} of {{total}} ({{percentage}}%)": "Сторінка {{page}} з {{total}} ({{percentage}}%)",
"Current position": "Поточна позиція",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизно сторінка {{page}} з {{total}} ({{percentage}}%)",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизно сторінка {{page}} із {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Приблизно {{percentage}}%",
"Failed to connect": "Не вдалося підключитися",
"Sync Server Connected": "Сервер синхронізації підключено",
"Failed to connect": "Не вдалося під'єднатися",
"Sync Server Connected": "Сервер синхронізації під'єднано",
"Precision: {{precision}} digits after the decimal": "Точність: {{precision}} знаків після коми",
"Sync as {{userDisplayName}}": "Синхронізувати як {{userDisplayName}}"
}
+10 -1
View File
@@ -1,9 +1,18 @@
{
"releases": {
"0.9.72": {
"date": "2025-08-18",
"notes": [
"Fixed Edge TTS voice playback for EPUBs",
"Reduced accidental page flips when toggling toolbars",
"Fixed images with background colors not displaying correctly in some books",
"Added support for custom KOReader Sync Servers on your local network (LAN)"
]
},
"0.9.71": {
"date": "2025-08-13",
"notes": [
"Sync: Added two ways to sync reading progress with Koreader devices",
"Sync: Added two ways to sync reading progress with KOReader devices",
"EPUB: Applied monospace font settings",
"EPUB: Fixed initial text alignment for some EPUB files",
"PDF: Fixed issue opening large PDF files on Android",
+1 -1
View File
@@ -41,7 +41,7 @@ tauri-plugin-log = "2"
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
tauri-plugin-os = "2"
tauri-plugin-http = "2"
tauri-plugin-http = { version = "2", features = ["dangerous-settings"] }
tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri-plugin-oauth = "2"
@@ -87,6 +87,12 @@
},
{
"url": "https://translate.googleapis.com"
},
{
"url": "http://*:*"
},
{
"url": "https://*:*"
}
]
},
@@ -16,6 +16,7 @@ import { getOSPlatform } from '@/utils/misc';
type Option = {
value: string;
label: string;
disabled?: boolean;
};
type SelectProps = {
@@ -43,8 +44,8 @@ const StyledSelect: React.FC<SelectProps> = ({
)}
disabled={disabled}
>
{options.map(({ value, label }) => (
<option key={value} value={value}>
{options.map(({ value, label, disabled = false }) => (
<option key={value} value={value} disabled={disabled}>
{label}
</option>
))}
@@ -83,6 +84,12 @@ export const KOSyncSettingsWindow: React.FC = () => {
// Get the OS name once
useEffect(() => {
const formatOsName = (name: string): string => {
if (!name) return '';
if (name.toLowerCase() === 'macos') return 'macOS';
return name.charAt(0).toUpperCase() + name.slice(1);
};
const getOsName = async () => {
let name = '';
if (appService?.appPlatform === 'tauri') {
@@ -93,7 +100,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
name = platform;
}
}
setOsName(name ? name.charAt(0).toUpperCase() + name.slice(1) : '');
setOsName(formatOsName(name));
};
getOsName();
}, [appService]);
@@ -236,7 +243,6 @@ export const KOSyncSettingsWindow: React.FC = () => {
onClose={() => setIsOpen(false)}
title={_('KOReader Sync Settings')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
bgClassName='!bg-black/60'
>
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
{isConfigured ? (
@@ -284,7 +290,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
onChange={handleChecksumMethodChange}
options={[
{ value: 'binary', label: _('File Content (recommended)') },
{ value: 'filename', label: _('File Name') },
{ value: 'filename', label: _('File Name'), disabled: true },
]}
/>
</div>
@@ -335,6 +341,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
type='text'
placeholder='https://koreader.sync.server'
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
@@ -347,6 +354,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
type='text'
placeholder={_('Your Username')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
@@ -264,7 +264,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
onChange={(event) => setCodeLanguage(event.target.value as CodeLanguage)}
options={CODE_LANGUAGES.map((lang) => ({
value: lang,
label: lang,
label: lang === 'auto-detect' ? _('Auto') : lang,
}))}
disabled={!codeHighlighting}
/>
@@ -163,6 +163,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
isOpen={true}
onClose={handleClose}
className='modal-open'
bgClassName='sm:!bg-black/20'
boxClassName={clsx('sm:min-w-[520px]', appService?.isMobile && 'sm:max-w-[90%] sm:w-3/4')}
snapHeight={appService?.isMobile ? 0.7 : undefined}
header={
@@ -59,7 +59,7 @@ export const useProgressSync = (bookKey: string) => {
config.xpointer = xpointerResult.xpointer;
}
} catch (error) {
console.error('Failed to convert CFI to XPointer', error);
console.warn('Failed to convert CFI to XPointer', error);
}
pushConfig(bookKey, config);
}
+1 -1
View File
@@ -161,7 +161,7 @@ const Dialog: React.FC<DialogProps> = ({
>
<div
className={clsx(
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20',
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/50',
appService?.hasRoundedWindow && 'rounded-window',
bgClassName,
)}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function Select({
value={value}
onChange={onChange}
className={clsx(
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
'select h-8 min-h-8 rounded-md border-none text-sm',
'bg-base-200 focus:outline-none focus:ring-0',
className,
)}
@@ -187,7 +187,6 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
title={editMode ? _('Edit Metadata') : _('Book Details')}
isOpen={isOpen}
onClose={handleClose}
bgClassName='sm:bg-black/50'
boxClassName={clsx(
editMode ? 'sm:min-w-[600px] sm:max-w-[600px]' : 'sm:min-w-[480px] sm:max-w-[480px]',
'sm:h-auto sm:max-h-[90%]',
+34 -1
View File
@@ -1,4 +1,5 @@
import { md5 } from 'js-md5';
import { createHash } from 'crypto';
import { randomMd5 } from '@/utils/misc';
import { LRUCache } from '@/utils/lru';
import { genSSML } from '@/utils/ssml';
@@ -6,6 +7,7 @@ import { genSSML } from '@/utils/ssml';
const EDGE_SPEECH_URL =
'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
const EDGE_API_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
const CHROMIUM_FULL_VERSION = '130.0.2849.68';
const EDGE_TTS_VOICES = {
'af-ZA': ['af-ZA-AdriNeural', 'af-ZA-WillemNeural'],
'am-ET': ['am-ET-AmehaNeural', 'am-ET-MekdesNeural'],
@@ -120,6 +122,31 @@ const EDGE_TTS_VOICES = {
'zh-TW': ['zh-TW-HsiaoChenNeural', 'zh-TW-HsiaoYuNeural', 'zh-TW-YunJheNeural'],
};
/**
* Generates the Sec-MS-GEC token value.
* This function generates a token value based on the current time in Windows file time format
* adjusted for clock skew, and rounded down to the nearest 5 minutes. The token is then hashed
* using SHA256 and returned as an uppercased hex digest.
*
* @returns The generated Sec-MS-GEC token value.
* @see https://github.com/rany2/edge-tts/issues/290#issuecomment-2464956570
*/
const WIN_EPOCH_OFFSET = 11644473600; // Windows epoch offset in seconds (1601 to 1970)
const S_TO_NS = 1000000000; // Seconds to nanoseconds conversion
const generateSecMsGec = () => {
let ticks = Math.floor(Date.now() / 1000);
// Switch to Windows file time epoch (1601-01-01 00:00:00 UTC)
ticks += WIN_EPOCH_OFFSET;
// Round down to the nearest 5 minutes (300 seconds)
ticks -= ticks % 300;
// Convert the ticks to 100-nanosecond intervals (Windows file time format)
ticks *= S_TO_NS / 100;
// Create the string to hash by concatenating the ticks and the trusted client token
const strToHash = `${ticks.toFixed(0)}${EDGE_API_TOKEN}`;
// Compute the SHA256 hash and return the uppercased hex digest
return createHash('sha256').update(strToHash, 'ascii').digest('hex').toUpperCase();
};
const genVoiceList = (voices: Record<string, string[]>) => {
return Object.entries(voices).flatMap(([lang, voices]) => {
return voices.map((id) => {
@@ -150,7 +177,13 @@ export class EdgeSpeechTTS {
async #fetchEdgeSpeechWs({ lang, text, voice, rate }: EdgeTTSPayload): Promise<Response> {
const connectId = randomMd5();
const url = `${EDGE_SPEECH_URL}?ConnectionId=${connectId}&TrustedClientToken=${EDGE_API_TOKEN}`;
const params = new URLSearchParams({
ConnectionId: connectId,
TrustedClientToken: EDGE_API_TOKEN,
'Sec-MS-GEC': generateSecMsGec(),
'Sec-MS-GEC-Version': `1-${CHROMIUM_FULL_VERSION}`,
});
const url = `${EDGE_SPEECH_URL}?${params.toString()}`;
const date = new Date().toString();
const configHeaders = {
'Content-Type': 'application/json; charset=utf-8',
@@ -1,8 +1,10 @@
import { md5 } from 'js-md5';
import { Book } from '@/types/book';
import { KoreaderSyncChecksumMethod } from '@/types/settings';
import { getAPIBaseUrl } from '../environment';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { KoSyncProxyPayload } from '@/types/kosync';
import { isLanAddress } from '@/utils/network';
import { getAPIBaseUrl, isTauriAppPlatform } from '../environment';
/**
* Interface for KOSync progress response from the server
@@ -23,6 +25,7 @@ export class KOSyncClient {
private checksumMethod: KoreaderSyncChecksumMethod;
private deviceId: string;
private deviceName: string;
private isLanServer: boolean;
constructor(
serverUrl: string,
@@ -38,6 +41,7 @@ export class KOSyncClient {
this.checksumMethod = checksumMethod;
this.deviceId = deviceId;
this.deviceName = deviceName;
this.isLanServer = isLanAddress(this.serverUrl);
}
private async request(
@@ -57,6 +61,24 @@ export class KOSyncClient {
headers.set('X-Auth-Key', this.userkey);
}
if (this.isLanServer) {
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const directUrl = `${this.serverUrl}${endpoint}`;
return fetch(directUrl, {
method,
headers: {
accept: 'application/vnd.koreader.v1+json',
...Object.fromEntries(headers.entries()),
},
body,
danger: {
acceptInvalidCerts: true,
acceptInvalidHostnames: true,
},
});
}
const proxyUrl = `${getAPIBaseUrl()}/kosync`;
const proxyBody: KoSyncProxyPayload = {
+55
View File
@@ -0,0 +1,55 @@
export const isLanAddress = (url: string) => {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname;
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return true;
}
// Check for IPv4 private ranges
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const match = hostname.match(ipv4Regex);
if (match) {
const [, a, b, c, d] = match.map(Number);
if (a === undefined || b === undefined || c === undefined || d === undefined) {
return false;
}
// Validate IP format
if (a > 255 || b > 255 || c > 255 || d > 255) {
return false;
}
// Check private IP ranges:
// 10.0.0.0/8 (10.0.0.0 to 10.255.255.255)
if (a === 10) return true;
// 172.16.0.0/12 (172.16.0.0 to 172.31.255.255)
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16 (192.168.0.0 to 192.168.255.255)
if (a === 192 && b === 168) return true;
// 169.254.0.0/16 (link-local addresses)
if (a === 169 && b === 254) return true;
}
// Check for IPv6 private addresses (simplified check)
if (hostname.includes(':')) {
if (
hostname.startsWith('::1') ||
hostname.startsWith('fe80:') ||
hostname.startsWith('fc00:') ||
hostname.startsWith('fd00:')
) {
return true;
}
}
return false;
} catch {
return false;
}
};
+2 -5
View File
@@ -131,9 +131,6 @@ const getColorStyles = (
${overrideColor ? `color: ${primary};` : isDarkMode ? `color: lightblue;` : ''}
text-decoration: none;
}
p:has(img), span:has(img) {
background-color: ${bg};
}
body.pbg {
${isDarkMode ? `background-color: ${bg} !important;` : ''}
}
@@ -142,7 +139,7 @@ const getColorStyles = (
${!isDarkMode && overrideColor ? 'mix-blend-mode: multiply;' : ''}
}
/* horizontal rule */
*:has(hr) {
*:has(> hr[class]) {
background-color: ${bg};
}
hr {
@@ -324,7 +321,7 @@ const getLayoutStyles = (
width: auto;
height: auto;
}
.duokan-footnote img {
.duokan-footnote img:not([class]) {
width: 0.8em;
height: 0.8em;
}
+4 -4
View File
@@ -63,7 +63,7 @@ function ReadestSync:tryRefreshToken()
self.settings.refresh_token = response.refresh_token
self.settings.expires_at = response.expires_at
self.settings.expires_in = response.expires_in
G_reader_settings:writeSetting("readest_sync", self.settings)
G_reader_settings:saveSetting("readest_sync", self.settings)
else
logger.err("ReadestSync: Token refresh failed:", response or "Unknown error")
end
@@ -229,8 +229,8 @@ function ReadestSync:doLogin(email, password, menu)
self.settings.refresh_token = response.refresh_token
self.settings.expires_at = response.expires_at
self.settings.expires_in = response.expires_in
G_reader_settings:writeSetting("readest_sync", self.settings)
G_reader_settings:saveSetting("readest_sync", self.settings)
if menu then
menu:updateItems()
end
@@ -261,7 +261,7 @@ function ReadestSync:logout(menu)
self.settings.refresh_token = nil
self.settings.expires_at = nil
self.settings.expires_in = nil
G_reader_settings:writeSetting("readest_sync", self.settings)
G_reader_settings:saveSetting("readest_sync", self.settings)
if menu then
menu:updateItems()
+3 -3
View File
@@ -1,19 +1,19 @@
{
"base_url": "http://web.readest.com/api",
"base_url": "https://web.readest.com/api",
"name": "readest-sync-api",
"methods": {
"pullChanges": {
"path": "/sync",
"method": "GET",
"required_params": ["since", "type", "book"],
"expected_status": [200, 400, 401, 403]
"expected_status": [200, 400, 301, 401, 403]
},
"pushChanges": {
"path": "/sync",
"method": "POST",
"required_params": ["books", "notes", "configs"],
"payload": ["books", "notes", "configs"],
"expected_status": [200, 201, 400, 401, 403]
"expected_status": [200, 201, 301, 400, 401, 403]
}
}
}