From 394643b95e4afabc0ba30cc2ab4edada90595bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Tue, 30 Jun 2020 15:29:39 +0800 Subject: [PATCH] add utils bridge --- src/bridges/settings.ts | 18 ++- src/bridges/utils.ts | 85 +++++++++++++ src/components/article.tsx | 17 ++- src/components/cards/card.tsx | 3 +- src/components/context-menu.tsx | 11 +- src/components/nav.tsx | 27 ++-- src/components/settings/about.tsx | 10 +- src/components/settings/app.tsx | 21 +--- src/components/settings/rules.tsx | 2 +- src/containers/nav-container.tsx | 15 +-- src/containers/settings/app-container.tsx | 30 +---- src/containers/settings/sources-container.tsx | 26 +--- src/electron.ts | 92 ++++---------- src/main/settings.ts | 19 ++- src/main/utils.ts | 116 ++++++++++++++++++ src/main/window.ts | 86 +++++++++++++ src/preload.ts | 6 +- src/scripts/db.ts | 2 +- src/scripts/models/group.ts | 63 +++++----- src/scripts/models/item.ts | 6 +- src/scripts/models/source.ts | 5 +- src/scripts/settings.ts | 103 +++++++--------- src/scripts/utils.ts | 28 +++-- 23 files changed, 486 insertions(+), 305 deletions(-) create mode 100644 src/bridges/utils.ts create mode 100644 src/main/utils.ts create mode 100644 src/main/window.ts diff --git a/src/bridges/settings.ts b/src/bridges/settings.ts index bf564fd..5ac5ed5 100644 --- a/src/bridges/settings.ts +++ b/src/bridges/settings.ts @@ -1,7 +1,7 @@ -import { SourceGroup, ViewType, ThemeSettings } from "../schema-types" +import { SourceGroup, ViewType, ThemeSettings, SchemaTypes } from "../schema-types" import { ipcRenderer } from "electron" -const SettingsBridge = { +const settingsBridge = { saveGroups: (groups: SourceGroup[]) => { ipcRenderer.invoke("set-groups", groups) }, @@ -59,13 +59,21 @@ const SettingsBridge = { }, getCurrentLocale: (): string => { return ipcRenderer.sendSync("get-locale") - } + }, + + getAll: () => { + return ipcRenderer.sendSync("get-all-settings") as Object + }, + + setAll: (configs) => { + ipcRenderer.invoke("import-all-settings", configs) + }, } declare global { interface Window { - settings: typeof SettingsBridge + settings: typeof settingsBridge } } -export default SettingsBridge \ No newline at end of file +export default settingsBridge \ No newline at end of file diff --git a/src/bridges/utils.ts b/src/bridges/utils.ts new file mode 100644 index 0000000..e8b9c7c --- /dev/null +++ b/src/bridges/utils.ts @@ -0,0 +1,85 @@ +import { ipcRenderer } from "electron" + +const utilsBridge = { + getVersion: (): string => { + return ipcRenderer.sendSync("get-version") + }, + + openExternal: (url: string) => { + ipcRenderer.invoke("open-external", url) + }, + + showErrorBox: (title: string, content: string) => { + ipcRenderer.invoke("show-error-box", title, content) + }, + + showMessageBox: async (title: string, message: string, confirm: string, cancel: string, defaultCancel=false, type="none") => { + return await ipcRenderer.invoke("show-message-box", title, message, confirm, cancel, defaultCancel, type) as boolean + }, + + showSaveDialog: async (filters: Electron.FileFilter[], path: string) => { + let result = await ipcRenderer.invoke("show-save-dialog", filters, path) as boolean + if (result) { + return (result: string, errmsg: string) => { + ipcRenderer.invoke("write-save-result", result, errmsg) + } + } else { + return null + } + }, + + showOpenDialog: async (filters: Electron.FileFilter[]) => { + return await ipcRenderer.invoke("show-open-dialog", filters) as string + }, + + getCacheSize: async (): Promise => { + return await ipcRenderer.invoke("get-cache") + }, + + clearCache: async () => { + await ipcRenderer.invoke("clear-cache") + }, + + addWebviewKeydownListener: (id: number, callback: (event: Electron.Input) => any) => { + ipcRenderer.invoke("add-webview-keydown-listener", id) + ipcRenderer.removeAllListeners("webview-keydown") + ipcRenderer.on("webview-keydown", (_, input) => { + callback(input) + }) + }, + + writeClipboard: (text: string) => { + ipcRenderer.invoke("write-clipboard", text) + }, + + closeWindow: () => { + ipcRenderer.invoke("close-window") + }, + minimizeWindow: () => { + ipcRenderer.invoke("minimize-window") + }, + maximizeWindow: () => { + ipcRenderer.invoke("maximize-window") + }, + isMaximized: () => { + return ipcRenderer.sendSync("is-maximized") as boolean + }, + addWindowStateListener: (callback: (state: boolean) => any) => { + ipcRenderer.removeAllListeners("maximized") + ipcRenderer.on("maximized", () => { + callback(true) + }) + ipcRenderer.removeAllListeners("unmaximized") + ipcRenderer.on("unmaximized", () => { + callback(false) + }) + }, +} + +declare global { + interface Window { + utils: typeof utilsBridge + } +} + +export default utilsBridge \ No newline at end of file diff --git a/src/components/article.tsx b/src/components/article.tsx index 10cb1f0..9046ed6 100644 --- a/src/components/article.tsx +++ b/src/components/article.tsx @@ -2,11 +2,9 @@ import * as React from "react" import intl from "react-intl-universal" import { renderToString } from "react-dom/server" import { RSSItem } from "../scripts/models/item" -import { openExternal } from "../scripts/utils" import { Stack, CommandBarButton, IContextualMenuProps, FocusZone } from "@fluentui/react" import { RSSSource, SourceOpenTarget } from "../scripts/models/source" import { store } from "../scripts/settings" -import { clipboard, remote } from "electron" const FONT_SIZE_STORE_KEY = "fontSize" const FONT_SIZE_OPTIONS = [12, 13, 14, 15, 16, 17, 18, 19, 20] @@ -70,7 +68,7 @@ class Article extends React.Component { key: "copyURL", text: intl.get("context.copyURL"), iconProps: { iconName: "Link" }, - onClick: () => { clipboard.writeText(this.props.item.link) } + onClick: () => { window.utils.writeClipboard(this.props.item.link) } }, { key: "toggleHidden", @@ -84,7 +82,7 @@ class Article extends React.Component { ipcHandler = event => { switch (event.channel) { case "request-navigation": { - openExternal(event.args[0]) + window.utils.openExternal(event.args[0]) break } case "context-menu": { @@ -96,13 +94,13 @@ class Article extends React.Component { } } popUpHandler = event => { - openExternal(event.url) + window.utils.openExternal(event.url) } navigationHandler = event => { - openExternal(event.url) + window.utils.openExternal(event.url) this.props.dismiss() } - keyDownHandler = (_, input) => { + keyDownHandler = (input: Electron.Input) => { if (input.type === "keyDown") { switch (input.key) { case "Escape": @@ -140,8 +138,7 @@ class Article extends React.Component { webview.addEventListener("new-window", this.popUpHandler) webview.addEventListener("will-navigate", this.navigationHandler) webview.addEventListener("dom-ready", () => { - let webContents = remote.webContents.fromId(webview.getWebContentsId()) - webContents.on("before-input-event", this.keyDownHandler) + window.utils.addWebviewKeydownListener(webview.getWebContentsId(), this.keyDownHandler) }) this.webview = webview webview.focus() @@ -163,7 +160,7 @@ class Article extends React.Component { } openInBrowser = () => { - openExternal(this.props.item.link) + window.utils.openExternal(this.props.item.link) } toggleWebpage = () => { diff --git a/src/components/cards/card.tsx b/src/components/cards/card.tsx index 9040126..f8b29d8 100644 --- a/src/components/cards/card.tsx +++ b/src/components/cards/card.tsx @@ -1,5 +1,4 @@ import * as React from "react" -import { openExternal } from "../../scripts/utils" import { RSSSource, SourceOpenTarget } from "../../scripts/models/source" import { RSSItem } from "../../scripts/models/item" @@ -16,7 +15,7 @@ export interface CardProps { export class Card extends React.Component { openInBrowser = () => { this.props.markRead(this.props.item) - openExternal(this.props.item.link) + window.utils.openExternal(this.props.item.link) } onClick = (e: React.MouseEvent) => { diff --git a/src/components/context-menu.tsx b/src/components/context-menu.tsx index 4359a5a..c773922 100644 --- a/src/components/context-menu.tsx +++ b/src/components/context-menu.tsx @@ -1,7 +1,6 @@ import * as React from "react" import intl from "react-intl-universal" -import { clipboard } from "electron" -import { openExternal, cutText, googleSearch } from "../scripts/utils" +import { cutText, googleSearch } from "../scripts/utils" import { ContextualMenu, IContextualMenuItem, ContextualMenuItemType, DirectionalHint } from "office-ui-fabric-react/lib/ContextualMenu" import { ContextMenuType } from "../scripts/models/app" import { RSSItem } from "../scripts/models/item" @@ -51,7 +50,7 @@ export class ContextMenu extends React.Component { iconProps: { iconName: "NavigateExternalInline" }, onClick: () => { this.props.markRead(this.props.item) - openExternal(this.props.item.link) + window.utils.openExternal(this.props.item.link) } }, this.props.item.hasRead @@ -85,12 +84,12 @@ export class ContextMenu extends React.Component { { key: "copyTitle", text: intl.get("context.copyTitle"), - onClick: () => { clipboard.writeText(this.props.item.title) } + onClick: () => { window.utils.writeClipboard(this.props.item.title) } }, { key: "copyURL", text: intl.get("context.copyURL"), - onClick: () => { clipboard.writeText(this.props.item.link) } + onClick: () => { window.utils.writeClipboard(this.props.item.link) } } ] case ContextMenuType.Text: return [ @@ -98,7 +97,7 @@ export class ContextMenu extends React.Component { key: "copyText", text: intl.get("context.copy"), iconProps: { iconName: "Copy" }, - onClick: () => { clipboard.writeText(this.props.text) } + onClick: () => { window.utils.writeClipboard(this.props.text) } }, { key: "searchText", diff --git a/src/components/nav.tsx b/src/components/nav.tsx index b976e97..b40026b 100644 --- a/src/components/nav.tsx +++ b/src/components/nav.tsx @@ -1,6 +1,5 @@ import * as React from "react" import intl from "react-intl-universal" -import { remote } from "electron" import { Icon } from "@fluentui/react/lib/Icon" import { AppState } from "../scripts/models/app" import { ProgressIndicator } from "@fluentui/react" @@ -20,25 +19,21 @@ type NavProps = { type NavState = { maximized: boolean, - window: Electron.BrowserWindow } class Nav extends React.Component { constructor(props) { super(props) - let window = remote.getCurrentWindow() - window.on("maximize", () => { - this.setState({ maximized: true }) - }) - window.on("unmaximize", () => { - this.setState({ maximized: false }) - }) + window.utils.addWindowStateListener(this.setMaximizeState) this.state = { - maximized: remote.getCurrentWindow().isMaximized(), - window: window + maximized: window.utils.isMaximized() } } + setMaximizeState = (state: boolean) => { + this.setState({ maximized: state }) + } + navShortcutsHandler = (e: KeyboardEvent) => { if (!this.props.state.settings.display) { switch (e.key) { @@ -77,18 +72,14 @@ class Nav extends React.Component { } minimize = () => { - this.state.window.minimize() + window.utils.minimizeWindow() } maximize = () => { - if (this.state.maximized) { - this.state.window.unmaximize() - } else { - this.state.window.maximize() - } + window.utils.maximizeWindow() this.setState({ maximized: !this.state.maximized }) } close = () => { - this.state.window.close() + window.utils.closeWindow() } canFetch = () => this.props.state.sourceInit && this.props.state.feedInit && !this.props.state.fetchingItems diff --git a/src/components/settings/about.tsx b/src/components/settings/about.tsx index 952bb8f..9bf2813 100644 --- a/src/components/settings/about.tsx +++ b/src/components/settings/about.tsx @@ -1,8 +1,6 @@ import * as React from "react" import intl from "react-intl-universal" import { Stack, Link } from "@fluentui/react" -import { openExternal } from "../../scripts/utils" -import { remote } from "electron" class AboutTab extends React.Component { render = () => ( @@ -10,12 +8,12 @@ class AboutTab extends React.Component {

Fluent Reader

- {intl.get("settings.version")} {remote.app.getVersion()} + {intl.get("settings.version")} {window.utils.getVersion()}

Copyright © 2020 Haoyuan Liu. All rights reserved.

- openExternal("https://github.com/yang991178/fluent-reader/wiki/Support#keyboard-shortcuts")}>{intl.get("settings.shortcuts")} - openExternal("https://github.com/yang991178/fluent-reader")}>{intl.get("settings.openSource")} - openExternal("https://github.com/yang991178/fluent-reader/issues")}>{intl.get("settings.feedback")} + window.utils.openExternal("https://github.com/yang991178/fluent-reader/wiki/Support#keyboard-shortcuts")}>{intl.get("settings.shortcuts")} + window.utils.openExternal("https://github.com/yang991178/fluent-reader")}>{intl.get("settings.openSource")} + window.utils.openExternal("https://github.com/yang991178/fluent-reader/issues")}>{intl.get("settings.feedback")}
diff --git a/src/components/settings/app.tsx b/src/components/settings/app.tsx index 58b57ab..fc12636 100644 --- a/src/components/settings/app.tsx +++ b/src/components/settings/app.tsx @@ -4,13 +4,12 @@ import { urlTest, byteToMB, calculateItemSize } from "../../scripts/utils" import { ThemeSettings } from "../../schema-types" import { getThemeSettings, setThemeSettings, exportAll } from "../../scripts/settings" import { Stack, Label, Toggle, TextField, DefaultButton, ChoiceGroup, IChoiceGroupOption, loadTheme, Dropdown, IDropdownOption, PrimaryButton } from "@fluentui/react" -import { remote } from "electron" import DangerButton from "../utils/danger-button" type AppTabProps = { setLanguage: (option: string) => void deleteArticles: (days: number) => Promise - importAll: () => void + importAll: () => Promise } type AppTabState = { @@ -38,7 +37,7 @@ class AppTab extends React.Component { } getCacheSize = () => { - remote.session.defaultSession.getCacheSize().then(size => { + window.utils.getCacheSize().then(size => { this.setState({ cacheSize: byteToMB(size) }) }) } @@ -49,7 +48,7 @@ class AppTab extends React.Component { } clearCache = () => { - remote.session.defaultSession.clearCache().then(() => { + window.utils.clearCache().then(() => { this.getCacheSize() }) } @@ -110,18 +109,6 @@ class AppTab extends React.Component { this.setState({ themeSettings: option.key as ThemeSettings }) } - exportAll = () => { - remote.dialog.showSaveDialog( - remote.getCurrentWindow(), - { - defaultPath: "*/Fluent_Reader_Backup.frdata", - filters: [{ name: intl.get("app.frData"), extensions: ["frdata"] }] - } - ).then(result => { - if (!result.canceled) exportAll(result.filePath) - }) - } - render = () => (
@@ -206,7 +193,7 @@ class AppTab extends React.Component { - + diff --git a/src/components/settings/rules.tsx b/src/components/settings/rules.tsx index 6a8e72f..373bcdb 100644 --- a/src/components/settings/rules.tsx +++ b/src/components/settings/rules.tsx @@ -7,7 +7,7 @@ import { SourceRule, RuleActions } from "../../scripts/models/rule" import { FilterType } from "../../scripts/models/feed" import { validateRegex } from "../../scripts/utils" import { RSSItem } from "../../scripts/models/item" -import Parser = require("@yang991178/rss-parser") +import Parser from "@yang991178/rss-parser" const actionKeyMap = { "r-true": "article.markRead", diff --git a/src/containers/nav-container.tsx b/src/containers/nav-container.tsx index c22b2a0..c3aeaed 100644 --- a/src/containers/nav-container.tsx +++ b/src/containers/nav-container.tsx @@ -1,4 +1,3 @@ -import { remote } from "electron" import intl from "react-intl-universal" import { connect } from "react-redux" import { createSelector } from "reselect" @@ -28,14 +27,12 @@ const mapDispatchToProps = (dispatch) => ({ settings: () => dispatch(toggleSettings()), search: () => dispatch(toggleSearch()), markAllRead: () => { - remote.dialog.showMessageBox(remote.getCurrentWindow(), { - title: intl.get("nav.markAllRead"), - message: intl.get("confirmMarkAll"), - buttons: process.platform === "win32" ? ["Yes", "No"] : [intl.get("confirm"), intl.get("cancel")], - defaultId: 0, - cancelId: 1 - }).then(response => { - if (response.response === 0) { + window.utils.showMessageBox( + intl.get("nav.markAllRead"), + intl.get("confirmMarkAll"), + intl.get("confirm"), intl.get("cancel") + ).then(response => { + if (response) { dispatch(markAllRead()) } }) diff --git a/src/containers/settings/app-container.tsx b/src/containers/settings/app-container.tsx index 38e26a8..dee500d 100644 --- a/src/containers/settings/app-container.tsx +++ b/src/containers/settings/app-container.tsx @@ -1,11 +1,9 @@ -import intl from "react-intl-universal" import { connect } from "react-redux" -import { importAll } from "../../scripts/settings" import { initIntl, saveSettings } from "../../scripts/models/app" import * as db from "../../scripts/db" import AppTab from "../../components/settings/app" import { initFeeds } from "../../scripts/models/feed" -import { remote } from "electron" +import { importAll } from "../../scripts/settings" const mapDispatchToProps = dispatch => ({ setLanguage: (option: string) => { @@ -24,28 +22,10 @@ const mapDispatchToProps = dispatch => ({ db.idb.persistence.compactDatafile() }) }), - importAll: () => { - let window = remote.getCurrentWindow() - remote.dialog.showOpenDialog(window, { - filters: [{ name: intl.get("app.frData"), extensions: ["frdata"] }], - properties: ["openFile"] - }).then(result => { - if (!result.canceled) { - remote.dialog.showMessageBox(remote.getCurrentWindow(), { - type: "warning", - title: intl.get("app.restore"), - message: intl.get("app.confirmImport"), - buttons: process.platform === "win32" ? ["Yes", "No"] : [intl.get("confirm"), intl.get("cancel")], - defaultId: 1, - cancelId: 1 - }).then(response => { - if (response.response === 0) { - dispatch(saveSettings()) - importAll(result.filePaths[0]) - } - }) - } - }) + importAll: async () => { + dispatch(saveSettings()) + let cancelled = await importAll() + if (cancelled) dispatch(saveSettings()) } }) diff --git a/src/containers/settings/sources-container.tsx b/src/containers/settings/sources-container.tsx index ce9990c..6a9b871 100644 --- a/src/containers/settings/sources-container.tsx +++ b/src/containers/settings/sources-container.tsx @@ -1,5 +1,3 @@ -import intl from "react-intl-universal" -import { remote } from "electron" import { connect } from "react-redux" import { createSelector } from "reselect" import { RootState } from "../../scripts/reducer" @@ -31,28 +29,8 @@ const mapDispatchToProps = (dispatch: AppDispatch) => { }, deleteSource: (source: RSSSource) => dispatch(deleteSource(source)), deleteSources: (sources: RSSSource[]) => dispatch(deleteSources(sources)), - importOPML: () => { - remote.dialog.showOpenDialog( - remote.getCurrentWindow(), - { - filters: [{ name: intl.get("sources.opmlFile"), extensions: ["xml", "opml"] }], - properties: ["openFile"] - } - ).then(result => { - if (!result.canceled && result.filePaths.length > 0) dispatch(importOPML(result.filePaths[0])) - }) - }, - exportOPML: () => { - remote.dialog.showSaveDialog( - remote.getCurrentWindow(), - { - defaultPath: "*/Fluent_Reader_Export.opml", - filters: [{ name: intl.get("sources.opmlFile"), extensions: ["opml"] }] - } - ).then(result => { - if (!result.canceled) dispatch(exportOPML(result.filePath)) - }) - } + importOPML: () => dispatch(importOPML()), + exportOPML: () => dispatch(exportOPML()) } } diff --git a/src/electron.ts b/src/electron.ts index fece65b..62223d0 100644 --- a/src/electron.ts +++ b/src/electron.ts @@ -1,9 +1,8 @@ -import { app, ipcMain, BrowserWindow, Menu, nativeTheme } from "electron" -import windowStateKeeper = require("electron-window-state") -import { ThemeSettings } from "./schema-types" -import { store, setThemeListener } from "./main/settings" +import { app, ipcMain, Menu, nativeTheme } from "electron" +import { ThemeSettings, SchemaTypes } from "./schema-types" +import { store } from "./main/settings" import performUpdate from "./main/update-scripts" -import path = require("path") +import { WindowManager } from "./main/window" if (!process.mas) { const locked = app.requestSingleInstanceLock() @@ -12,55 +11,15 @@ if (!process.mas) { } } -let mainWindow: BrowserWindow -let restarting: boolean +let restarting = false -function init(setTheme = true) { - restarting = false +function init() { performUpdate(store) - if (setTheme) nativeTheme.themeSource = store.get("theme", ThemeSettings.Default) + nativeTheme.themeSource = store.get("theme", ThemeSettings.Default) } init() -function createWindow() { - let mainWindowState = windowStateKeeper({ - defaultWidth: 1200, - defaultHeight: 700, - }) - // Create the browser window. - mainWindow = new BrowserWindow({ - title: "Fluent Reader", - backgroundColor: process.platform === "darwin" ? "#00000000" : (nativeTheme.shouldUseDarkColors ? "#282828" : "#faf9f8"), - vibrancy: "sidebar", - x: mainWindowState.x, - y: mainWindowState.y, - width: mainWindowState.width, - height: mainWindowState.height, - minWidth: 992, - minHeight: 600, - frame: process.platform === "darwin", - titleBarStyle: "hiddenInset", - fullscreenable: false, - show: false, - webPreferences: { - nodeIntegration: true, - webviewTag: true, - enableRemoteModule: true, - preload: path.join(app.getAppPath(), (app.isPackaged ? "dist/" : "") + "preload.js") - } - }) - mainWindowState.manage(mainWindow) - mainWindow.on("ready-to-show", () => { - mainWindow.show() - mainWindow.focus() - if (!app.isPackaged) mainWindow.webContents.openDevTools() - }) - setThemeListener(mainWindow) - // and load the index.html of the app. - mainWindow.loadFile((app.isPackaged ? "dist/" : "") + "index.html", ) -} - if (process.platform === "darwin") { const template = [ { @@ -86,34 +45,29 @@ if (process.platform === "darwin") { Menu.setApplicationMenu(null) } -app.on("ready", createWindow) +const winManager = new WindowManager() -app.on("second-instance", () => { - if (mainWindow !== null) { - mainWindow.focus() +app.on("window-all-closed", () => { + if (winManager.hasWindow()) { + winManager.mainWindow.webContents.session.clearStorageData({ storages: ["cookies"] }) } -}) - -app.on("window-all-closed", function () { - if (mainWindow) { - mainWindow.webContents.session.clearStorageData({ storages: ["cookies"] }) - } - mainWindow = null + winManager.mainWindow = null if (restarting) { - init(false) - createWindow() + restarting = false + winManager.createWindow() } else if (process.platform !== "darwin") { app.quit() } }) -app.on("activate", function () { - if (mainWindow === null) { - createWindow() - } -}) - -ipcMain.on("restart", () => { +ipcMain.handle("import-all-settings", (_, configs: SchemaTypes) => { restarting = true - mainWindow.close() + store.clear() + for (let [key, value] of Object.entries(configs)) { + // @ts-ignore + store.set(key, value) + } + performUpdate(store) + nativeTheme.themeSource = store.get("theme", ThemeSettings.Default) + winManager.mainWindow.close() }) diff --git a/src/main/settings.ts b/src/main/settings.ts index 627f75f..eef870d 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,6 +1,7 @@ import Store = require("electron-store") import { SchemaTypes, SourceGroup, ViewType, ThemeSettings } from "../schema-types" import { ipcMain, session, nativeTheme, BrowserWindow, app } from "electron" +import { WindowManager } from "./window" export const store = new Store() @@ -76,12 +77,14 @@ ipcMain.handle("set-theme", (_, theme: ThemeSettings) => { ipcMain.on("get-theme-dark-color", (event) => { event.returnValue = nativeTheme.shouldUseDarkColors }) -export function setThemeListener(window: BrowserWindow) { +export function setThemeListener(manager: WindowManager) { nativeTheme.removeAllListeners() nativeTheme.on("updated", () => { - let contents = window.webContents - if (!contents.isDestroyed()) { - contents.send("theme-updated", nativeTheme.shouldUseDarkColors) + if (manager.hasWindow()) { + let contents = manager.mainWindow.webContents + if (!contents.isDestroyed()) { + contents.send("theme-updated", nativeTheme.shouldUseDarkColors) + } } }) } @@ -101,3 +104,11 @@ ipcMain.on("get-locale", (event) => { let locale = setting === "default" ? app.getLocale() : setting event.returnValue = locale }) + +ipcMain.on("get-all-settings", (event) => { + let output = {} + for (let [key, value] of store) { + output[key] = value + } + event.returnValue = output +}) diff --git a/src/main/utils.ts b/src/main/utils.ts new file mode 100644 index 0000000..6c3364f --- /dev/null +++ b/src/main/utils.ts @@ -0,0 +1,116 @@ +import { ipcMain, shell, dialog, app, session, webContents, clipboard } from "electron" +import { WindowManager } from "./window" +import fs = require("fs") + +export function setUtilsListeners(manager: WindowManager) { + ipcMain.on("get-version", (event) => { + event.returnValue = app.getVersion() + }) + + ipcMain.handle("open-external", (_, url: string) => { + if (url.startsWith("https://") || url.startsWith("http://")) + shell.openExternal(url) + }) + + ipcMain.handle("show-error-box", (_, title, content) => { + dialog.showErrorBox(title, content) + }) + + ipcMain.handle("show-message-box", async (_, title, message, confirm, cancel, defaultCancel, type) => { + if (manager.hasWindow()) { + let response = await dialog.showMessageBox(manager.mainWindow, { + type: type, + title: title, + message: message, + buttons: process.platform === "win32" ? ["Yes", "No"] : [confirm, cancel], + cancelId: 1, + defaultId: defaultCancel ? 1 : 0 + }) + return response.response === 0 + } else { + return false + } + }) + + ipcMain.handle("show-save-dialog", async (_, filters: Electron.FileFilter[], path: string) => { + ipcMain.removeAllListeners("write-save-result") + if (manager.hasWindow()) { + let response = await dialog.showSaveDialog(manager.mainWindow, { + defaultPath: path, + filters: filters + }) + if (!response.canceled) { + ipcMain.handleOnce("write-save-result", (_, result, errmsg) => { + fs.writeFile(response.filePath, result, (err) => { + if (err) dialog.showErrorBox(errmsg, String(err)) + }) + }) + return true + } + } + return false + }) + + ipcMain.handle("show-open-dialog", async (_, filters: Electron.FileFilter[]) => { + if (manager.hasWindow()) { + let response = await dialog.showOpenDialog(manager.mainWindow, { + filters: filters, + properties: ["openFile"] + }) + if (!response.canceled) { + try { + return await fs.promises.readFile(response.filePaths[0], "utf-8") + } catch (err) { + console.log(err) + } + } + } + return null + }) + + ipcMain.handle("get-cache", async () => { + return await session.defaultSession.getCacheSize() + }) + + ipcMain.handle("clear-cache", async () => { + await session.defaultSession.clearCache() + }) + + ipcMain.handle("add-webview-keydown-listener", (_, id) => { + let contents = webContents.fromId(id) + contents.on("before-input-event", (_, input) => { + if (manager.hasWindow()) { + let contents = manager.mainWindow.webContents + if (!contents.isDestroyed()) { + contents.send("webview-keydown", input) + } + } + }) + }) + + ipcMain.handle("write-clipboard", (_, text) => { + clipboard.writeText(text) + }) + + ipcMain.handle("close-window", () => { + if (manager.hasWindow()) manager.mainWindow.close() + }) + + ipcMain.handle("minimize-window", () => { + if (manager.hasWindow()) manager.mainWindow.minimize() + }) + + ipcMain.handle("maximize-window", () => { + if (manager.hasWindow) { + if (manager.mainWindow.isMaximized()) { + manager.mainWindow.unmaximize() + } else { + manager.mainWindow.maximize() + } + } + }) + + ipcMain.on("is-maximized", (event) => { + event.returnValue = Boolean(manager.mainWindow) && manager.mainWindow.isMaximized() + }) +} \ No newline at end of file diff --git a/src/main/window.ts b/src/main/window.ts new file mode 100644 index 0000000..f9cd027 --- /dev/null +++ b/src/main/window.ts @@ -0,0 +1,86 @@ +import windowStateKeeper = require("electron-window-state") +import { BrowserWindow, nativeTheme, app } from "electron" +import path = require("path") +import { setThemeListener } from "./settings" +import { setUtilsListeners } from "./utils" + +export class WindowManager { + mainWindow: BrowserWindow = null + private mainWindowState: windowStateKeeper.State + + constructor() { + this.init() + } + + private init = () => { + app.on("ready", () => { + this.mainWindowState = windowStateKeeper({ + defaultWidth: 1200, + defaultHeight: 700, + }) + this.setListeners() + this.createWindow() + }) + } + + private setListeners = () => { + setThemeListener(this) + setUtilsListeners(this) + + app.on("second-instance", () => { + if (this.mainWindow !== null) { + this.mainWindow.focus() + } + }) + + app.on("activate", () => { + if (this.mainWindow === null) { + this.createWindow() + } + }) + } + + createWindow = () => { + if (!this.hasWindow()) { + this.mainWindow = new BrowserWindow({ + title: "Fluent Reader", + backgroundColor: process.platform === "darwin" ? "#00000000" : (nativeTheme.shouldUseDarkColors ? "#282828" : "#faf9f8"), + vibrancy: "sidebar", + x: this.mainWindowState.x, + y: this.mainWindowState.y, + width: this.mainWindowState.width, + height: this.mainWindowState.height, + minWidth: 992, + minHeight: 600, + frame: process.platform === "darwin", + titleBarStyle: "hiddenInset", + fullscreenable: false, + show: false, + webPreferences: { + nodeIntegration: true, + webviewTag: true, + enableRemoteModule: true, + preload: path.join(app.getAppPath(), (app.isPackaged ? "dist/" : "") + "preload.js") + } + }) + this.mainWindowState.manage(this.mainWindow) + this.mainWindow.on("ready-to-show", () => { + this.mainWindow.show() + this.mainWindow.focus() + if (!app.isPackaged) this.mainWindow.webContents.openDevTools() + }) + this.mainWindow.loadFile((app.isPackaged ? "dist/" : "") + "index.html", ) + + this.mainWindow.on("maximize", () => { + this.mainWindow.webContents.send("maximized") + }) + this.mainWindow.on("unmaximize", () => { + this.mainWindow.webContents.send("unmaximized") + }) + } + } + + hasWindow = () => { + return this.mainWindow !== null && !this.mainWindow.isDestroyed() + } +} \ No newline at end of file diff --git a/src/preload.ts b/src/preload.ts index 59e4198..afbad90 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,4 +1,6 @@ import { contextBridge } from "electron" -import SettingsBridge from "./bridges/settings" +import settingsBridge from "./bridges/settings" +import utilsBridge from "./bridges/utils" -window.settings = SettingsBridge \ No newline at end of file +window.settings = settingsBridge +window.utils = utilsBridge \ No newline at end of file diff --git a/src/scripts/db.ts b/src/scripts/db.ts index caca713..2e5fbee 100644 --- a/src/scripts/db.ts +++ b/src/scripts/db.ts @@ -1,4 +1,4 @@ -import Datastore = require("nedb") +import Datastore from "nedb" import { RSSSource } from "./models/source" import { RSSItem } from "./models/item" diff --git a/src/scripts/models/group.ts b/src/scripts/models/group.ts index 987f80b..8526327 100644 --- a/src/scripts/models/group.ts +++ b/src/scripts/models/group.ts @@ -1,11 +1,9 @@ -import fs = require("fs") import intl from "react-intl-universal" import { SourceActionTypes, ADD_SOURCE, DELETE_SOURCE, addSource, RSSSource } from "./source" import { SourceGroup } from "../../schema-types" -import { ActionStatus, AppThunk, domParser, AppDispatch } from "../utils" +import { ActionStatus, AppThunk, domParser } from "../utils" import { saveSettings } from "./app" import { fetchItemsIntermediate, fetchItemsRequest, fetchItemsSuccess } from "./item" -import { remote } from "electron" export const CREATE_SOURCE_GROUP = "CREATE_SOURCE_GROUP" export const ADD_SOURCE_TO_GROUP = "ADD_SOURCE_TO_GROUP" @@ -167,12 +165,11 @@ function outlineToSource(outline: Element): [ReturnType, strin } } -export function importOPML(path: string): AppThunk { +export function importOPML(): AppThunk { return async (dispatch) => { - fs.readFile(path, "utf-8", async (err, data) => { - if (err) { - console.log(err) - } else { + const filters = [{ name: intl.get("sources.opmlFile"), extensions: ["xml", "opml"] }] + window.utils.showOpenDialog(filters).then(data => { + if (data) { dispatch(saveSettings()) let doc = domParser.parseFromString(data, "text/xml").getElementsByTagName("body") if (doc.length == 0) { @@ -182,7 +179,7 @@ export function importOPML(path: string): AppThunk { let parseError = doc[0].getElementsByTagName("parsererror") if (parseError.length > 0) { dispatch(saveSettings()) - remote.dialog.showErrorBox(intl.get("sources.errorParse"), intl.get("sources.errorParseHint")) + window.utils.showErrorBox(intl.get("sources.errorParse"), intl.get("sources.errorParseHint")) return } let sources: [ReturnType, number, string][] = [] @@ -214,7 +211,7 @@ export function importOPML(path: string): AppThunk { dispatch(fetchItemsSuccess([], {})) dispatch(saveSettings()) if (errors.length > 0) { - remote.dialog.showErrorBox( + window.utils.showErrorBox( intl.get("sources.errorImport", { count: errors.length }), errors.map(e => { return e[0] + "\n" + String(e[1]) @@ -236,33 +233,35 @@ function sourceToOutline(source: RSSSource, xml: Document) { return outline } -export function exportOPML(path: string): AppThunk { +export function exportOPML(): AppThunk { return (_, getState) => { - let state = getState() - let xml = domParser.parseFromString( - "Fluent Reader Export", - "text/xml" - ) - let body = xml.getElementsByTagName("body")[0] - for (let group of state.groups) { - if (group.isMultiple) { - let outline = xml.createElement("outline") - outline.setAttribute("text", group.name) - outline.setAttribute("name", group.name) - for (let sid of group.sids) { - outline.appendChild(sourceToOutline(state.sources[sid], xml)) + const filters = [{ name: intl.get("sources.opmlFile"), extensions: ["opml"] }] + window.utils.showSaveDialog(filters, "*/Fluent_Reader_Export.opml").then(write => { + if (write) { + let state = getState() + let xml = domParser.parseFromString( + "Fluent Reader Export", + "text/xml" + ) + let body = xml.getElementsByTagName("body")[0] + for (let group of state.groups) { + if (group.isMultiple) { + let outline = xml.createElement("outline") + outline.setAttribute("text", group.name) + outline.setAttribute("name", group.name) + for (let sid of group.sids) { + outline.appendChild(sourceToOutline(state.sources[sid], xml)) + } + body.appendChild(outline) + } else { + body.appendChild(sourceToOutline(state.sources[group.sids[0]], xml)) + } } - body.appendChild(outline) - } else { - body.appendChild(sourceToOutline(state.sources[group.sids[0]], xml)) + let serializer = new XMLSerializer() + write(serializer.serializeToString(xml), intl.get("settings.writeError")) } - } - let serializer = new XMLSerializer() - fs.writeFile(path, serializer.serializeToString(xml), (err) => { - if (err) remote.dialog.showErrorBox(intl.get("settings.writeError"), String(err)) }) } - } export type GroupState = SourceGroup[] diff --git a/src/scripts/models/item.ts b/src/scripts/models/item.ts index 70e43a9..89c17fb 100644 --- a/src/scripts/models/item.ts +++ b/src/scripts/models/item.ts @@ -1,9 +1,9 @@ import * as db from "../db" import intl from "react-intl-universal" -import { domParser, htmlDecode, ActionStatus, AppThunk, openExternal } from "../utils" +import { domParser, htmlDecode, ActionStatus, AppThunk } from "../utils" import { RSSSource } from "./source" import { FeedActionTypes, INIT_FEED, LOAD_MORE } from "./feed" -import Parser = require("@yang991178/rss-parser") +import Parser from "@yang991178/rss-parser" export class RSSItem { _id: string @@ -279,7 +279,7 @@ export function itemShortcuts(item: RSSItem, key: string): AppThunk { break case "b": case "B": if (!item.hasRead) dispatch(markRead(item)) - openExternal(item.link) + window.utils.openExternal(item.link) break case "s": case "S": dispatch(toggleStarred(item)) diff --git a/src/scripts/models/source.ts b/src/scripts/models/source.ts index 42ac3e2..e4ad53b 100644 --- a/src/scripts/models/source.ts +++ b/src/scripts/models/source.ts @@ -1,10 +1,9 @@ -import Parser = require("@yang991178/rss-parser") +import Parser from "@yang991178/rss-parser" import intl from "react-intl-universal" import * as db from "../db" import { fetchFavicon, ActionStatus, AppThunk, parseRSS } from "../utils" import { RSSItem, insertItems, ItemActionTypes, FETCH_ITEMS, MARK_READ, MARK_UNREAD, MARK_ALL_READ } from "./item" import { saveSettings } from "./app" -import { remote } from "electron" import { SourceRule } from "./rule" export enum SourceOpenTarget { @@ -256,7 +255,7 @@ export function addSource(url: string, name: string = null, batch = false): AppT .catch(e => { dispatch(addSourceFailure(e, batch)) if (!batch) { - remote.dialog.showErrorBox(intl.get("sources.errorAdd"), String(e)) + window.utils.showErrorBox(intl.get("sources.errorAdd"), String(e)) } return Promise.reject(e) }) diff --git a/src/scripts/settings.ts b/src/scripts/settings.ts index 4083603..1bff9ce 100644 --- a/src/scripts/settings.ts +++ b/src/scripts/settings.ts @@ -1,9 +1,7 @@ -import { remote, ipcRenderer } from "electron" import { IPartialTheme, loadTheme } from "@fluentui/react" import locales from "./i18n/_locales" import Store = require("electron-store") import { ThemeSettings, SchemaTypes } from "../schema-types" -import fs = require("fs") import intl from "react-intl-universal" export const store = new Store() @@ -61,65 +59,58 @@ export function getCurrentLocale() { return (locale in locales) ? locale : "en-US" } -export function exportAll(path: string) { - let output = {} - for (let [key, value] of store) { - output[key] = value - } - output["nedb"] = {} - let openRequest = window.indexedDB.open("NeDB") - openRequest.onsuccess = () => { - let db = openRequest.result - let objectStore = db.transaction("nedbdata").objectStore("nedbdata") - let cursorRequest = objectStore.openCursor() - cursorRequest.onsuccess = () => { - let cursor = cursorRequest.result - if (cursor) { - output["nedb"][cursor.key] = cursor.value - cursor.continue() - } else { - fs.writeFile(path, JSON.stringify(output), (err) => { - if (err) remote.dialog.showErrorBox(intl.get("settings.writeError"), String(err)) - }) - } - } - } -} - -export function importAll(path) { - fs.readFile(path, "utf-8", async (err, data) => { - if (err) { - console.log(err) - } else { - let configs = JSON.parse(data) +export function exportAll() { + const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }] + window.utils.showSaveDialog(filters, "*/Fluent_Reader_Backup.frdata").then(write => { + if (write) { + let output = window.settings.getAll() + output["nedb"] = {} let openRequest = window.indexedDB.open("NeDB") openRequest.onsuccess = () => { let db = openRequest.result - let objectStore = db.transaction("nedbdata", "readwrite").objectStore("nedbdata") - let requests = Object.entries(configs.nedb).map(([key, value]) => { - return objectStore.put(value, key) - }) - let promises = requests.map(req => new Promise((resolve, reject) => { - req.onsuccess = () => resolve() - req.onerror = () => reject() - })) - Promise.all(promises).then(() => { - delete configs.nedb - store.clear() - let hasTheme = false - for (let [key, value] of Object.entries(configs)) { - if (key === THEME_STORE_KEY) { - setThemeSettings(value as ThemeSettings) - hasTheme = true - } else { - // @ts-ignore - store.set(key, value) - } + let objectStore = db.transaction("nedbdata").objectStore("nedbdata") + let cursorRequest = objectStore.openCursor() + cursorRequest.onsuccess = () => { + let cursor = cursorRequest.result + if (cursor) { + output["nedb"][cursor.key] = cursor.value + cursor.continue() + } else { + write(JSON.stringify(output), intl.get("settings.writeError")) } - if (!hasTheme) setThemeSettings(ThemeSettings.Default) - ipcRenderer.send("restart") - }) + } } } }) } + +export async function importAll() { + const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }] + let data = await window.utils.showOpenDialog(filters) + if (!data) return true + let confirmed = await window.utils.showMessageBox( + intl.get("app.restore"), + intl.get("app.confirmImport"), + intl.get("confirm"), intl.get("cancel"), + true, "warning" + ) + if (!confirmed) return true + let configs = JSON.parse(data) + let openRequest = window.indexedDB.open("NeDB") + openRequest.onsuccess = () => { + let db = openRequest.result + let objectStore = db.transaction("nedbdata", "readwrite").objectStore("nedbdata") + let requests = Object.entries(configs.nedb).map(([key, value]) => { + return objectStore.put(value, key) + }) + let promises = requests.map(req => new Promise((resolve, reject) => { + req.onsuccess = () => resolve() + req.onerror = () => reject() + })) + Promise.all(promises).then(() => { + delete configs.nedb + window.settings.setAll(configs) + }) + } + return false +} diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index 01078e7..c8032f8 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -1,7 +1,8 @@ -import { shell, remote } from "electron" import { ThunkAction, ThunkDispatch } from "redux-thunk" import { AnyAction } from "redux" import { RootState } from "./reducer" +import Parser from "@yang991178/rss-parser" +import Url from "url" export enum ActionStatus { Request, Success, Failure, Intermediate @@ -16,7 +17,6 @@ export type AppThunk = ThunkAction< export type AppDispatch = ThunkDispatch -import Parser = require("@yang991178/rss-parser") const rssParser = new Parser({ customFields: { item: [ @@ -41,7 +41,6 @@ export async function parseRSS(url: string) { export const domParser = new DOMParser() -import Url = require("url") export async function fetchFavicon(url: string) { try { let result = await fetch(url, { credentials: "omit" }) @@ -77,21 +76,16 @@ export function htmlDecode(input: string) { return doc.documentElement.textContent } -export function openExternal(url: string) { - if (url.startsWith("https://") || url.startsWith("http://")) - shell.openExternal(url) -} - export const urlTest = (s: string) => /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi.test(s) -export const getWindowBreakpoint = () => remote.getCurrentWindow().getSize()[0] >= 1441 +export const getWindowBreakpoint = () => window.outerWidth >= 1441 export const cutText = (s: string, length: number) => { return (s.length <= length) ? s : s.slice(0, length) + "…" } -export const googleSearch = (text: string) => openExternal("https://www.google.com/search?q=" + encodeURIComponent(text)) +export const googleSearch = (text: string) => window.utils.openExternal("https://www.google.com/search?q=" + encodeURIComponent(text)) export function mergeSortedArrays(a: T[], b: T[], cmp: ((x: T, y: T) => number)): T[] { let merged = new Array() @@ -114,6 +108,17 @@ export function byteToMB(B: number) { return MB + "MB" } +function byteLength(str: string) { + var s = str.length; + for (var i = str.length - 1; i >= 0; i--) { + var code = str.charCodeAt(i); + if (code > 0x7f && code <= 0x7ff) s++; + else if (code > 0x7ff && code <= 0xffff) s += 2; + if (code >= 0xDC00 && code <= 0xDFFF) i--; //trail surrogate + } + return s; +} + export function calculateItemSize(): Promise { return new Promise((resolve, reject) => { let openRequest = window.indexedDB.open("NeDB") @@ -122,8 +127,7 @@ export function calculateItemSize(): Promise { let objectStore = db.transaction("nedbdata").objectStore("nedbdata") let getRequest = objectStore.get("items") getRequest.onsuccess = () => { - let resultBuffer = Buffer.from(getRequest.result) - resolve(resultBuffer.length) + resolve(byteLength(getRequest.result)) } getRequest.onerror = () => reject() }