'use strict' import { app, ipcMain, shell, session, Menu, Tray, BrowserWindow, BrowserWindowConstructorOptions, MenuItemConstructorOptions, IpcMainEvent, nativeTheme, IpcMainInvokeEvent, Notification, NotificationConstructorOptions } from 'electron' import crypto from 'crypto' import log from 'electron-log' import windowStateKeeper from 'electron-window-state' import simplayer from 'simplayer' import path from 'path' import ContextMenu from 'electron-context-menu' import { initSplashScreen, Config } from '@trodi/electron-splashscreen' import openAboutWindow from 'about-window' import generator, { detector, OAuth, NotificationType, Entity } from 'megalodon' import AutoLaunch from 'auto-launch' import minimist from 'minimist' import sanitizeHtml from 'sanitize-html' // db import { backwardAccount, forwardAccount, getAccount, insertAccount, listAccounts, removeAccount, removeAllAccounts } from './db/account' import { insertTag, listTags, removeTag } from './db/hashtags' import { createOrUpdateSetting, getSetting } from './db/setting' import { insertServer } from './db/server' import { DirectStreaming, ListStreaming, LocalStreaming, PublicStreaming, StreamingURL, TagStreaming, UserStreaming } from './websocket' import Preferences from './preferences' import Fonts from './fonts' import i18next from '~/src/config/i18n' import { i18n as I18n } from 'i18next' import Language, { LanguageType } from '../constants/language' import { LocalAccount } from '~/src/types/localAccount' import { LocalTag } from '~/src/types/localTag' import { Proxy } from '~/src/types/proxy' import ProxyConfiguration from './proxy' import { Menu as MenuPreferences } from '~/src/types/preference' import { General as GeneralPreferences } from '~/src/types/preference' import newDB from './database' import { Setting } from '~/src/types/setting' import { LocalServer } from '~/src/types/localServer' import { Notify } from '~/src/types/notify' /** * Context menu */ ContextMenu({ showCopyImageAddress: true, showSaveImageAs: true }) /** * Set log level */ log.transports.console.level = 'debug' log.transports.file.level = 'info' declare namespace global { let __static: string } /** * Set `__static` path to static files in production * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-static-assets.html */ if (process.env.NODE_ENV !== 'development') { global.__static = path.join(__dirname, '/static').replace(/\\/g, '\\\\') } let mainWindow: BrowserWindow | null let tray: Tray | null const winURL = process.env.NODE_ENV === 'development' ? `http://localhost:9080` : path.join('file://', __dirname, '/index.html') // MAS build is not allowed requestSingleInstanceLock. // ref: https://github.com/h3poteto/whalebird-desktop/issues/1030 // ref: https://github.com/electron/electron-osx-sign/issues/137#issuecomment-307626305 if (process.platform !== 'darwin') { // Enforces single instance for linux and windows. const gotTheLock = app.requestSingleInstanceLock() if (!gotTheLock) { app.quit() } else { app.on('second-instance', () => { // Someone tried to run a second instance, we should focus our window. if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore() if (!mainWindow!.isVisible()) { mainWindow!.show() mainWindow!.setSkipTaskbar(false) } mainWindow.focus() } }) } } const appId = 'social.whalebird.desktop' const splashURL = process.env.NODE_ENV === 'development' ? path.resolve(__dirname, '../../static/splash-screen.html') : path.join(__dirname, '/static/splash-screen.html') const userData = app.getPath('userData') const appPath = app.getPath('exe') const databasePath = process.env.NODE_ENV === 'production' ? userData + '/db/whalebird.db' : 'whalebird.db' const db = newDB(databasePath) const preferencesDBPath = process.env.NODE_ENV === 'production' ? userData + './db/preferences.json' : 'preferences.json' const soundBasePath = process.env.NODE_ENV === 'development' ? path.join(__dirname, '../../build/sounds/') : path.join(process.resourcesPath!, 'build/sounds/') const iconBasePath = process.env.NODE_ENV === 'development' ? path.resolve(__dirname, '../../build/icons/') : path.resolve(process.resourcesPath!, 'build/icons/') let launcher: AutoLaunch | null = null const proxyConfiguration = new ProxyConfiguration(preferencesDBPath) // On MAS build, auto launch is not working. // We have to use Launch Agent: https://github.com/Teamwork/node-auto-launch/issues/43 // But it is too difficult to build, and Slack does not provide this function in MAS build. // Therefore I don't provide this function for MacOS. if (process.platform !== 'darwin') { launcher = new AutoLaunch({ name: 'Whalebird', path: appPath }) } async function changeAccount(account: LocalAccount, index: number) { // Sometimes application is closed to tray. // In this time, mainWindow in not exist, so we have to create window. if (mainWindow === null) { await createWindow() // We have to wait the web contents is loaded. mainWindow!.webContents.on('did-finish-load', () => { mainWindow!.webContents.send('change-account', Object.assign(account, { index: index })) }) } else { mainWindow.show() mainWindow.webContents.send('change-account', Object.assign(account, { index: index })) } } async function getLanguage() { try { const preferences = new Preferences(preferencesDBPath) const conf = await preferences.load() return conf.language.language } catch (err) { log.warn(err) return Language.en.key } } const getSpellChecker = async (): Promise => { try { const preferences = new Preferences(preferencesDBPath) const conf = await preferences.load() return conf.language.spellchecker.enabled } catch (err) { return true } } const getMenuPreferences = async (): Promise => { const preferences = new Preferences(preferencesDBPath) const conf = await preferences.load() return conf.menu } const getGeneralPreferences = async (): Promise => { const preferences = new Preferences(preferencesDBPath) const conf = await preferences.load() return conf.general } /** * Set application menu * @return Whether the menu bar is auto hide. */ const updateApplicationMenu = async (accountsChange: Array): Promise => { const menuPreferences = await getMenuPreferences() const menu = ApplicationMenu(accountsChange, menuPreferences, i18next) Menu.setApplicationMenu(menu) let autoHideMenuBar = false if (menuPreferences.autoHideMenu) { autoHideMenuBar = true } return autoHideMenuBar } /** * Set dock menu for mac */ const updateDockMenu = async (accountsChange: Array) => { if (process.platform !== 'darwin') { return } const dockMenu = Menu.buildFromTemplate(accountsChange) app.dock.setMenu(dockMenu) } async function createWindow() { /** * List accounts */ const accounts = await listAccounts(db) const accountsChange: Array = accounts.map(([a, s], index) => { return { label: s.domain, accelerator: `CmdOrCtrl+${index + 1}`, click: () => changeAccount(a, index) } }) /** * Get language */ const language = await getLanguage() i18next.changeLanguage(language) /** * Get spellcheck */ const spellcheck = await getSpellChecker() /** * Get general preferences */ const generalPreferences = await getGeneralPreferences() /** * Load system theme color for dark mode */ nativeTheme.themeSource = 'system' /** * Set Application Menu */ const autoHideMenuBar = await updateApplicationMenu(accountsChange) /** * Set dock menu for mac */ await updateDockMenu(accountsChange) /** * Windows10 don't notify, so we have to set appId * https://github.com/electron/electron/issues/10864 */ app.setAppUserModelId(appId) /** * Enable accessibility */ app.accessibilitySupportEnabled = true /** * Initial window options */ const mainWindowState = windowStateKeeper({ defaultWidth: 1000, defaultHeight: 563 }) const titleBarStyle = process.platform === 'win32' ? 'default' : 'hidden' const mainOpts: BrowserWindowConstructorOptions = { titleBarStyle: titleBarStyle, x: mainWindowState.x, y: mainWindowState.y, width: mainWindowState.width, height: mainWindowState.height, backgroundColor: '#fff', useContentSize: true, icon: path.join(iconBasePath, '256x256.png'), autoHideMenuBar: autoHideMenuBar, webPreferences: { nodeIntegration: false, contextIsolation: false, preload: path.resolve(__dirname, './preload.js'), spellcheck: spellcheck } } const config: Config = { windowOpts: mainOpts, templateUrl: splashURL, splashScreenOpts: { width: 425, height: 325 } } mainWindow = initSplashScreen(config) mainWindowState.manage(mainWindow) /** * Get system proxy configuration. */ if (session && session.defaultSession) { const proxyInfo = await session.defaultSession.resolveProxy('https://mastodon.social') proxyConfiguration.setSystemProxy(proxyInfo) log.info(`System proxy configuration: ${proxyInfo}`) } /** * Set proxy for BrowserWindow */ const proxyConfig = await proxyConfiguration.forMastodon() if (proxyConfig) { await mainWindow.webContents.session.setProxy({ proxyRules: `${proxyConfig.protocol}://${proxyConfig.host}:${proxyConfig.port}` }) } mainWindow.loadURL(winURL) mainWindow.webContents.on('will-navigate', event => event.preventDefault()) // Show tray icon only linux and windows. if (process.platform !== 'darwin') { // Show tray icon tray = new Tray(path.join(iconBasePath, 'tray_icon.png')) const trayMenu = TrayMenu(accountsChange, i18next) tray.setContextMenu(trayMenu) // For Windows tray.setToolTip(i18next.t('main_menu.application.name')) tray.on('click', () => { if (mainWindow!.isVisible()) { mainWindow!.hide() mainWindow!.setSkipTaskbar(true) } else { mainWindow!.show() mainWindow!.setSkipTaskbar(false) } }) // Minimize to tray mainWindow.on('close', event => { mainWindow!.hide() mainWindow!.setSkipTaskbar(true) event.preventDefault() }) // Minimize to tray immediately if "hide on launch" selected // or if --hidden arg is passed if ((generalPreferences.other.hideOnLaunch || args.hidden) && !args.show) { mainWindow.once('show', () => { mainWindow?.hide() mainWindow?.setSkipTaskbar(true) }) } } else { mainWindow.on('closed', () => { mainWindow = null }) } } // Parse command line arguments and show help command. const args = minimist(process.argv.slice(process.env.NODE_ENV === 'development' ? 2 : 1)) if (args.help) { console.log(` Whalebird is Mastodon, Pleroma and Misskey client for desktop. Usage $ whalebird Options --help show help --hidden start Whalebird hidden to tray --show start Whalebird with a window `) process.exit(0) } // Do not lower the rendering priority of Chromium when background app.commandLine.appendSwitch('disable-renderer-backgrounding') app.on('ready', async () => { createWindow() const accounts = await listAccounts(db) const preferences = new Preferences(preferencesDBPath) startUserStreamings(accounts, proxyConfiguration, preferences) startDirectStreamings(accounts, proxyConfiguration) startLocalStreamings(accounts, proxyConfiguration) startPublicStreamings(accounts, proxyConfiguration) }) app.on('window-all-closed', () => { // this action is called when user click the close button. // In macOS, close button does not shutdown application. It is hide application window. if (process.platform !== 'darwin') { app.quit() } else { // In MacOS, we should change disable some menu items. const menu = Menu.getApplicationMenu() if (menu) { if (menu.items[0].submenu) { // Preferences menu.items[0].submenu.items[2].enabled = false } if (menu.items[1].submenu) { // New Toot menu.items[1].submenu.items[0].enabled = false } if (menu.items[4].submenu) { // Open Window menu.items[4].submenu.items[1].enabled = true // Jump to menu.items[4].submenu.items[4].enabled = false } } } }) app.on('activate', () => { if (mainWindow === null) { createWindow() } }) ipcMain.handle('add-server', async (_: IpcMainInvokeEvent, domain: string) => { const proxy = await proxyConfiguration.forMastodon() const sns = await detector(`https://${domain}`, proxy) const server = await insertServer(db, `https://${domain}`, domain, sns, null) return server }) ipcMain.handle('add-app', async (_: IpcMainInvokeEvent, url: string) => { const proxy = await proxyConfiguration.forMastodon() const sns = await detector(url, proxy) const client = generator(sns, url, null, 'Whalebird', proxy) const appData = await client.registerApp('Whalebird', { website: 'https://whalebird.social' }) if (appData.url) { shell.openExternal(appData.url) } return appData }) type AuthorizeRequest = { server: LocalServer appData: OAuth.AppData code: string } ipcMain.handle('authorize', async (_: IpcMainInvokeEvent, req: AuthorizeRequest) => { const proxy = await proxyConfiguration.forMastodon() const sns = await detector(req.server.baseURL, proxy) const client = generator(sns, req.server.baseURL, null, 'Whalebird', proxy) const tokenData = await client.fetchAccessToken(req.appData.client_id, req.appData.client_secret, req.code, 'urn:ietf:wg:oauth:2.0:oob') let accessToken = tokenData.access_token if (sns === 'misskey') { // In misskey, access token is sha256(userToken + clientSecret) accessToken = crypto .createHash('sha256') .update(tokenData.access_token + req.appData.client_secret, 'utf8') .digest('hex') } const authorizedClient = generator(sns, req.server.baseURL, accessToken, 'Whalebird', proxy) const credentials = await authorizedClient.verifyAccountCredentials() const account = await insertAccount( db, credentials.data.username, credentials.data.id, credentials.data.avatar, req.appData.client_id, req.appData.client_secret, accessToken, tokenData.refresh_token, req.server ) return account }) ipcMain.handle('list-accounts', async (_: IpcMainInvokeEvent) => { const accounts = await listAccounts(db) return accounts }) ipcMain.handle('get-local-account', async (_: IpcMainInvokeEvent, id: number) => { const account = await getAccount(db, id) return account }) ipcMain.handle('remove-account', async (_: IpcMainInvokeEvent, id: number) => { await removeAccount(db, id) const accounts = await listAccounts(db) const accountsChange: Array = accounts.map(([account, server], index) => { return { label: server.domain, accelerator: `CmdOrCtrl+${index + 1}`, click: () => changeAccount(account, index) } }) await updateApplicationMenu(accountsChange) await updateDockMenu(accountsChange) if (process.platform !== 'darwin' && tray !== null) { tray.setContextMenu(TrayMenu(accountsChange, i18next)) } }) ipcMain.handle('forward-account', async (_: IpcMainInvokeEvent, id: number) => { await forwardAccount(db, id) }) ipcMain.handle('backward-account', async (_: IpcMainInvokeEvent, id: number) => { await backwardAccount(db, id) }) ipcMain.handle('remove-all-accounts', async (_: IpcMainInvokeEvent) => { await removeAllAccounts(db) const accounts = await listAccounts(db) const accountsChange: Array = accounts.map(([account, server], index) => { return { label: server.domain, accelerator: `CmdOrCtrl+${index + 1}`, click: () => changeAccount(account, index) } }) await updateApplicationMenu(accountsChange) await updateDockMenu(accountsChange) if (process.platform !== 'darwin' && tray !== null) { tray.setContextMenu(TrayMenu(accountsChange, i18next)) } }) ipcMain.handle('change-auto-launch', async (_: IpcMainInvokeEvent, enable: boolean) => { if (launcher) { const enabled = await launcher.isEnabled() if (!enabled && enable && launcher) { launcher.enable() } else if (enabled && !enable && launcher) { launcher.disable() } return enable } else { return false } }) // badge ipcMain.on('reset-badge', () => { if (process.platform === 'darwin') { app.dock.setBadge('') } }) // sounds ipcMain.on('fav-rt-action-sound', () => { const preferences = new Preferences(preferencesDBPath) preferences .load() .then(conf => { if (conf.general.sound.fav_rb) { const sound = path.join(soundBasePath, 'operation_sound01.wav') simplayer(sound, (err: Error) => { if (err) log.error(err) }) } }) .catch(err => log.error(err)) }) ipcMain.on('toot-action-sound', () => { const preferences = new Preferences(preferencesDBPath) preferences .load() .then(conf => { if (conf.general.sound.toot) { const sound = path.join(soundBasePath, 'operation_sound02.wav') simplayer(sound, (err: Error) => { if (err) log.error(err) }) } }) .catch(err => log.error(err)) }) // preferences ipcMain.handle('get-preferences', async (_: IpcMainInvokeEvent) => { const preferences = new Preferences(preferencesDBPath) let enabled = false if (launcher) { enabled = await launcher.isEnabled() } await preferences .update({ general: { other: { launch: enabled } } }) .catch(err => console.error(err)) const conf = await preferences.load() return conf }) ipcMain.handle('update-preferences', async (_: IpcMainInvokeEvent, data: any) => { const preferences = new Preferences(preferencesDBPath) const conf = await preferences.update(data) return conf }) ipcMain.handle('reset-preferences', async (_: IpcMainInvokeEvent) => { const preferences = new Preferences(preferencesDBPath) const conf = await preferences.reset() return conf }) ipcMain.handle('system-use-dark-theme', async (_: IpcMainInvokeEvent) => { return nativeTheme.shouldUseDarkColors }) ipcMain.on('change-collapse', (_event: IpcMainEvent, value: boolean) => { const preferences = new Preferences(preferencesDBPath) preferences .update({ state: { collapse: value } }) .catch(err => { log.error(err) }) }) ipcMain.handle('get-collapse', async (_: IpcMainInvokeEvent) => { const preferences = new Preferences(preferencesDBPath) const conf = await preferences.load() return conf.state.collapse }) ipcMain.handle('change-global-header', async (_: IpcMainInvokeEvent, value: boolean) => { const preferences = new Preferences(preferencesDBPath) const conf = await preferences.update({ state: { hideGlobalHeader: value } }) return conf }) ipcMain.handle('get-global-header', async (_: IpcMainInvokeEvent) => { const preferences = new Preferences(preferencesDBPath) const conf = await preferences.load() return conf.state.hideGlobalHeader }) // proxy ipcMain.handle('update-proxy-config', async (_event: IpcMainInvokeEvent, proxy: Proxy) => { const preferences = new Preferences(preferencesDBPath) try { const conf = await preferences.update({ proxy: proxy }) const proxyConfig = await proxyConfiguration.forMastodon() if (proxyConfig) { await mainWindow?.webContents.session.setProxy({ proxyRules: `${proxyConfig.protocol}://${proxyConfig.host}:${proxyConfig.port}` }) } else { await mainWindow?.webContents.session.setProxy({}) } return conf } catch (err) { log.error(err) } return null }) // language ipcMain.handle('change-language', async (_: IpcMainInvokeEvent, value: string) => { const preferences = new Preferences(preferencesDBPath) const conf = await preferences.update({ language: { language: value } }) i18next.changeLanguage(conf.language.language) const accounts = await listAccounts(db) const accountsChange: Array = accounts.map(([a, s], index) => { return { label: s.domain, accelerator: `CmdOrCtrl+${index + 1}`, click: () => changeAccount(a, index) } }) await updateApplicationMenu(accountsChange) await updateDockMenu(accountsChange) if (process.platform !== 'darwin' && tray !== null) { tray.setContextMenu(TrayMenu(accountsChange, i18next)) } return conf.language.language }) ipcMain.handle('toggle-spellchecker', async (_: IpcMainInvokeEvent, value: boolean) => { mainWindow?.webContents.session.setSpellCheckerEnabled(value) const preferences = new Preferences(preferencesDBPath) const conf = await preferences.update({ language: { spellchecker: { enabled: value } } }) return conf.language.spellchecker.enabled }) ipcMain.handle('update-spellchecker-languages', async (_: IpcMainInvokeEvent, languages: Array) => { const decoded: Array = languages.map(l => { const d = decodeLanguage(l) return d.rfc4646 }) mainWindow?.webContents.session.setSpellCheckerLanguages(decoded) const preferences = new Preferences(preferencesDBPath) const conf = await preferences.update({ language: { spellchecker: { languages: languages } } }) return conf.language.spellchecker.languages }) // hashtag ipcMain.handle('save-hashtag', async (_: IpcMainInvokeEvent, req: { accountId: number; tag: string }) => { await insertTag(db, req.accountId, req.tag) }) ipcMain.handle('list-hashtags', async (_: IpcMainInvokeEvent, accountId: number) => { const tags = await listTags(db, accountId) return tags }) ipcMain.handle('remove-hashtag', async (_: IpcMainInvokeEvent, tag: LocalTag) => { await removeTag(db, tag) }) // Fonts ipcMain.handle('list-fonts', async (_: IpcMainInvokeEvent) => { const list = await Fonts() return list }) // Settings ipcMain.handle( 'get-account-setting', async (_: IpcMainInvokeEvent, accountId: number): Promise => { const setting = await getSetting(db, accountId) return setting } ) ipcMain.handle( 'update-account-setting', async (_: IpcMainInvokeEvent, setting: Setting): Promise => { console.log(setting) const res = await createOrUpdateSetting(db, setting) return res } ) // Cache ipcMain.handle('get-cache-hashtags', async (_: IpcMainInvokeEvent) => { // TODO: return [] }) ipcMain.handle('insert-cache-hashtags', async (_: IpcMainInvokeEvent) => { return null }) ipcMain.handle('get-cache-accounts', async (_: IpcMainInvokeEvent) => { return [] }) ipcMain.handle('insert-cache-accounts', async (_: IpcMainInvokeEvent) => { return [] }) // Application control ipcMain.on('relaunch', () => { app.relaunch() app.exit() }) /** * Auto Updater * * Uncomment the following code below and install `electron-updater` to * support auto updating. Code Signing with a valid certificate is required. * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-electron-builder.html#auto-updating */ /* import { autoUpdater } from 'electron-updater' autoUpdater.on('update-downloaded', () => { autoUpdater.quitAndInstall() }) app.on('ready', () => { if (process.env.NODE_ENV === 'production') autoUpdater.checkForUpdates() }) */ /** * Generate application menu */ const ApplicationMenu = (accountsChange: Array, menu: MenuPreferences, i18n: I18n): Menu => { /** * For mac menu */ const macGeneralMenu: Array = process.platform !== 'darwin' ? [] : [ { type: 'separator' }, { label: i18n.t('main_menu.application.services'), role: 'services' }, { type: 'separator' }, { label: i18n.t('main_menu.application.hide'), role: 'hide' }, { label: i18n.t('main_menu.application.hide_others'), role: 'hideOthers' }, { label: i18n.t('main_menu.application.show_all'), role: 'unhide' } ] const macWindowMenu: Array = process.platform === 'darwin' ? [] : [ { label: i18n.t('main_menu.window.always_show_menu_bar'), type: 'checkbox', checked: !menu.autoHideMenu, click: item => { changeMenuAutoHide(!item.checked) } }, { type: 'separator' } ] const applicationQuitMenu: Array = process.platform === 'darwin' ? [ { label: i18n.t('main_menu.application.quit'), accelerator: 'CmdOrCtrl+Q', role: 'quit' } ] : [ { label: i18n.t('main_menu.application.quit'), accelerator: 'CmdOrCtrl+Q', click: () => { mainWindow!.destroy() } } ] const template: Array = [ { label: i18n.t('main_menu.application.name'), submenu: [ { label: i18n.t('main_menu.application.about'), role: 'about', click: () => { openAboutWindow({ icon_path: path.join(iconBasePath, '256x256.png'), copyright: 'Copyright (c) 2021 AkiraFukushima', package_json_dir: path.resolve(__dirname, '../../'), open_devtools: process.env.NODE_ENV !== 'production' }) } }, { type: 'separator' }, { label: i18n.t('main_menu.application.preferences'), accelerator: 'CmdOrCtrl+,', click: () => { mainWindow!.webContents.send('open-preferences') } }, { label: i18n.t('main_menu.application.shortcuts'), accelerator: 'Shift+?', click: () => { mainWindow!.webContents.send('open-shortcuts-list') } }, ...macGeneralMenu, { type: 'separator' }, ...applicationQuitMenu ] }, { label: i18n.t('main_menu.toot.name'), submenu: [ { label: i18n.t('main_menu.toot.new'), accelerator: 'CmdOrCtrl+N', click: () => { mainWindow!.webContents.send('CmdOrCtrl+N') } } ] }, { label: i18n.t('main_menu.edit.name'), submenu: [ { label: i18n.t('main_menu.edit.undo'), accelerator: 'CmdOrCtrl+Z', role: 'undo' }, { label: i18n.t('main_menu.edit.redo'), accelerator: 'Shift+CmdOrCtrl+Z', role: 'redo' }, { type: 'separator' }, { label: i18n.t('main_menu.edit.cut'), accelerator: 'CmdOrCtrl+X', role: 'cut' }, { label: i18n.t('main_menu.edit.copy'), accelerator: 'CmdOrCtrl+C', role: 'copy' }, { label: i18n.t('main_menu.edit.paste'), accelerator: 'CmdOrCtrl+V', role: 'paste' }, { label: i18n.t('main_menu.edit.select_all'), accelerator: 'CmdOrCtrl+A', role: 'selectall' } ] as Array }, { label: i18n.t('main_menu.view.name'), submenu: [ { label: i18n.t('main_menu.view.toggle_full_screen'), role: 'togglefullscreen' } ] }, { label: i18n.t('main_menu.window.name'), submenu: [ ...macWindowMenu, { label: i18n.t('main_menu.window.close'), role: 'close' }, { label: i18n.t('main_menu.window.open'), enabled: false, click: () => { reopenWindow() } }, { label: i18n.t('main_menu.window.minimize'), role: 'minimize' }, { type: 'separator' }, { label: i18n.t('main_menu.window.jump_to'), accelerator: 'CmdOrCtrl+K', enabled: true, click: () => { mainWindow!.webContents.send('CmdOrCtrl+K') } }, { type: 'separator' }, ...accountsChange ] } ] return Menu.buildFromTemplate(template) } const TrayMenu = (accountsChange: Array, i18n: I18n): Menu => { const template: Array = [ ...accountsChange, { label: i18n.t('main_menu.application.open'), click: async () => { if (mainWindow) { mainWindow.show() } else { await createWindow() } } }, { label: i18n.t('main_menu.application.quit'), click: () => { stopAllStreamings() mainWindow!.destroy() } } ] const menu: Menu = Menu.buildFromTemplate(template) return menu } const changeMenuAutoHide = async (autoHide: boolean) => { if (mainWindow === null) { return null } mainWindow.autoHideMenuBar = autoHide mainWindow.setMenuBarVisibility(!autoHide) const preferences = new Preferences(preferencesDBPath) preferences.update({ menu: { autoHideMenu: autoHide } }) return null } async function reopenWindow() { if (mainWindow === null) { await createWindow() return null } else { return null } } const decodeLanguage = (lang: string): LanguageType => { const l = Object.keys(Language).find(k => Language[k].key === lang) if (l === undefined) { return Language.en } else { return Language[l] } } //---------------------------------------------- // Streamings //---------------------------------------------- const userStreamings: { [key: number]: UserStreaming } = {} const directStreamings: { [key: number]: DirectStreaming } = {} const localStreamings: { [key: number]: DirectStreaming } = {} const publicStreamings: { [key: number]: DirectStreaming } = {} const stopAllStreamings = () => { Object.keys(userStreamings).forEach((key: string) => { userStreamings[parseInt(key)].stop() }) Object.keys(directStreamings).forEach((key: string) => { directStreamings[parseInt(key)].stop() }) Object.keys(localStreamings).forEach((key: string) => [localStreamings[parseInt(key)].stop()]) Object.keys(publicStreamings).forEach((key: string) => { publicStreamings[parseInt(key)].stop() }) } const startUserStreamings = async ( accounts: Array<[LocalAccount, LocalServer]>, proxyConfiguration: ProxyConfiguration, preferences: Preferences ) => { const proxy = await proxyConfiguration.forMastodon() accounts.forEach(async ([account, server]) => { const url = await StreamingURL(server.sns, account, server, proxy) userStreamings[account.id] = new UserStreaming(server.sns, account, url, proxy) userStreamings[account.id].start( async (update: Entity.Status) => { if (!mainWindow?.webContents.isDestroyed()) { mainWindow?.webContents.send(`update-user-streamings-${account.id}`, update) } }, async (notification: Entity.Notification) => { await publishNotification(notification, account.id, preferences) if (!mainWindow?.webContents.isDestroyed()) { mainWindow?.webContents.send(`notification-user-streamings-${account.id}`, notification) } }, (statusId: string) => { if (!mainWindow?.webContents.isDestroyed()) { mainWindow?.webContents.send(`delete-user-streamings-${account.id}`, statusId) } }, (err: Error) => { log.error(err) } ) }) return userStreamings } const startDirectStreamings = async (accounts: Array<[LocalAccount, LocalServer]>, proxyConfiguration: ProxyConfiguration) => { const proxy = await proxyConfiguration.forMastodon() accounts.forEach(async ([account, server]) => { const url = await StreamingURL(server.sns, account, server, proxy) directStreamings[account.id] = new DirectStreaming(server.sns, account, url, proxy) directStreamings[account.id].start( (update: Entity.Status) => { if (!mainWindow?.webContents.isDestroyed()) { mainWindow?.webContents.send(`update-direct-streamings-${account.id}`, update) } }, (id: string) => { if (!mainWindow?.webContents.isDestroyed()) { mainWindow?.webContents.send(`delete-direct-streamings-${account.id}`, id) } }, (err: Error) => { log.error(err) } ) }) } const startLocalStreamings = async (accounts: Array<[LocalAccount, LocalServer]>, proxyConfiguration: ProxyConfiguration) => { const proxy = await proxyConfiguration.forMastodon() accounts.forEach(async ([account, server]) => { const url = await StreamingURL(server.sns, account, server, proxy) localStreamings[account.id] = new LocalStreaming(server.sns, account, url, proxy) localStreamings[account.id].start( (update: Entity.Status) => { if (!mainWindow?.webContents.isDestroyed()) { mainWindow?.webContents.send(`update-local-streamings-${account.id}`, update) } }, (id: string) => { if (!mainWindow?.webContents.isDestroyed()) { mainWindow?.webContents.send(`delete-local-streamings-${account.id}`, id) } }, (err: Error) => { log.error(err) } ) }) } const startPublicStreamings = async (accounts: Array<[LocalAccount, LocalServer]>, proxyConfiguration: ProxyConfiguration) => { const proxy = await proxyConfiguration.forMastodon() accounts.forEach(async ([account, server]) => { const url = await StreamingURL(server.sns, account, server, proxy) publicStreamings[account.id] = new PublicStreaming(server.sns, account, url, proxy) publicStreamings[account.id].start( (update: Entity.Status) => { if (!mainWindow?.webContents.isDestroyed()) { mainWindow?.webContents.send(`update-public-streamings-${account.id}`, update) } }, (id: string) => { if (!mainWindow?.webContents.isDestroyed()) { mainWindow?.webContents.send(`delete-public-streamings-${account.id}`, id) } }, (err: Error) => { log.error(err) } ) }) } const publishNotification = async (notification: Entity.Notification, accountId: number, preferences: Preferences) => { const conf = await preferences.load() const options = createNotification(notification, conf.notification.notify) if (options !== null) { const notify = new Notification(options) notify.on('click', _ => { if (!mainWindow?.webContents.isDestroyed()) { mainWindow?.webContents.send('open-notification-tab', accountId) } }) notify.show() } if (process.platform === 'darwin') { app.dock.setBadge('•') } } const createNotification = (notification: Entity.Notification, notifyConfig: Notify): NotificationConstructorOptions | null => { switch (notification.type) { case NotificationType.Favourite: if (notifyConfig.favourite) { return { title: i18next.t('notification.favourite.title'), body: i18next.t('notification.favourite.body', { username: username(notification.account) }), silent: false } as NotificationConstructorOptions } break case NotificationType.Follow: if (notifyConfig.follow) { return { title: i18next.t('notification.follow.title'), body: i18next.t('notification.follow.body', { username: username(notification.account) }), silent: false } as NotificationConstructorOptions } break case NotificationType.FollowRequest: if (notifyConfig.follow_request) { return { title: i18next.t('notification.follow_request.title'), body: i18next.t('notification.follow_request.body', { username: username(notification.account) }), silent: false } as NotificationConstructorOptions } break case NotificationType.Mention: if (notifyConfig.reply) { return { title: `${username(notification.status!.account)}`, body: sanitizeHtml(notification.status!.content, { allowedTags: [], allowedAttributes: [] }), silent: false } as NotificationConstructorOptions } break case NotificationType.Reblog: if (notifyConfig.reblog) { if (notification.status && notification.status.quote) { return { title: i18next.t('notification.quote.title'), body: i18next.t('notification.quote.body', { username: username(notification.account) }), silent: false } as NotificationConstructorOptions } else { return { title: i18next.t('notification.reblog.title'), body: i18next.t('notification.reblog.body', { username: username(notification.account) }), silent: false } as NotificationConstructorOptions } } break case NotificationType.EmojiReaction: if (notifyConfig.reaction) { return { title: i18next.t('notification.reaction.title'), body: i18next.t('notification.reaction.body', { username: username(notification.account) }), silent: false } as NotificationConstructorOptions } break case NotificationType.Status: if (notifyConfig.status) { return { title: i18next.t('notification.status.title'), body: i18next.t('notification.status.body', { username: username(notification.account) }), silent: false } as NotificationConstructorOptions } break case NotificationType.PollVote: if (notifyConfig.poll_vote) { return { title: i18next.t('notification.poll_vote.title'), body: i18next.t('notification.poll_vote.body', { username: username(notification.account) }), silent: false } as NotificationConstructorOptions } break case NotificationType.PollExpired: if (notifyConfig.poll_expired) { return { title: i18next.t('notification.poll_expired.title'), body: i18next.t('notification.poll_expired.body', { username: username(notification.account) }), silent: false } as NotificationConstructorOptions } break default: break } return null } const username = (account: Entity.Account): string => { if (account.display_name !== '') { return account.display_name } else { return account.username } } //---------------------------------------- // List streamings //---------------------------------------- const listStreamings: { [key: number]: ListStreaming } = {} type ListStreamingOpts = { listId: string accountId: number } ipcMain.on('start-list-streaming', async (event: IpcMainEvent, obj: ListStreamingOpts) => { const { listId, accountId } = obj try { const [account, server] = await getAccount(db, accountId) // Stop old list streaming if (listStreamings[accountId] !== undefined) { listStreamings[accountId].stop() } const proxy = await proxyConfiguration.forMastodon() const url = await StreamingURL(server.sns, account, server, proxy) listStreamings[accountId] = new ListStreaming(server.sns, account, url, proxy) listStreamings[accountId].start( listId, (update: Entity.Status) => { if (!event.sender.isDestroyed()) { event.sender.send(`update-list-streamings-${accountId}`, update) } }, (id: string) => { if (!event.sender.isDestroyed()) { event.sender.send(`delete-list-streamings-${accountId}`, id) } }, (err: Error) => { log.error(err) } ) } catch (err) { log.error(err) } }) //---------------------------------------- // Tag streamings //---------------------------------------- const tagStreamings: { [key: number]: TagStreaming } = {} type TagStreamingOpts = { tag: string accountId: number } ipcMain.on('start-tag-streaming', async (event: IpcMainEvent, obj: TagStreamingOpts) => { const { tag, accountId } = obj try { const [account, server] = await getAccount(db, accountId) // Stop old tag streaming if (tagStreamings[accountId] !== undefined) { tagStreamings[accountId].stop() } const proxy = await proxyConfiguration.forMastodon() const url = await StreamingURL(server.sns, account, server, proxy) tagStreamings[accountId] = new TagStreaming(server.sns, account, url, proxy) tagStreamings[accountId].start( tag, (update: Entity.Status) => { if (!event.sender.isDestroyed()) { event.sender.send(`update-tag-streamings-${accountId}`, update) } }, (id: string) => { if (!event.sender.isDestroyed()) { event.sender.send(`delete-tag-streamings-${accountId}`, id) } }, (err: Error) => { log.error(err) } ) } catch (err) { log.error(err) } })