diff --git a/main/account_switcher.ts b/main/account_switcher.ts new file mode 100644 index 0000000..f3f62e6 --- /dev/null +++ b/main/account_switcher.ts @@ -0,0 +1,61 @@ +import {EventEmitter} from 'events'; +import {Menu, MenuItem} from 'electron'; +import log from './log'; +import {Account} from './config'; + +export function partitionForAccount(account: Account) { + return `persist:mstdn:${account.name}:${account.host}`; +} + +export default class AccountSwitcher extends EventEmitter { + accounts: Account[]; + current: Account; + + constructor(accounts: Account[]) { + super(); + const submenu = [] as Electron.MenuItemOptions[]; + + for (const account of accounts.filter(a => a.name !== '')) { + let name = account.name; + if (!name.startsWith('@')) { + name = '@' + name; + } + name += '@' + account.host; + submenu.push({ + label: name, + type: 'radio', + checked: false, + click: () => this.switchTo(account), + }); + } + + this.accounts = accounts; + if (accounts.length === 0) { + return; + } + + submenu[0].checked = true; + this.current = accounts[0]; + + const item = new MenuItem({ + label: 'Accounts', + type: 'submenu', + submenu, + }); + + const menu = Menu.getApplicationMenu(); + // Insert item before 'Help' + menu.insert(menu.items.length - 1, item); + Menu.setApplicationMenu(menu); + } + + switchTo(account: Account) { + if (this.current.name === account.name && this.current.host === account.host) { + log.debug('Current account is already @' + account.name); + return; + } + log.debug('Switch to account', account); + this.emit('switch', account, this.current); + this.current = account; + } +} diff --git a/main/app.ts b/main/app.ts index a7683ed..c413cb5 100644 --- a/main/app.ts +++ b/main/app.ts @@ -1,205 +1,52 @@ import * as path from 'path'; -import {app, BrowserWindow, globalShortcut, Tray, shell, dialog, Menu} from 'electron'; -import windowState = require('electron-window-state'); -import * as menubar from 'menubar'; +import {app, Menu, globalShortcut} from 'electron'; import log from './log'; import {Config, Account} from './config'; +import AccountSwitcher from './account_switcher'; +import defaultMenu from './default_menu'; +import Window from './window'; -const IS_DEBUG = process.env.NODE_ENV === 'development'; const IS_DARWIN = process.platform === 'darwin'; const APP_ICON = path.join(__dirname, '..', 'resources', 'icon', 'icon.png'); -const PRELOAD_JS = path.join(__dirname, '..', 'renderer', 'preload.js'); export class App { - private account: Account; + private switcher: AccountSwitcher; - constructor(private win: Electron.BrowserWindow, private config: Config) { + constructor(private win: Window, private config: Config) { if (config.accounts.length === 0) { throw new Error('No account found. Please check the config.'); } - this.account = this.config.accounts[0]; - } - - open() { - if (!IS_DARWIN) { - // Users can still access menu bar with pressing Alt key. - this.win.setMenu(Menu.getApplicationMenu()); + if (IS_DARWIN) { + app.dock.setIcon(APP_ICON); } + Menu.setApplicationMenu(defaultMenu()); + this.switcher = new AccountSwitcher(this.config.accounts); + this.switcher.on('switch', this.onAccountSwitch); + } - this.win.webContents.on('dom-ready', () => { - this.win.show(); - }); - this.win.loadURL(`https://${this.account.host}${this.account.default_page}`); - this.win.webContents.on('will-navigate', (e, url) => { - if (!url.startsWith(`https://${this.account.host}`)) { - e.preventDefault(); - shell.openExternal(url); - } - }); - this.win.webContents.on('new-window', (e, url) => { - e.preventDefault(); - shell.openExternal(url); - }); - this.win.webContents.session.setPermissionRequestHandler((contents, permission, callback) => { - if (permission !== 'geolocation' && permission !== 'media') { - // Granted - callback(true); - return; - } + start() { + const a = this.switcher.current; + const url = `https://${a.host}${a.default_page}`; + this.win.open(url); + log.debug('Application started', a, url); + } - dialog.showMessageBox({ - type: 'question', - buttons: ['Accept', 'Reject'], - message: `Permission '${permission}' is requested by ${contents.getURL()}`, - detail: "Please choose one of 'Accept' or 'Reject'", - }, (buttonIndex: number) => { - const granted = buttonIndex === 0; - callback(granted); - }); + private onAccountSwitch = (next: Account) => { + this.win.close(); + if (this.config.hot_key) { + log.debug('Disable global shortcut for switching account'); + globalShortcut.unregister(this.config.hot_key); + } + Window.create(next, this.config, this.win.menubar) .then(win => { + log.debug('Window was recreated again', next); + this.win = win; + this.start(); }); } } -function trayIcon(color: string) { - return path.join(__dirname, '..', 'resources', 'icon', `tray-icon-${ - color === 'white' ? 'white' : 'black' - }@2x.png`); -} - export default function startApp(config: Config) { - return (config.normal_window ? startNormalWindow : startMenuBar)(config) + const default_account = config.accounts[0]; + return Window.create(default_account, config) .then(win => new App(win, config)); } - -function startNormalWindow(config: Config): Promise { - log.debug('Setup a normal window'); - return new Promise(resolve => { - const state = windowState({ - defaultWidth: 600, - defaultHeight: 800, - }); - const win = new BrowserWindow({ - width: state.width, - height: state.height, - x: state.x, - y: state.y, - icon: APP_ICON, - show: false, - useContentSize: true, - autoHideMenuBar: true, - webPreferences: { - nodeIntegration: false, - sandbox: true, - preload: PRELOAD_JS, - }, - }); - win.once('ready-to-show', () => { - win.show(); - }); - win.once('closed', () => { - app.quit(); - }); - - if (state.isFullScreen) { - win.setFullScreen(true); - } else if (state.isMaximized) { - win.maximize(); - } - state.manage(win); - - const toggleWindow = () => { - if (win.isFocused()) { - log.debug('Toggle window: shown -> hidden'); - if (IS_DARWIN) { - app.hide(); - } else { - win.hide(); - } - } else { - log.debug('Toggle window: hidden -> shown'); - win.show(); - } - }; - - win.webContents.on('dom-ready', () => { - log.debug('Send config to renderer procress'); - win.webContents.send('mstdn:config', config); - }); - win.webContents.once('dom-ready', () => { - log.debug('Normal window application was launched'); - if (config.hot_key) { - globalShortcut.register(config.hot_key, toggleWindow); - log.debug('Hot key was set to:', config.hot_key); - } - if (IS_DEBUG) { - win.webContents.openDevTools({mode: 'detach'}); - } - }); - - const normalIcon = trayIcon(config.icon_color); - const tray = new Tray(normalIcon); - tray.on('click', toggleWindow); - tray.on('double-click', toggleWindow); - if (IS_DARWIN) { - tray.setHighlightMode('never'); - app.dock.setIcon(APP_ICON); - } - - resolve(win); - }); -} - -function startMenuBar(config: Config): Promise { - log.debug('Setup a menubar window'); - return new Promise(resolve => { - const state = windowState({ - defaultWidth: 350, - defaultHeight: 420, - }); - const icon = trayIcon(config.icon_color); - const mb = menubar({ - icon, - width: state.width, - height: state.height, - alwaysOnTop: IS_DEBUG || !!config.always_on_top, - tooltip: 'Mstdn', - autoHideMenuBar: true, - useContentSize: true, - show: false, - webPreferences: { - nodeIntegration: false, - sandbox: true, - preload: PRELOAD_JS, - }, - }); - mb.once('ready', () => mb.showWindow()); - mb.once('after-create-window', () => { - log.debug('Menubar application was launched'); - if (config.hot_key) { - globalShortcut.register(config.hot_key, () => { - if (mb.window.isFocused()) { - log.debug('Toggle window: shown -> hidden'); - mb.hideWindow(); - } else { - log.debug('Toggle window: hidden -> shown'); - mb.showWindow(); - } - }); - log.debug('Hot key was set to:', config.hot_key); - } - if (IS_DEBUG) { - mb.window.webContents.openDevTools({mode: 'detach'}); - } - mb.window.webContents.on('dom-ready', () => { - log.debug('Send config to renderer procress'); - mb.window.webContents.send('mstdn:config', config); - }); - state.manage(mb.window); - - resolve(mb.window); - }); - mb.once('after-close', () => { - app.quit(); - }); - }); -} diff --git a/main/default_menu.ts b/main/default_menu.ts new file mode 100644 index 0000000..6fbcbcd --- /dev/null +++ b/main/default_menu.ts @@ -0,0 +1,189 @@ +import * as path from 'path'; +import {Menu, shell, app} from 'electron'; + +export default function defaultMenu() { + const template: Electron.MenuItemOptions[] = [ + { + label: 'Edit', + submenu: [ + { + label: 'Edit Config', + click() { + shell.openItem(path.join(app.getPath('userData'), 'config.json')); + } + }, + { + type: 'separator' + }, + { + role: 'undo' + }, + { + role: 'redo' + }, + { + type: 'separator' + }, + { + role: 'cut' + }, + { + role: 'copy' + }, + { + role: 'paste' + }, + { + role: 'pasteandmatchstyle' + }, + { + role: 'delete' + }, + { + role: 'selectall' + } + ] + }, + { + label: 'View', + submenu: [ + { + role: 'reload' + }, + { + role: 'toggledevtools' + }, + { + type: 'separator' + }, + { + role: 'resetzoom' + }, + { + role: 'zoomin' + }, + { + role: 'zoomout' + }, + { + type: 'separator' + }, + { + role: 'togglefullscreen' + } + ] + }, + { + role: 'window', + submenu: [ + { + role: 'minimize' + }, + { + role: 'close' + } + ] + }, + { + role: 'help', + submenu: [ + { + label: 'Learn More', + click() { + shell.openExternal('https://github.com/rhysd/Mstdn#readme'); + } + }, + { + label: 'Search Issues', + click() { + shell.openExternal('https://github.com/rhysd/Mstdn/issues'); + } + } + ] + } + ]; + + if (process.platform === 'darwin') { + template.unshift({ + label: 'Mstdn', + submenu: [ + { + role: 'about' + }, + { + type: 'separator' + }, + { + role: 'services', + submenu: [] + }, + { + type: 'separator' + }, + { + role: 'hide' + }, + { + role: 'hideothers' + }, + { + role: 'unhide' + }, + { + type: 'separator' + }, + { + role: 'quit' + } + ] + }); + + (template[1].submenu as Electron.MenuItemOptions[]).push( + { + type: 'separator' + }, + { + label: 'Speech', + submenu: [ + { + role: 'startspeaking' + }, + { + role: 'stopspeaking' + } + ] + } + ); + + template[3].submenu = [ + { + role: 'close' + }, + { + role: 'minimize' + }, + { + role: 'zoom' + }, + { + type: 'separator' + }, + { + role: 'front' + } + ]; + } else { + template.unshift( + { + label: 'File', + submenu: [ + { + role: 'quit' + } + ] + } + ); + } + + return Menu.buildFromTemplate(template); +} diff --git a/main/index.ts b/main/index.ts index a4d5a70..f1da477 100644 --- a/main/index.ts +++ b/main/index.ts @@ -3,6 +3,10 @@ import log from './log'; import startApp from './app'; import loadConfig from './config'; +// default_app sets app.on('all-window-closed', () => app.quit()) before +// loading this application. We need to disable the callback. +app.removeAllListeners(); + const appReady = new Promise(resolve => app.once('ready', resolve)); process.on('unhandledRejection', (reason: string) => { @@ -16,7 +20,7 @@ app.on('will-quit', () => { Promise.all([ loadConfig(), appReady -]).then(([config, _]) => startApp(config)).then(win => { - win.open(); +]).then(([config, _]) => startApp(config)).then(mstdn => { + mstdn.start(); log.debug('Application launched!'); }); diff --git a/main/window.ts b/main/window.ts new file mode 100644 index 0000000..0e9178b --- /dev/null +++ b/main/window.ts @@ -0,0 +1,236 @@ +import * as path from 'path'; +import {app, BrowserWindow, globalShortcut, Tray, shell, dialog, Menu} from 'electron'; +import windowState = require('electron-window-state'); +import * as menubar from 'menubar'; +import {Config, Account} from './config'; +import {partitionForAccount} from './account_switcher'; +import log from './log'; + +const IS_DEBUG = process.env.NODE_ENV === 'development'; +const IS_DARWIN = process.platform === 'darwin'; +const APP_ICON = path.join(__dirname, '..', 'resources', 'icon', 'icon.png'); +const PRELOAD_JS = path.join(__dirname, '..', 'renderer', 'preload.js'); + +export default class Window { + static create(account: Account, config: Config, mb: Menubar.MenubarApp | null = null) { + if (config.normal_window) { + return startNormalWindow(account, config); + } else { + return startMenuBar(account, config, mb); + } + } + + constructor( + public browser: Electron.BrowserWindow, + public state: any /*XXX: ElectronWindowState.WindowState */, + public account: Account, + public menubar: Menubar.MenubarApp | null, + ) { + if (!IS_DARWIN) { + // Users can still access menu bar with pressing Alt key. + browser.setMenu(Menu.getApplicationMenu()); + } + + browser.webContents.on('will-navigate', (e, url) => { + if (!url.startsWith(`https://${this.account.host}`)) { + e.preventDefault(); + shell.openExternal(url); + } + log.debug('Opened URL with external browser (will-navigate)', url); + }); + browser.webContents.on('new-window', (e, url) => { + e.preventDefault(); + shell.openExternal(url); + log.debug('Opened URL with external browser (new-window)', url); + }); + + browser.webContents.session.setPermissionRequestHandler((contents, permission, callback) => { + if (permission !== 'geolocation' && permission !== 'media') { + // Granted + log.debug('Permission was granted', permission); + callback(true); + return; + } + + log.debug('Create dialog for user permission', permission); + dialog.showMessageBox({ + type: 'question', + buttons: ['Accept', 'Reject'], + message: `Permission '${permission}' is requested by ${contents.getURL()}`, + detail: "Please choose one of 'Accept' or 'Reject'", + }, (buttonIndex: number) => { + const granted = buttonIndex === 0; + callback(granted); + }); + }); + } + + open(url: string) { + log.debug('Open URL:', url); + this.browser.loadURL(url); + } + + close() { + log.debug('Closing window:', this.account); + this.state.unmanage(); + this.browser.webContents.removeAllListeners(); + this.browser.removeAllListeners(); + if (this.menubar) { + // Note: + // menubar.windowClear() won't be called because all listners was removed + delete this.menubar.window; + } + this.browser.close(); + } +} + +function trayIcon(color: string) { + return path.join(__dirname, '..', 'resources', 'icon', `tray-icon-${ + color === 'white' ? 'white' : 'black' + }@2x.png`); +} + +function startNormalWindow(account: Account, config: Config): Promise { + log.debug('Setup a normal window'); + return new Promise(resolve => { + const state = windowState({ + defaultWidth: 600, + defaultHeight: 800, + }); + const win = new BrowserWindow({ + width: state.width, + height: state.height, + x: state.x, + y: state.y, + icon: APP_ICON, + show: false, + useContentSize: true, + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: false, + sandbox: true, + preload: PRELOAD_JS, + partition: partitionForAccount(account), + }, + }); + win.once('ready-to-show', () => { + win.show(); + }); + win.once('closed', () => { + app.quit(); + }); + + if (state.isFullScreen) { + win.setFullScreen(true); + } else if (state.isMaximized) { + win.maximize(); + } + state.manage(win); + + const toggleWindow = () => { + if (win.isFocused()) { + log.debug('Toggle window: shown -> hidden'); + if (IS_DARWIN) { + app.hide(); + } else { + win.hide(); + } + } else { + log.debug('Toggle window: hidden -> shown'); + win.show(); + } + }; + + win.webContents.on('dom-ready', () => { + log.debug('Send config to renderer procress'); + win.webContents.send('mstdn:config', config, account); + }); + win.webContents.once('dom-ready', () => { + log.debug('Normal window application was launched'); + if (config.hot_key) { + globalShortcut.register(config.hot_key, toggleWindow); + log.debug('Hot key was set to:', config.hot_key); + } + if (IS_DEBUG) { + win.webContents.openDevTools({mode: 'detach'}); + } + }); + + const normalIcon = trayIcon(config.icon_color); + const tray = new Tray(normalIcon); + tray.on('click', toggleWindow); + tray.on('double-click', toggleWindow); + if (IS_DARWIN) { + tray.setHighlightMode('never'); + } + + resolve(new Window(win, state, account, null)); + }); +} + +function startMenuBar(account: Account, config: Config, bar: Menubar.MenubarApp | null): Promise { + log.debug('Setup a menubar window'); + return new Promise(resolve => { + const state = windowState({ + defaultWidth: 350, + defaultHeight: 420, + }); + const icon = trayIcon(config.icon_color); + const mb = bar || menubar({ + icon, + width: state.width, + height: state.height, + alwaysOnTop: IS_DEBUG || !!config.always_on_top, + tooltip: 'Mstdn', + useContentSize: true, + autoHideMenuBar: true, + show: false, + showDockIcon: true, + webPreferences: { + nodeIntegration: false, + sandbox: true, + preload: PRELOAD_JS, + partition: partitionForAccount(account), + }, + }); + mb.once('after-create-window', () => { + log.debug('Menubar application was launched'); + if (config.hot_key) { + globalShortcut.register(config.hot_key, () => { + if (mb.window.isFocused()) { + log.debug('Toggle window: shown -> hidden'); + mb.hideWindow(); + } else { + log.debug('Toggle window: hidden -> shown'); + mb.showWindow(); + } + }); + log.debug('Hot key was set to:', config.hot_key); + } + if (IS_DEBUG) { + mb.window.webContents.openDevTools({mode: 'detach'}); + } + mb.window.webContents.on('dom-ready', () => { + log.debug('Send config to renderer procress'); + mb.window.webContents.send('mstdn:config', config, account); + }); + state.manage(mb.window); + + resolve(new Window(mb.window, state, account, mb)); + }); + mb.once('after-close', () => { + app.quit(); + }); + if (bar) { + log.debug('Recreate menubar window with different partition:', account); + const pref = mb.getOption('webPreferences'); + pref.partition = partitionForAccount(account); + mb.setOption('webPreferences', pref); + mb.showWindow(); + } else { + log.debug('New menubar instance was created:', account); + mb.once('ready', () => mb.showWindow()); + } + }); +} + diff --git a/renderer/index.ts b/renderer/index.ts index d9135a2..f78c6d2 100644 --- a/renderer/index.ts +++ b/renderer/index.ts @@ -1,5 +1,5 @@ import * as Mousetrap from 'mousetrap'; -import {Config} from '../main/config'; +import {Config, Account} from '../main/config'; import * as Ipc from './ipc'; import log from './log'; @@ -39,7 +39,7 @@ function setupKeybinds(keybinds: {[key: string]: string}, host: string) { }); } else { const func = ShortcutActions[action]; - if (func === undefined) { + if (!func) { log.error('Unknown shortcut action:', action); continue; } @@ -52,8 +52,10 @@ function setupKeybinds(keybinds: {[key: string]: string}, host: string) { } } -Ipc.on('mstdn:config', (config: Config) => { - // TODO: Temporary. It should be fixed on supporting multi-account. - const host = config.accounts[0].host; +let config: Config | null = null; + +Ipc.on('mstdn:config', (c: Config, a: Account) => { + config = c; + const host = a.host; setupKeybinds(config.keymaps, host); }); diff --git a/renderer/ipc.ts b/renderer/ipc.ts index a954c5c..b5389e8 100644 --- a/renderer/ipc.ts +++ b/renderer/ipc.ts @@ -5,7 +5,7 @@ const ipc = electron.ipcRenderer; export function on(channel: IpcChannel, callback: (...args: any[]) => void) { ipc.on(channel, (...args: any[]) => { - log.debug('IPC: Received from:', channel, args); + log.info('IPC: Received from:', channel, args); callback(...args); }); } diff --git a/typings/ipc.d.ts b/typings/ipc.d.ts index becc519..2312831 100644 --- a/typings/ipc.d.ts +++ b/typings/ipc.d.ts @@ -1,3 +1,4 @@ type IpcChannel = 'mstdn:config' + | 'mstdn:change-account' ;