1
0
mirror of https://github.com/rhysd/Mstdn.git synced 2025-04-11 03:02:30 +02:00

Compare commits

..

No commits in common. "master" and "v0.1.1" have entirely different histories.

23 changed files with 228 additions and 880 deletions

View File

@ -1,13 +0,0 @@
Thank you for contributing Mstdn.app!
=====================================
## Language
You can use either English or Japanese for issues or pull requests. Please feel free to make ones.
issue 及び pull request は日本語で書いていただいて大丈夫です.
## Templates
Please use issue templates which are automatically inserted.

View File

@ -1,9 +0,0 @@
- Your OS: <!-- Please write -->
- App version: <!-- Please write -->
Note:
- If you're reporting a bug, please describe how to reproduce it.
- If you're suggesting a new feature, please detailed spec of it.
<!-- Please start your description from here -->

View File

@ -12,4 +12,3 @@
*.ts
*.js.map
/typings
/.github

417
README.md
View File

@ -1,30 +1,24 @@
Web-based Desktop Client for [Mastodon][]
=========================================
[![npm version][npm version badge]][npm]
Mstdn is a desktop application based on the mobile version of the Mastodon page
and the [Electron][] framework. It basically uses Mastodon's mobile page and
provides various desktop application features (such as desktop notifications,
keybindings, and multi-account support).
<img src="https://github.com/rhysd/ss/blob/master/Mstdn/main.png?raw=true" width="484" alt="screen shot"/>
Mstdn is a desktop application based on mobile version Mastodon page and [Electron][] framework.
It basically uses Mastodon's mobile page and provides various desktop application features
(such as desktop notification, keybinds, multi-account support).
Features:
- [x] Small window on your menubar (or isolated normal window)
- [x] Desktop notification
- [x] Customizable (and pluggable) shortcut keybinds
- [x] Customizable shortcut keybinds
- [x] Multi-account (switching among accounts)
- [x] User CSS
- [x] Plugin architecture based on Node.js and Electron APIs
Mastodon is an open source project. So if you want to make a new UI, you can
just fork the project, implement your favorite UI and host it on your place.
Then you can participate Mastodon networks from it.
Mastodon is an open source project. So if you want to make a new UI, you can just fork the project,
implement your favorite UI and host it on your place. Then you can participate Mastodon networks from it.
However, Mastodon is a web application. So we can't use it outside of a browser.
This small tool provides the ability to use the Mastodon page in a desktop
application window outside of a browser.
However, Mastodon is a web application. So we can't go out from a browser. So this small tool
provides a way to do it.
## Installation
@ -34,66 +28,29 @@ application window outside of a browser.
$ npm install -g mstdn
```
### Via [yarn][]
```
$ yarn global add mstdn --prefix /usr/local
```
### As an isolated app
Download a package archive from the [Release page][], put the unarchived app
into the proper place, and open it.
### Arch Linux ([AUR][])
Install the `mstdn` package from the AUR.
Download a package archive from [Release page][], put unarchived app to proper place, and open it.
## Usage
If you installed this app via npm or yarn, the following command is available to
start app:
If you installed this app via npm, below command is available to start app.
```
$ open-mstdn-app
```
At first, a dialog which recommends that you create a config is shown and JSON
config file will be opened in your editor. You need to fill in the `"name"` and
`"host"` keys in first element of `"accounts"`. Please see the `accounts`
section below for more information about how to configure the option.
At first, a dialog which recommends to write up config is shown and JSON config file will be open in your editor.
You need to fill up `"name"` and `"host"` keys in first element of `"accounts"`.
Then please try to start app again. Usage is the same as web client on mobile
devices. Some shortcuts are available by default (please see the 'Customization'
section below).
Then please try to start app again. Usage is the same as web client on mobile devices.
Some shortcuts are available by default (please see below 'Customization' section).
Supported platforms are macOS (confirmed with 10.12), Linux (confirmed on Arch
Linux kernel version 4.11) and Windows (confirmed with Windows 8.1).
There are two window modes in this app: 'menubar mode' and 'normal window mode'.
You can switch them with `"normal_window"` option (please see below
'Customization' section for how to configure it).
- **menubar mode**: Window is attached to the menubar. You can toggle the window
by clicking the menubar icon or typing the hot key. The advantage of this mode
is that the app does not fill any workspaces. You can see your timeline on
demand anytime. Like as menus in menubar on macOS, menubar window is automatically
hidden when it loses its focus. This mode is the default on macOS and Linux.
- **normal window mode**: App starts with a normal window like a separated
browser window. You can put/resize window wherever you would like in your
workspace.
In both modes, the app remembers the size and location of its window. So you
need to specify window size (or location in normal window mode) only once.
After starting the app, you would see the login page of your instance. Some
instances allow to login with other web services. However, Mstdn.app cannot
fully support it. If you encounter problems with a customized login feature,
please try to login with normal flow instead.
Supported platforms are macOS (confirmed with 10.12), Linux (hopefully) and Windows (hopefully).
## Customization
Mstdn can be customized using the JSON config file at `{app dir}/config.json`
Mstdn can be customized with JSON config file at `{app dir}/config.json`
The `{app dir}` is:
@ -101,114 +58,59 @@ The `{app dir}` is:
- `~/.config/Mstdn` for Linux
- `%APPDATA%\Mstdn` for Windows.
The JSON file can contain the following key-values:
The JSON file can contain below key-values:
### `hot_key`
`hot_key` is a key sequence to toggle application window. The shortcut key is
defined globally. The format is an [Electron's
accelerator](https://github.com/electron/electron/blob/master/docs/api/accelerator.md).
Please see the document to know how to configure this value. Default value is
`"CmdOrCtrl+Shift+Enter"`. If you want to disable, please set this value to an
empty string or `null`.
`hot_key` is a key sequence to toggle application window. The shortcut key is defined globally.
The format is a [Electron's accelerator](https://github.com/electron/electron/blob/master/docs/api/accelerator.md).
Please see the document to know how to configure this value.
Default value is `"CmdOrCtrl+Shift+S"`. If you want to disable, please set empty string or `null`.
### `icon_color`
The color of icon in menubar. `"black"` or `"white"` can be specified.
Color of icon in menubar. `"black"` or `"white"` can be specified.
### `always_on_top`
When this value is set to `true`, the window won't be hidden if it loses a
focus. Default value is `false`.
When this value is set to `true`, the window won't be hidden if it loses a focus. Default value is `false`.
### `normal_window`
When this value is set to `true`, application will be launched as a normal
window application. If the menu bar behavior does not work for you, please set
this value to `true` to avoid it. Default value is `false` on macOS or Linux,
`true` on Windows because window position is broken in some versions of Windows.
### `hide_menu`
When this value is set to `true`, the application will be launched with the
menubar hidden, assuming `normal_window` is also true. When set to `false`, the
menubar will be visible on launch. Default value is `false`.
On Windows, typing Alt key shows the hidden menubar.
When this value is set to `true`, application will be launched as a normal window application.
If menu bar behavior does not work for you, please use set this value to `true` to avoid it.
Default value is `false` on macOS or Linux, `true` on Windows because window position is broken in some version of Windows.
### `zoom_factor`
Font zoom factor in application. It should be positive number. For example,
`0.7` means `70%` font zooming. Default font size is a bit bigger because the UI
is originally for mobile devices. So default value is `0.9`.
Font zoom factor in application. It should be positive number. For example, `0.7` means `70%` font zooming.
Default font size is a bit bigger because the UI is originally for mobile devices. So default value is `0.9`.
### `accounts`
Array of your accounts. Each element should have `"name"`, `"host"` and
`"default_page"` keys.
- `"name"` represents your screen name. If your name is `@foo` then please
specify `"foo"` for it.
- `"host"` represents a host part of URL of your mastodon instance. If you
belong to `https://mastodon.social`, please specify `mastodon.social`.
`https://` is not necessary.
- `"default_page"` is a path of the first shown page on app start. If
`/web/notifications` is specified, `https://{your host}/web/notifications`
will be opened when the app starts.
Array of your accounts. An element should has `"name"`, `"host"` and `"default_page"` keys.
`"name"` represents your screen name. `"host"` represents a host part of URL of your mastodon instance.
`"default_page"` is a page firstly shown.
You need to write up this config at first.
### `chromium_sandbox`
If `true` is specified, Chromium's native sandbox is enabled (and default value
is `true`). Sandbox provides some OS level security protection such as resource
access control like tabs in Chromium. However, sandbox also blocks plugins for
Electron application.
If `false` is specified, you can use some advanced features (user CSS and key
shortcut plugin). Before setting `false` to this value, please read and
understand [sandbox documentation in Electron repo][sandbox doc] to know what
you're doing.
Please note that this sandbox feature is different from Node.js integration in
Electron. Node.js integration is always disabled.
### `keymaps`
Object whose key is a key sequence and whose value is an action name.
| Action Name | Description | Default Key |
|-------------------|---------------------------------|-------------|
| `scroll-down` | Scroll down window | `j` |
| `scroll-up` | Scroll up window | `k` |
| `scroll-top` | Scroll up to top of window | `i` |
| `scroll-bottom` | Scroll down to bottom of window | `m` |
| `next-account` | Switch to next account | N/A |
| `prev-account` | Switch to previous account | N/A |
| `open-in-browser` | Open current page in browser | N/A |
| Action Name | Description | Default Key |
|-----------------|---------------------------------|-------------|
| `scroll-down` | Scroll down window | `j` |
| `scroll-up` | Scroll up window | `k` |
| `scroll-top` | Scroll up to top of window | `i` |
| `scroll-bottom` | Scroll down to bottom of window | `m` |
| `next-account` | Switch to next account | N/A |
| `prev-account` | Switch to previous account | N/A |
If an action name starts with `/`, it will navigate to the path. For example,
if you set `"/web/timelines/home"` to some key shortcut and you input the key,
browser will navigate page to `https://{your host}/web/timelines/home`.
Below is a default path actions. They are corresponding the position of tab
items.
| Path | Description | Default Key |
|-------------------------------|-----------------------------------|-------------|
| `/web/statuses/new` | Move to 'make a new toot' page | `1` |
| `/web/timelines/home` | Move to 'home timeline' page | `2` |
| `/web/notifications` | Move to 'notifications' page | `3` |
| `/web/timelines/public/local` | Move to 'local timeline' page | `4` |
| `/web/timelines/public` | Move to 'federated timeline' page | `5` |
| `/web/getting-started` | Move to 'getting started' page | `6` |
If an action name ends with `.js`, it will run key shortcut plugin (please see
below 'Key Shortcut Plugin' section). This plugin feature requires
`"chromium_sandbox": true`.
By default, some key shortcuts for tab items are set in addition to above table.
<details>
@ -220,7 +122,6 @@ By default, some key shortcuts for tab items are set in addition to above table.
"always_on_top": false,
"normal_window": false,
"zoom_factor": 0.9,
"chromium_sandbox": true,
"accounts": [
{
"name": "Linda_pp",
@ -259,244 +160,14 @@ By default, some key shortcuts for tab items are set in addition to above table.
## Multi account
If you add multiple accounts to the `accounts` array in `config.json`, an
`Accounts` menu item will appear in the application menu.
If you set multiple accounts to `accounts` array in `config.json`, `Accounts` menu item will appear in application menu.
![multi account menu item](https://github.com/rhysd/ss/blob/master/Mstdn/multi-account.png?raw=true)
It will show the list of your accounts. The check mark indicates the current
user. When you click menu item of non-current user, application window will be
recreated and switch page to the account.
## Key Shortcut Plugin
By specifying JavaScript file to action name in `keymaps` of `config.json`, you
can write your favorite behavior with JavaScript directly. Please note that
`"chromium_sandbox" : true` is also required (if you don't know what happens
with `"chromium_sandbox" : true`, please read the above config section).
```json
{
...
"chromium_sandbox": false,
...
"keymaps": {
"r": "hello.js"
}
}
```
The script file path is a relative path from your `Mstdn` application directory.
For example, Specifying `hello.js` will run `/Users/You/Application
Support/Mstdn/hello.js` on Mac.
The plugin script MUST export one function. The function receives a
configuration object and the current account object as its parameters. So the
above `hello.js` would look like:
```js
module.exports = function (config, account) {
alert('Hello, ' + account.name);
}
```
With above example config, typing `r` will show 'Hello, {your name}' alert
dialog. You can use any DOM APIs, Node.js's standard libraries and Electron APIs
in a plugin script.
## User CSS
When `user.css` is put in your `Mstdn` application directory, it will be loaded
automatically. To enable this feature, `"chromium_sandbox" : true` is also
required (if you don't know what happens with `"chromium_sandbox" : true`,
please read the above config section).
For example, the below `/Users/You/Application Support/Mstdn/user.css` will
change font color to red on Mac.
```css
body {
color: red !important;
}
```
## Plugin (experimental)
You can make a Node.js package which is run inner mastodon page.
Plugin is enabled if `chromium_sandbox` option is set to `false`. Please read
above configuration section before using any plugin.
### How to make a plugin
Create a `node_modules` directory in your application directory at first. And
then, please make `mstdn-plugin-hello` directory. It consists a node package.
The package name must start with `mastdn-plugin-`.
Make `package.json` manually or by `$ npm init` in the directory.
```json
{
"name": "mstdn-plugin-hello",
"version": "0.0.0",
"description": "Sample plugin of Mstdn.app",
"main": "index.js",
"author": "Your name",
"license": "Some license"
}
```
And make `index.js` as below:
```javascript
module.exports = {
preload(config, account) {
console.log('Hello from plugin!', config, account);
}
};
```
Each package must export one object. If the object has a function as a value of
`preload` key, it will be called when the page is being loaded. The function
receives two parameters `config` and `account`.
By defining `keymaps` key you can define plugin-defined key shortcut action.
```javascript
module.exports = {
preload(config, account) {
console.log('Hello from plugin!', config, account);
},
keymaps: {
'alert-hello'(event, config, account) {
event.preventDefault();
alert('Hello, ' + account.name);
}
}
};
```
The `keymaps` object has keymap action name (key) and its handler (value).
Here 'alert-hello' key shortcut action is defined. Key shortcut handler takes 3
arguments. `config` and `account` is the same as `preload`'s. `event` is a
`KeyboardEvent` browser event on the key shortcut being called. You can cancel
default event behavior using the `.preventDefault()` method.
User can specify the key shortcut as `plugin:{plugin name}:{action name}`. In
above example, `plugin:hello:alert-hello` is available in `keymaps` section in
config.json.
Note that you can use below APIs in the script.
- [Node.js standard libraries][] via `require` (e.g. `require('fs')`)
- Dependant node packages listed in `package.json` of the plugin
- [Electron Renderer APIs][Electron APIs]
- All Web APIs on browser (including DOM API)
#### !!! Security Notice !!!
Do not leak Node.js stuff to global namespace like below.
```javascript
// Never do things like this!
window.my_require = require;
```
### How to use
If you didn't try above 'How to make' section, please install plugin package at
first. Below will install 'hello' plugin to `{app
directory}/node_modules/mstdn-plugin-hello`.
```
$ cd {Your application directory}
$ npm install mstdn-plugin-hello
```
And then write what plugin should be loaded to `"plugins"` section of your
account in `config.json`. `"hello"` should be added to the list. If listed
plugin defines some keymaps, you can specify it in `keymaps` section with
`plugin:{name}:{action}` format.
```json
{
...
"accounts": [
{
...
"plugins": [
"hello"
]
}
],
"keymaps": {
...
"ctrl+h": "plugin:hello:alert-hello"
},
...
}
```
Note that you can enable different plugin for each your accounts.
Finally open Mstdn.app and open the DevTools via [Menu] -> [View] -> [Developper
Tools] -> console. If window is too small to see DevTools, please make window
size bigger.
In the developer console, the message 'Hello from plugin!', config information
and account information should be output.
### List of Plugins
To be made
## How to Debug
By setting `NODE_ENV` environment variable to `development`, Mstdn will start in
debug mode.
In debug mode, app outputs all logs to stdout and opens DevTools for a page
rendered in a window automatically. You can also see logs in 'console' tab of
DevTools for debugging renderer process.
```sh
# If you installed this app via npm or yarn
$ NODE_ENV=development open-mstdn-app
# Package on macOS
$ NODE_ENV=development /path/to/Mstdn.app/Contents/MacOS/Mstdn
# Package on Linux
$ NODE_ENV=development /path/to/Mstdn-linux-xxx-x.y.z/Mstdn
# Package on cmd.exe on Windows
$ set NODE_ENV=development
$ \path\to\Mstdn-win32-xxx-x.y.z\Mstdn.exe
```
## Contact to the Author
Please feel free to create an issue on GitHub or mention me on Mastodon/Twitter.
- [@Linda\_pp@mstdn.jp](https://mstdn.jp/@Linda_pp) (Japanese account. English
is also OK)
- [@inudog@mastodon.social](https://mastodon.social/@inudog) (English account)
It will show the list of your account. Check mark is added for current user.
When you click menu item of non-current user, application window will be recreated and switch page to the account.
[Mastodon]: https://github.com/tootsuite/mastodon
[npm]: https://www.npmjs.com/package/mstdn
[yarn]: https://yarnpkg.com/en/package/mstdn
[AUR]: https://aur.archlinux.org/packages/mstdn/
[Release page]: https://github.com/rhysd/Mstdn/releases
[Electron]: https://electron.atom.io/
[sandbox doc]: https://github.com/electron/electron/blob/master/docs/api/sandbox-option.md
[npm version badge]: https://badge.fury.io/js/mstdn.svg
[Node.js standard libraries]: https://nodejs.org/api/
[Electron APIs]: https://electron.atom.io/docs/api/
[Electron]: electron.atom.io

View File

@ -4,13 +4,7 @@ import log from './log';
import {Account} from './config';
export function partitionForAccount(account: Account) {
let host = account.host;
if (account.host.startsWith('https://')) {
host = host.slice(8);
} else if (account.host.startsWith('http://')) {
host = host.slice(7);
}
return `persist:mstdn:${account.name}:${host}`;
return `persist:mstdn:${account.name}:${account.host}`;
}
export default class AccountSwitcher extends EventEmitter {
@ -19,7 +13,7 @@ export default class AccountSwitcher extends EventEmitter {
constructor(accounts: Account[]) {
super();
const submenu = [] as Electron.MenuItemConstructorOptions[];
const submenu = [] as Electron.MenuItemOptions[];
for (const account of accounts.filter(a => a.name !== '')) {
let name = account.name;
@ -49,8 +43,7 @@ export default class AccountSwitcher extends EventEmitter {
submenu,
});
// Note: Electron's type definitions don't support nullable types yet.
const menu = Menu.getApplicationMenu() as Electron.Menu | null;
const menu = Menu.getApplicationMenu();
if (menu !== null) {
// Insert item before 'Help'
menu.insert(menu.items.length - 1, item);

View File

@ -1,6 +1,6 @@
import {app, Menu, globalShortcut, Tray} from 'electron';
import log from './log';
import {Config, Account, hostUrl} from './config';
import {Config, Account} from './config';
import AccountSwitcher from './account_switcher';
import defaultMenu from './default_menu';
import Window from './window';
@ -26,7 +26,7 @@ export class App {
start() {
const a = this.switcher.current;
const url = hostUrl(a) + a.default_page;
const url = `https://${a.host}${a.default_page}`;
this.win.open(url);
log.debug('Application started', a, url);
}
@ -36,7 +36,20 @@ export class App {
return;
}
globalShortcut.register(this.config.hot_key, () => this.win.toggle());
if (this.win.menubar) {
globalShortcut.register(this.config.hot_key, () => {
const mb = this.win.menubar!;
if (mb.window.isFocused()) {
log.debug('Toggle window: shown -> hidden');
mb.hideWindow();
} else {
log.debug('Toggle window: hidden -> shown');
mb.showWindow();
}
});
} else {
globalShortcut.register(this.config.hot_key, this.toggleNormalWindow);
}
log.debug('Hot key was set to:', this.config.hot_key);
}
@ -49,14 +62,28 @@ export class App {
const icon = trayIcon(this.config.icon_color);
const tray = new Tray(icon);
const toggle = this.win.toggle.bind(this.win);
tray.on('click', toggle);
tray.on('double-click', toggle);
tray.on('click', this.toggleNormalWindow);
tray.on('double-click', this.toggleNormalWindow);
if (IS_DARWIN) {
tray.setHighlightMode('never');
}
}
private toggleNormalWindow = () => {
const win = this.win.browser;
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();
}
}
private onAccountSwitch = (next: Account) => {
this.win.close();
Window.create(next, this.config, this.win.menubar).then(win => {

View File

@ -3,13 +3,10 @@ import {app} from 'electron';
export const IS_DEBUG = process.env.NODE_ENV === 'development';
export const IS_DARWIN = process.platform === 'darwin';
export const IS_WINDOWS = process.platform === 'win32';
export const APP_ICON = path.join(__dirname, '..', 'resources', 'icon', 'icon.png');
export const PRELOAD_JS = path.join(__dirname, '..', 'renderer', 'preload.js');
export const DATA_DIR = app.getPath('userData');
export const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
export const USER_CSS = path.join(DATA_DIR, 'user.css');
export const IOS_SAFARI_USERAGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_0 like Mac OS X) AppleWebKit/602.1.38 (KHTML, like Gecko) Version/10.0 Mobile/14A300 Safari/602.1';
export function trayIcon(color: string) {
return path.join(__dirname, '..', 'resources', 'icon', `tray-icon-${

View File

@ -1,45 +1,38 @@
import {app, systemPreferences, dialog, shell} from 'electron';
import * as fs from 'fs';
import log from './log';
import {CONFIG_FILE, IS_DARWIN, IS_WINDOWS, DATA_DIR} from './common';
import {CONFIG_FILE} from './common';
export interface Account {
host: string;
name: string;
default_page: string;
plugins: string[];
}
export interface Config {
hot_key: string;
icon_color: string;
always_on_top: boolean;
hide_menu: boolean;
normal_window: boolean;
zoom_factor: number;
accounts: Account[];
chromium_sandbox: boolean;
__DATA_DIR?: string;
keymaps: {[key: string]: string};
}
function makeDefaultConfig(): Config {
const IsDarkMode = IS_DARWIN && systemPreferences.isDarkMode();
const menubarBroken = IS_WINDOWS;
const IsDarkMode = (process.platform === 'darwin') && systemPreferences.isDarkMode();
const menubarBroken = process.platform === 'win32';
return {
hot_key: 'CmdOrCtrl+Shift+Enter',
hot_key: 'CmdOrCtrl+Shift+S',
icon_color: IsDarkMode ? 'white' : 'black',
always_on_top: false,
hide_menu: false,
normal_window: menubarBroken,
zoom_factor: 0.9,
chromium_sandbox: true,
accounts: [{
name: '',
host: '',
default_page: '/web/timelines/home',
plugins: [],
}],
keymaps: {
j: 'scroll-down',
@ -73,14 +66,6 @@ function recommendConfigAndDie(file: string) {
showDyingDialog(title, detail);
}
export function hostUrl(a: Account) {
if (a.host.startsWith('https://') || a.host.startsWith('http://')) {
return a.host;
} else {
return 'https://' + a.host;
}
}
export default function loadConfig(): Promise<Config> {
return new Promise<Config>(resolve => {
fs.readFile(CONFIG_FILE, 'utf8', (err, json) => {
@ -111,10 +96,6 @@ export default function loadConfig(): Promise<Config> {
if (!config.accounts || config.accounts[0].host === '' || config.accounts[0].name === '') {
recommendConfigAndDie(CONFIG_FILE);
} else {
config.__DATA_DIR = DATA_DIR;
if (config.chromium_sandbox === undefined) {
config.chromium_sandbox = true;
}
resolve(config);
}
} catch (e) {

View File

@ -2,7 +2,7 @@ import * as path from 'path';
import {Menu, shell, app} from 'electron';
export default function defaultMenu() {
const template: Electron.MenuItemConstructorOptions[] = [
const template: Electron.MenuItemOptions[] = [
{
label: 'Edit',
submenu: [
@ -138,7 +138,7 @@ export default function defaultMenu() {
]
});
(template[1].submenu as Electron.MenuItemConstructorOptions[]).push(
(template[1].submenu as Electron.MenuItemOptions[]).push(
{
type: 'separator'
},

View File

@ -1,5 +1,4 @@
import {app, dialog} from 'electron';
import * as contextMenu from 'electron-context-menu';
import {app} from 'electron';
import log from './log';
import startApp from './app';
import loadConfig from './config';
@ -8,22 +7,10 @@ import loadConfig from './config';
// loading this application. We need to disable the callback.
app.removeAllListeners();
contextMenu();
const appReady = new Promise<void>(resolve => app.once('ready', resolve));
process.on('unhandledRejection', (reason: string) => {
const msg = 'FATAL: Unhandled rejection! Reason: ' + reason;
appReady.then(() =>
dialog.showMessageBox({
type: 'error',
buttons: ['OK'],
message: msg,
}, () => {
app.quit();
})
);
log.error(msg);
log.error('FATAL: Unhandled rejection! Reason:', reason);
});
app.on('will-quit', () => {

View File

@ -5,5 +5,6 @@ if (process.env.NODE_ENV === 'development') {
} else {
log.setLevel('info');
}
log.setLevel('debug');
export default log;

View File

@ -1,42 +1,25 @@
import * as fs from 'fs';
import {app, BrowserWindow, shell, dialog, Menu} from 'electron';
import windowState = require('electron-window-state');
import * as menubar from 'menubar';
import {Config, Account, hostUrl} from './config';
import {Config, Account} from './config';
import {partitionForAccount} from './account_switcher';
import log from './log';
import {IS_DEBUG, IS_DARWIN, IS_WINDOWS, APP_ICON, PRELOAD_JS, USER_CSS, IOS_SAFARI_USERAGENT, trayIcon} from './common';
function shouldOpenInternal(host: string, url: string): boolean {
if (host.startsWith('https://pawoo.net') && url.startsWith('https://accounts.pixiv.net/login?')) {
log.debug('accounts.pixiv.net opens and will redirect to pawoo.net for signin with Pixiv account.');
return true;
}
return false;
}
import {IS_DEBUG, IS_DARWIN, APP_ICON, PRELOAD_JS, trayIcon} from './common';
export default class Window {
static create(account: Account, config: Config, mb: Menubar.MenubarApp | null = null) {
return (config.normal_window ? startNormalWindow(account, config) : startMenuBar(account, config, mb))
.then(win => {
win.browser.webContents.on('dom-ready', () => {
applyUserCss(win.browser, config);
win.browser.webContents.setZoomFactor(config.zoom_factor);
log.debug('Send config to renderer procress');
win.browser.webContents.send('mstdn:config', config, account);
});
return win;
});
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,
/* tslint:disable:no-shadowed-variable */
public menubar: Menubar.MenubarApp | null,
/* tslint:enable:no-shadowed-variable */
) {
if (!IS_DARWIN) {
// Users can still access menu bar with pressing Alt key.
@ -44,17 +27,10 @@ export default class Window {
}
browser.webContents.on('will-navigate', (e, url) => {
const host = hostUrl(this.account);
if (url.startsWith(host)) {
return;
if (!url.startsWith(`https://${this.account.host}`)) {
e.preventDefault();
shell.openExternal(url);
}
if (shouldOpenInternal(host, url)) {
return;
}
e.preventDefault();
shell.openExternal(url);
log.debug('Opened URL with external browser (will-navigate)', url);
});
browser.webContents.on('new-window', (e, url) => {
@ -66,7 +42,7 @@ export default class Window {
browser.webContents.session.setPermissionRequestHandler((contents, permission, callback) => {
const url = contents.getURL();
const grantedByDefault =
url.startsWith(hostUrl(this.account)) &&
url.startsWith(`https://${this.account.host}`) &&
permission !== 'geolocation' &&
permission !== 'media';
@ -92,80 +68,21 @@ export default class Window {
open(url: string) {
log.debug('Open URL:', url);
this.browser.webContents.once('did-get-redirect-request', (e: Event, _: string, newUrl: string) => {
if (url === newUrl) {
return;
}
log.debug('Redirecting to ' + newUrl + '. Will navigate to login page for user using single user mode');
e.preventDefault();
this.browser.loadURL(hostUrl(this.account) + '/auth/sign_in');
});
if (url.startsWith('https://pawoo.net')) {
this.browser.loadURL(url, {
userAgent: IOS_SAFARI_USERAGENT,
});
} else {
this.browser.loadURL(url);
}
this.browser.loadURL(url);
}
close() {
log.debug('Closing window:', this.account);
this.state.saveState();
this.state.unmanage();
this.browser.webContents.removeAllListeners();
this.browser.removeAllListeners();
if (this.menubar) {
// Note:
// menubar.windowClear() won't be called because all listeners were removed
// menubar.windowClear() won't be called because all listners was removed
delete this.menubar.window;
}
this.browser.close();
}
toggle() {
if (this.menubar) {
if (this.browser.isFocused()) {
log.debug('Toggle window: shown -> hidden');
this.menubar.hideWindow();
} else {
log.debug('Toggle window: hidden -> shown');
this.menubar.showWindow();
}
} else {
if (this.browser.isFocused()) {
log.debug('Toggle window: shown -> hidden');
if (IS_DARWIN) {
app.hide();
} else if (IS_WINDOWS) {
this.browser.minimize();
} else {
this.browser.hide();
}
} else {
log.debug('Toggle window: hidden -> shown');
this.browser.show();
if (IS_WINDOWS) {
this.browser.focus();
}
}
}
}
}
function applyUserCss(win: Electron.BrowserWindow, config: Config) {
if (config.chromium_sandbox) {
log.debug('User CSS is disabled because Chromium sandbox is enabled');
return;
}
fs.readFile(USER_CSS, 'utf8', (err, css) => {
if (err) {
log.debug('Failed to load user.css: ', err.message);
return;
}
win.webContents.insertCSS(css);
log.debug('Applied user CSS:', USER_CSS);
});
}
function startNormalWindow(account: Account, config: Config): Promise<Window> {
@ -182,13 +99,13 @@ function startNormalWindow(account: Account, config: Config): Promise<Window> {
y: state.y,
icon: APP_ICON,
show: false,
autoHideMenuBar: !!config.hide_menu,
useContentSize: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
sandbox: sandboxFlag(config, account),
sandbox: true,
preload: PRELOAD_JS,
partition: partitionForAccount(account),
zoomFactor: config.zoom_factor,
},
});
win.once('ready-to-show', () => {
@ -205,6 +122,10 @@ function startNormalWindow(account: Account, config: Config): Promise<Window> {
}
state.manage(win);
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 (IS_DEBUG) {
@ -216,25 +137,11 @@ function startNormalWindow(account: Account, config: Config): Promise<Window> {
});
}
function sandboxFlag(config: Config, account: Account) {
// XXX:
// Electron has a bug that CSP prevents preload script from being loaded.
// mstdn.jp enables CSP. So currently we need to disable native sandbox to load preload script.
//
// Ref: https://github.com/electron/electron/issues/9276
//
const electronBugIssue9276 = account.host === 'mstdn.jp' || account.host === 'https://mstdn.jp';
if (electronBugIssue9276) {
return false;
}
return !!config.chromium_sandbox;
}
function startMenuBar(account: Account, config: Config, bar: Menubar.MenubarApp | null): Promise<Window> {
log.debug('Setup a menubar window');
return new Promise<Window>(resolve => {
const state = windowState({
defaultWidth: 320,
defaultWidth: 350,
defaultHeight: 420,
});
const icon = trayIcon(config.icon_color);
@ -244,15 +151,15 @@ function startMenuBar(account: Account, config: Config, bar: Menubar.MenubarApp
height: state.height,
alwaysOnTop: IS_DEBUG || !!config.always_on_top,
tooltip: 'Mstdn',
autoHideMenuBar: !!config.hide_menu,
useContentSize: true,
autoHideMenuBar: true,
show: false,
showDockIcon: true,
webPreferences: {
nodeIntegration: false,
sandbox: sandboxFlag(config, account),
sandbox: true,
preload: PRELOAD_JS,
partition: partitionForAccount(account),
zoomFactor: config.zoom_factor,
},
});
mb.once('after-create-window', () => {
@ -260,6 +167,10 @@ function startMenuBar(account: Account, config: Config, bar: Menubar.MenubarApp
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));
@ -271,7 +182,6 @@ function startMenuBar(account: Account, config: Config, bar: Menubar.MenubarApp
log.debug('Recreate menubar window with different partition:', account);
const pref = mb.getOption('webPreferences');
pref.partition = partitionForAccount(account);
pref.sandbox = sandboxFlag(config, account);
mb.setOption('webPreferences', pref);
mb.showWindow();
} else {

View File

@ -1,7 +1,7 @@
{
"name": "mstdn",
"productName": "Mstdn",
"version": "0.2.6",
"version": "0.1.1",
"description": "Tiny web-based mastodon client for your desktop",
"main": "main/index.js",
"bin": {
@ -9,12 +9,12 @@
},
"scripts": {
"build:ts": "tsc --pretty -p .",
"build:bundle": "NODE_ENV=development browserify -x electron renderer/index.js -o renderer/preload.js",
"build:bundle": "NODE_ENV=development browserify renderer/index.js -o renderer/preload.js",
"build": "npm run build:ts && npm run build:bundle",
"build:bundle:release": "NODE_ENV=production browserify -x electron renderer/index.js -o renderer/preload.js",
"build:bundle:release": "NODE_ENV=production browserify renderer/index.js -o renderer/preload.js",
"build:release": "npm run build:ts && npm run build:bundle:release",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "tslint --project tsconfig.json --type-check",
"lint": "tslint --project tsconfig.json --type-check $(git ls-files | grep -E \"\\.ts$\")",
"watch": "guard --watchdir main renderer typings",
"debug": "NODE_ENV=development electron .",
"start": "NODE_ENV=production electron .",
@ -37,20 +37,20 @@
},
"homepage": "https://github.com/rhysd/Mstdn#readme",
"devDependencies": {
"@types/electron-window-state": "^2.0.29",
"@types/loglevel": "^1.4.30",
"@types/menubar": "^5.1.4",
"@types/mousetrap": "^1.5.34",
"@types/node": "^8.0.26",
"browserify": "^14.4.0",
"electron-packager": "^9.0.0",
"npm-run-all": "^4.1.1",
"tslint": "^5.7.0",
"typescript": "^2.4.2"
"@types/electron": "^1.4.35",
"@types/electron-window-state": "^2.0.28",
"@types/loglevel": "^1.4.29",
"@types/menubar": "^5.1.3",
"@types/mousetrap": "^1.5.33",
"@types/node": "^7.0.12",
"browserify": "^14.3.0",
"electron-packager": "^8.6.0",
"npm-run-all": "^4.0.2",
"tslint": "^5.1.0",
"typescript": "^2.2.2"
},
"dependencies": {
"electron": "^1.7.5",
"electron-context-menu": "^0.9.1",
"electron": "^1.6.2",
"electron-window-state": "^4.1.1",
"loglevel": "^1.4.1",
"menubar": "github:rhysd/menubar#mstdn",

View File

@ -1,11 +1,81 @@
import * as Mousetrap from 'mousetrap';
import {Config, Account} from '../main/config';
import * as Ipc from './ipc';
import setupKeymaps from './key_handler';
import PluginsLoader from './plugins';
import log from './log';
function scrollable() {
const scrollable = document.querySelector('.scrollable');
if (!scrollable) {
log.error('Scrollable element was not found!');
return {scrollTop: 0};
}
return scrollable;
}
function navigateTo(host: string, path: string) {
const url = `https://${host}${path}`;
if (window.location.href === url) {
log.info('Current URL is already', url);
return;
}
const link = document.querySelector(`a[href="${path}"]`);
if (link) {
log.info('Click link by shortcut', path);
(link as HTMLAnchorElement).click();
} else {
log.info('Force navigation by shortcut', path);
window.location.href = url;
}
}
const ShortcutActions = {
'scroll-top': () => {
scrollable().scrollTop = 0;
},
'scroll-bottom': () => {
scrollable().scrollTop = document.body.scrollHeight;
},
'scroll-down': () => {
scrollable().scrollTop += window.innerHeight / 3;
},
'scroll-up': () => {
scrollable().scrollTop -= window.innerHeight / 3;
},
'next-account': () => {
Ipc.send('mstdn:next-account');
},
'prev-account': () => {
Ipc.send('mstdn:prev-account');
},
} as {[action: string]: () => void};
function setupKeybinds(keybinds: {[key: string]: string}, host: string) {
for (const key in keybinds) {
const action = keybinds[key];
if (action.startsWith('/')) {
Mousetrap.bind(key, e => {
e.preventDefault();
navigateTo(host, action);
});
} else {
const func = ShortcutActions[action];
if (!func) {
log.error('Unknown shortcut action:', action);
continue;
}
Mousetrap.bind(key, e => {
log.info('Shortcut:', action);
e.preventDefault();
func();
});
}
}
}
let config: Config | null = null;
Ipc.on('mstdn:config', (c: Config, a: Account) => {
const loader = new PluginsLoader(c, a);
loader.loadAfterAppPrepared();
setupKeymaps(c, a, loader);
document.title = `${document.title} @${a.name}@${a.host}`;
config = c;
setupKeybinds(config.keymaps, a.host);
});

View File

@ -1,8 +1,10 @@
import {ipcRenderer as ipc} from 'electron';
import log from './log';
import r from './require';
const electron = r('electron');
const ipc = electron.ipcRenderer;
export function on(channel: IpcChannelFromMain, callback: (...args: any[]) => void) {
ipc.on(channel, (_: any, ...args: any[]) => {
ipc.on(channel, (_, ...args: any[]) => {
log.info('IPC: Received from:', channel, args);
callback(...args);
});

View File

@ -1,121 +0,0 @@
import {join} from 'path';
import {remote} from 'electron';
import * as Mousetrap from 'mousetrap';
import * as Ipc from './ipc';
import log from './log';
import PluginsLoader from './plugins';
import {Config, Account} from '../main/config';
function scrollable(pred: (elem: HTMLElement) => void) {
const scrollables = document.querySelectorAll('.scrollable') as NodeListOf<HTMLElement>;
if (scrollables.length === 0) {
log.error('Scrollable element was not found!');
return;
}
for (const elem of scrollables) {
pred(elem);
}
}
function navigateTo(host: string, path: string) {
const url = `https://${host}${path}`;
if (window.location.href === url) {
log.info('Current URL is already', url);
return;
}
const link = document.querySelector(`a[href="${path}"]`);
if (link) {
log.info('Click link by shortcut', path);
(link as HTMLAnchorElement).click();
} else {
log.info('Force navigation by shortcut', path);
window.location.href = url;
}
}
const ShortcutActions = {
'scroll-top': () => {
scrollable(s => { s.scrollTop = 0; });
},
'scroll-bottom': () => {
scrollable(s => { s.scrollTop = document.body.scrollHeight; });
},
'scroll-down': () => {
scrollable(s => { s.scrollTop += window.innerHeight / 3; });
},
'scroll-up': () => {
scrollable(s => { s.scrollTop -= window.innerHeight / 3; });
},
'next-account': () => {
Ipc.send('mstdn:next-account');
},
'prev-account': () => {
Ipc.send('mstdn:prev-account');
},
'open-in-browser': () => {
remote.shell.openExternal(window.location.href);
}
} as {[action: string]: () => void};
export default function setupKeymaps(
config: Config,
account: Account,
loader: PluginsLoader,
) {
const dataDir = config.__DATA_DIR || '/';
for (const key in config.keymaps) {
const action = config.keymaps[key];
if (action.endsWith('.js')) {
if (config.chromium_sandbox) {
log.info('Loading external script is limited because Chromium sandbox is enabled. Disable shortcut:', action);
continue;
}
const script = join(dataDir, action);
let plugin: (c: Config, a: Account) => void;
try {
plugin = require(script);
} catch (e) {
log.error('Error while loading plugin ' + script, e);
continue;
}
Mousetrap.bind(key, e => {
e.preventDefault();
log.info('Shortcut:', action);
try {
plugin(config, account);
} catch (e) {
log.error('Failed to run shortcut script ' + script, e);
}
});
} else if (action.startsWith('plugin:')) {
// Format is 'plugin:{name}:{action}'
const split = action.split(':').slice(1);
if (split.length <= 1) {
log.error('Invalid format keymap. Plugin-defined action should be "plugin:{name}:{action}":', action);
continue;
}
Mousetrap.bind(key, e => {
loader.runKeyShortcut(e, split[0], split[1]);
});
} else if (action.startsWith('/')) {
Mousetrap.bind(key, e => {
e.preventDefault();
navigateTo(account.host, action);
});
} else {
const func = ShortcutActions[action];
if (!func) {
log.error('Unknown shortcut action:', action);
continue;
}
Mousetrap.bind(key, e => {
log.info('Shortcut:', action);
e.preventDefault();
func();
});
}
}
}

View File

@ -1,119 +0,0 @@
import * as path from 'path';
import {Config, Account} from '../main/config';
import log from './log';
interface Plugin {
keymaps?: {
[action: string]: (e: KeyboardEvent, c: Config, a: Account) => void;
};
preload?(c: Config, a: Account): void;
}
interface Plugins {
[module_path: string]: Plugin;
}
export default class PluginsLoader {
preloads: Plugins;
loaded: boolean;
constructor(private config: Config, private account: Account) {
this.loaded = false;
this.preloads = {};
if (config.chromium_sandbox) {
log.info('Chromium sandbox is enabled. Plugin is disabled.');
return;
}
const dir_base = path.join(config.__DATA_DIR!, 'node_modules');
for (const plugin of this.account.plugins || []) {
const plugin_path = path.join(dir_base, `mstdn-plugin-${plugin}`);
try {
this.preloads[plugin_path] = require(plugin_path) as Plugin;
} catch (e) {
log.error(`Failed to load plugin ${plugin_path}:`, e);
}
}
}
loadAfterAppPrepared() {
if (Object.keys(this.preloads).length === 0) {
log.info('No Plugin found. Skip loading');
this.loaded = true;
return;
}
return new Promise<void>(resolve => {
// In order not to prevent application's initial loading, load preload plugins
// on an idle callback.
window.requestIdleCallback(() => {
log.debug('Start loading plugins', this.preloads);
if (this.tryLoading()) {
return resolve();
}
this.observeAppPrepared(this.tryLoading).then(resolve);
});
});
}
observeAppPrepared(callback: () => void) {
// TODO:
// Make an instance of MutationObserver to observe React root.
// But it may be unnecessary because application shell is rendered
// in server side.
return Promise.resolve(callback());
}
tryLoading = () => {
if (document.querySelector('[data-react-class="Mastodon"]') === null) {
log.info('Root element of react app was not found. App seems not to be loaded yet.');
return false;
}
for (const key in this.preloads) {
const f = this.preloads[key].preload;
if (!f) {
log.info('Plugin does not have preload function. Skipped:', key);
continue;
}
try {
f(this.config, this.account);
} catch (e) {
log.error(`Error while loading plugin '${key}':`, e);
}
}
log.info('Plugins were loaded:', this.preloads);
return true;
}
findPluginByName(name: string): Plugin | null {
const pluginName = `mstdn-plugin-${name}`;
for (const p in this.preloads) {
if (p.endsWith(pluginName)) {
return this.preloads[p];
}
}
return null;
}
runKeyShortcut(event: KeyboardEvent, name: string, action: string) {
const plugin = this.findPluginByName(name);
if (plugin === null) {
log.error(`While trying to execute key shortcut '${action}', plugin for '${name}' not found:`, this.preloads);
return;
}
const f = (plugin.keymaps || {})[action];
if (!f) {
log.error(`There is no key shortcut action '${action}' in plugin '${name}'`, plugin);
return;
}
try {
f(event, this.config, this.account);
} catch (e) {
log.error(`Error while executing plugin-defined key short action: ${action} with plugin '${name}'`, e);
}
}
}

4
renderer/require.ts Normal file
View File

@ -0,0 +1,4 @@
// In preload script, global.require does not exist.
// So we need to wrap require() to avoid to be bundled.
export default require;

View File

@ -24,9 +24,9 @@ function pack-app() {
local electron_version=$(electron --version)
electron_version=${electron_version#v}
electron-packager ./app --platform=darwin --arch=x64 "--app-copyright=copyright (c) 2017 rhysd" --app-version=$version --build-version=$version --icon=./resources/icon/icon.icns --electron-version=$electron_version
electron-packager ./app --platform=linux --arch=all "--app-copyright=copyright (c) 2017 rhysd" --app-version=$version --build-version=$version --icon=./resources/icon/icon.ico --electron-version=$electron_version
electron-packager ./app --platform=win32 --arch=all "--app-copyright=copyright (c) 2017 rhysd" --app-version=$version --build-version=$version --icon=./resources/icon/icon.ico --electron-version=$electron_version --version-string=$version
electron-packager ./app --platform=darwin --arch=x64 "--app-copyright=copyright (c) 2017 rhysd" --app-version=$version --build-version=$version --icon=./resources/icon.icns --electron-version=$electron_version
electron-packager ./app --platform=linux --arch=all "--app-copyright=copyright (c) 2017 rhysd" --app-version=$version --build-version=$version --icon=./resources/icon.ico --electron-version=$electron_version
electron-packager ./app --platform=win32 --arch=all "--app-copyright=copyright (c) 2017 rhysd" --app-version=$version --build-version=$version --icon=./resources/icon.ico --electron-version=$electron_version --version-string=$version
}
function make-dist() {
@ -40,12 +40,10 @@ function make-dist() {
cp LICENSE README.md "$dir"
zip --symlinks "dist/${dir}-${version}.zip" -r "$dir"
done
rm -rf Mstdn-*
rm -r Mstdn-*
open dist
}
export PATH=./node_modules/.bin:$PATH
prepare-app
pack-app
make-dist

View File

@ -11,7 +11,7 @@
"noUnusedParameters": true,
"noEmitOnError": true,
"strictNullChecks": true,
"target": "es2016",
"target": "es2015",
"sourceMap": true
},
"include": [

6
typings/browserify_workaround.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare namespace NodeJS {
interface Global {
require: NodeRequireFunction;
}
}

View File

@ -1,21 +0,0 @@
declare namespace ElectronContextMenu {
interface Options {
window?: Electron.BrowserWindow | Electron.WebviewTag;
showInspectElement?: boolean;
labels?: {
cut?: string;
copy?: string;
paste?: string;
save?: string;
copyLink?: string;
inspect?: string;
};
append?(...args: any[]): any;
prepend?(params: any, win: Electron.BrowserWindow): Electron.MenuItem[];
shouldShowMenu?(event: Event, params: any): boolean;
}
}
declare module 'electron-context-menu' {
const create: (options?: ElectronContextMenu.Options) => void;
export = create;
}

View File

@ -1,15 +0,0 @@
interface RequestIdleCallback {
didTimeout?: boolean;
timeRemaining?(): number;
}
interface RequestIdleCallbackOptions {
timeout?: number;
}
type RequestIdleCallbackId = number;
interface Window {
requestIdleCallback(cb: (deadline: RequestIdleCallback) => any, options?: RequestIdleCallbackOptions): RequestIdleCallbackId;
cancelIdleCallback(id: RequestIdleCallbackId): void;
}