diff --git a/main/app.ts b/main/app.ts new file mode 100644 index 0000000..111b436 --- /dev/null +++ b/main/app.ts @@ -0,0 +1,169 @@ +import * as path from 'path'; +import {app, BrowserWindow, globalShortcut, Tray} from 'electron'; +import windowState = require('electron-window-state'); +import * as menubar from 'menubar'; +import log from './log'; +import {Config} from './config' + +const IS_DEBUG = process.env.NODE_ENV === 'development'; +const IS_DARWIN = process.platform === 'darwin'; +const APP_ICON = path.join(__dirname, '..', 'resources', 'icon.png'); +const DEFAULT_WIDTH = 340; +const DEFAULT_HEIGHT = 400; + +export class App { + private host: string; + + constructor(private win: Electron.BrowserWindow, private config: Config) { + if (config.accounts.length === 0) { + throw new Error('No account found. Please check the config.'); + } + this.host = this.config.accounts[0].host; + } + + open() { + this.win.loadURL(`https://${this.host}`); + this.win.show(); + } +} + +function trayIcon(color: string) { + return path.join(__dirname, '..', 'resources', `tray-icon-${ + color === 'white' ? 'white' : 'black' + }@2x.png`); +} + +export default function startApp (config: Config) { + return (config.normal_window ? startNormalWindow : startMenuBar)(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, + }, + }); + 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'}); + } + resolve(win); + }); + + 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'); + } + }); +} + +function startMenuBar(config: Config): Promise { + log.debug('Setup a menubar window'); + return new Promise(resolve => { + const state = windowState({ + defaultWidth: DEFAULT_WIDTH, + defaultHeight: DEFAULT_HEIGHT, + }); + 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', + showDockIcon: true, + autoHideMenuBar: true, + useContentSize: true, + show: false, + webPreferences: { + nodeIntegration: false, + sandbox: true, + }, + }); + 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/config.ts b/main/config.ts new file mode 100644 index 0000000..f615291 --- /dev/null +++ b/main/config.ts @@ -0,0 +1,68 @@ +import {app, systemPreferences} from 'electron'; +import * as fs from 'fs'; +import {join} from 'path'; +import log from './log'; + +export interface Account { + host: string; + name: string; +} + +export interface Config { + hot_key: string; + icon_color: string; + always_on_top: boolean; + normal_window: boolean; + zoom_factor: number; + accounts: Account[]; + keymaps: {[key: string]: string}; +} + +function makeDefaultConfig(): Config { + const IsDarkMode = (process.platform === 'darwin') && systemPreferences.isDarkMode(); + const menubarBroken = process.platform === 'win32'; + + return { + hot_key: 'CmdOrCtrl+Shift+S', + icon_color: IsDarkMode ? 'white' : 'black', + always_on_top: false, + normal_window: menubarBroken, + zoom_factor: 1.0, + accounts: [], + keymaps: {}, + }; +} + +export default function loadConfig(): Promise { + return new Promise(resolve => { + const dir = app.getPath('userData'); + const file = join(dir, 'config.json'); + fs.readFile(file, 'utf8', (err, json) => { + if (err) { + log.info('Configuration file was not found, will create:', file); + const default_config = makeDefaultConfig(); + // Note: + // If calling writeFile() directly here, it tries to create config file before Electron + // runtime creates data directory. As the result, writeFile() would fail to create a file. + if (app.isReady()) { + fs.writeFile(file, JSON.stringify(default_config, null, 2)); + } else { + app.once('ready', () => fs.writeFile(file, JSON.stringify(default_config, null, 2))); + } + return resolve(default_config); + } + + try { + const config = JSON.parse(json); + if (config.hot_key && config.hot_key.startsWith('mod+')) { + config.hot_key = `CmdOrCtrl+${config.hot_key.slice(4)}`; + } + log.debug('Configuration was loaded successfully', config); + resolve(config); + } catch (e) { + log.error('Error on loading JSON file, will load default configuration:', e.message); + resolve(makeDefaultConfig()); + } + }); + }); +} diff --git a/main/index.ts b/main/index.ts new file mode 100644 index 0000000..a4d5a70 --- /dev/null +++ b/main/index.ts @@ -0,0 +1,22 @@ +import {app} from 'electron'; +import log from './log'; +import startApp from './app'; +import loadConfig from './config'; + +const appReady = new Promise(resolve => app.once('ready', resolve)); + +process.on('unhandledRejection', (reason: string) => { + log.error('FATAL: Unhandled rejection! Reason:', reason); +}); + +app.on('will-quit', () => { + log.debug('Application is quitting'); +}); + +Promise.all([ + loadConfig(), + appReady +]).then(([config, _]) => startApp(config)).then(win => { + win.open(); + log.debug('Application launched!'); +}); diff --git a/main/log.ts b/main/log.ts new file mode 100644 index 0000000..29e118f --- /dev/null +++ b/main/log.ts @@ -0,0 +1,9 @@ +import * as log from 'loglevel'; + +if (process.env.NODE_ENV === 'development') { + log.setLevel('debug'); +} else { + log.setLevel('info'); +} + +export default log; diff --git a/package.json b/package.json index aae7afa..8d06f7b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mstdn", "productName": "Mstdn", - "version": "0.0.0", + "version": "0.0.1", "description": "Tiny web-based mastodon client for your desktop", "main": "main/index.js", "bin": { @@ -35,6 +35,7 @@ "devDependencies": { "@types/electron": "^1.4.35", "@types/electron-window-state": "^2.0.28", + "@types/loglevel": "^1.4.29", "@types/mousetrap": "^1.5.33", "@types/node": "^7.0.12", "electron-packager": "^8.6.0", @@ -46,7 +47,7 @@ "electron": "^1.6.2", "electron-window-state": "^4.1.1", "loglevel": "^1.4.1", - "menubar": "github:rhysd/menubar#rhysd-fixes", + "menubar": "github:rhysd/menubar#mstdn", "mousetrap": "^1.6.1" } }