1
0
mirror of https://github.com/rhysd/Mstdn.git synced 2025-04-05 22:57:36 +02:00

Compare commits

...

71 Commits

Author SHA1 Message Date
rhysd
9789c367ee v0.2.6 2017-08-31 19:49:43 +09:00
rhysd
f96d3fdcab improve type definitions 2017-08-30 16:15:13 +09:00
rhysd
496cacb8c8 update dependencies and fix lint errors 2017-08-30 12:40:43 +09:00
rhysd
33c3b4df5f update Electron to v1.7.5 (use official type definitions) 2017-08-30 12:33:49 +09:00
rhysd
1a7745345b update dependencies and avoid Electron's official type definitions 2017-06-20 17:39:07 +09:00
rhysd
150b42bcfb describe more about menubar window (#29) 2017-06-20 17:21:54 +09:00
Sumner Evans
10754d1305 README updates (#26)
* README updates

* added AUR link

* Update from PR
2017-05-29 12:00:18 +09:00
Sumner Evans
262e839e25 Fixed Electron link on README (#25) 2017-05-28 12:18:48 +09:00
rhysd
ec6ecdf337 fix linter errors and bump version (0.2.5) 2017-05-08 19:29:49 +09:00
rhysd
6cf895a13c fix scrolling timeline view 2017-05-08 19:27:28 +09:00
rhysd
02a406b821 update dependencies 2017-05-08 02:13:56 +09:00
Linda_pp
070924b707 add notes to issue template 2017-05-06 17:14:41 +09:00
rhysd
8a4b8a1dda update issue template 2017-05-04 11:33:30 +09:00
Linda_pp
b272c2cb97 Merge pull request #22 from algernon/f/context-menu
Add a context menu
2017-05-04 00:30:57 +09:00
Gergely Nagy
5163f82b34 Add a context menu
Fixes #21.

Signed-off-by: Gergely Nagy <algernon@madhouse-project.org>
2017-05-03 17:27:39 +02:00
rhysd
8f60edd353 open external page with external browser again (fix #14)
I confirmed the bug was fixed with Windows 8.1
2017-05-02 18:15:59 +09:00
rhysd
84b740839b fix login doc 2017-05-01 18:21:49 +09:00
rhysd
777d96e92e v0.2.4 2017-05-01 18:15:13 +09:00
rhysd
754ca9e7c1 enable login with Pixiv account on pawoo.net 2017-05-01 18:12:19 +09:00
rhysd
e03537f4f8 add notice about login with other service account 2017-05-01 03:55:51 +09:00
rhysd
bfbb758ff7 give plugins the ability to make custom key shortcuts 2017-04-26 15:52:57 +09:00
rhysd
f01dea80ea change default hot key to CmdOrCtrl+Shift+Enter (#17) 2017-04-26 10:43:02 +09:00
rhysd
92c27957f7 fix sandbox flag on switching menubar window 2017-04-25 23:33:32 +09:00
rhysd
acedcf750b remove browserify workaround 2017-04-25 17:37:10 +09:00
rhysd
3d4237e677 move plugins config into account config 2017-04-25 17:30:18 +09:00
rhysd
73c0a73e14 use minimize() instead of hide() on Windows
to restore window state correctly.

https://twitter.com/raa0121/status/856701302285287424
2017-04-25 16:46:20 +09:00
rhysd
282b24d7b0 manage focus on toggling window on Windows 2017-04-25 16:46:20 +09:00
Linda_pp
840ecde8ef Merge pull request #16 from nicexe/patch-1
Added options for installation with yarn
2017-04-25 14:00:27 +09:00
Nicolas Tsagarides
6184a3999f Added options for installation with yarn 2017-04-25 00:41:49 +03:00
rhysd
f1059130be v0.2.3 2017-04-25 01:29:16 +09:00
rhysd
79a096b604 add workaround for bug of sandbox and CSP in Electron 2017-04-25 01:27:07 +09:00
rhysd
969df0ca60 describe more detail about window modes in README (#15) 2017-04-24 22:46:27 +09:00
rhysd
506055084a fix type definitions for RequestIdleCallback 2017-04-24 18:35:58 +09:00
rhysd
f8e7d64b08 load preload plugins after app gets idling 2017-04-24 18:32:42 +09:00
rhysd
da55de25d1 do not see protocol scheme for partitions 2017-04-23 23:12:11 +09:00
rhysd
5429c0150c allow host to contain 'https://' or 'http://' 2017-04-23 23:04:03 +09:00
rhysd
aa6d196f2b move GitHub things to .github 2017-04-23 22:37:35 +09:00
rhysd
88cf44e01a add contribution guideline and minimal issue template 2017-04-23 22:34:24 +09:00
rhysd
2bf5bcc2a1 describe how to debug app 2017-04-23 22:25:15 +09:00
rhysd
ffdab65515 describe how to write up "accounts" section (#4) 2017-04-23 18:03:45 +09:00
rhysd
64ab056fa9 0.2.2 2017-04-21 20:00:50 +09:00
rhysd
49c45619a5 do not use content size (fix #10) 2017-04-21 20:00:42 +09:00
rhysd
1e7df65f57 basic plugin implementation 2017-04-21 20:00:41 +09:00
rhysd
bf069f58fc add more information to doc
- add npm badge
- add default path actions
- add how to contact to me
2017-04-21 11:43:43 +09:00
rhysd
d70f264d46 do not navigate to login page if destination of redirect is expected page 2017-04-20 23:41:25 +09:00
rhysd
9c3f0fe970 detect single user mode at first loading (#5) 2017-04-20 23:39:18 +09:00
rhysd
cca0475686 fix cleanup temporary directory after making zips 2017-04-20 23:12:32 +09:00
rhysd
fa4b99871c 0.2.1 2017-04-20 14:48:33 +09:00
rhysd
241b97f0b4 add 'open-in-browser' key shortcut 2017-04-20 14:47:48 +09:00
rhysd
dad67cd8d4 fix setting zoom factor 2017-04-20 14:00:57 +09:00
rhysd
4dbe2b7f08 fix released apps' icons 2017-04-20 12:32:45 +09:00
rhysd
734ccc4d86 0.2.0 2017-04-20 12:23:31 +09:00
rhysd
04aab35ba2 remember window state while switching account 2017-04-20 12:22:45 +09:00
rhysd
7746e26338 set chromium_sandbox to true by default 2017-04-20 12:11:10 +09:00
rhysd
26911d441a describe chromium_sandbox, key shortcut plugin and user.css 2017-04-20 12:06:46 +09:00
rhysd
ae833be107 implement key shortcut plugin 2017-04-20 11:47:44 +09:00
rhysd
8bb5b78ef9 implement user CSS 2017-04-20 11:09:57 +09:00
rhysd
0f449b8b2b show app crash with dialog and die 2017-04-20 10:48:29 +09:00
rhysd
d2315a576b 0.1.3 2017-04-20 01:33:28 +09:00
rhysd
969c8cee21 IPC workaround is no longer needed with Electron 1.6.5 2017-04-20 01:32:18 +09:00
rhysd
80eb09877f tiny followup for a74b4f3 2017-04-20 01:30:25 +09:00
rhysd
5aebef7619 Merge branch 'master' of github.com:rhysd/Mstdn 2017-04-20 01:28:01 +09:00
Linda_pp
a74b4f38cf Merge pull request #2 from algernon/f/autohide-menubar
Add a way to launch with the menubar hidden & normal window
2017-04-20 00:59:22 +09:00
Gergely Nagy
2f12c63d93 Rename config.menubar to config.hide_menu
Signed-off-by: Gergely Nagy <algernon@madhouse-project.org>
2017-04-19 17:45:45 +02:00
rhysd
c9c74dcf6b disable debug log by default... 2017-04-20 00:26:54 +09:00
rhysd
e323625cca fix IPC event handling on macOS 2017-04-20 00:21:23 +09:00
rhysd
4169bcf902 avoid crash problem at preventing 'new-window' event (#1)
https://github.com/electron/electron/issues/9230
2017-04-20 00:07:12 +09:00
Gergely Nagy
f67c6f15e8 Add a way to launch with the menubar hidden & normal window
I prefer to launch the application with a normal window, yet, the
menubar on auto-hide, because most of the time, it's just taking up
space and serves no purpose. This patch introduces a `config.menubar`
setting that makes this possible. Defaults to `true` to preserve the
current behaviour, and is only used when `normal_window` is also used.

Signed-off-by: Gergely Nagy <algernon@madhouse-project.org>
2017-04-19 16:39:54 +02:00
rhysd
952be70e4d 0.1.2 2017-04-19 20:58:53 +09:00
rhysd
edfe81e06a do not crash on external link on Windows 2017-04-19 20:56:54 +09:00
rhysd
1fd663a93c do not hide app menu on Windows 2017-04-19 18:07:12 +09:00
23 changed files with 880 additions and 228 deletions

13
.github/CONTRIBUTING.md vendored Normal file
View File

@ -0,0 +1,13 @@
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.

9
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,9 @@
- 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,3 +12,4 @@
*.ts
*.js.map
/typings
/.github

417
README.md
View File

@ -1,24 +1,30 @@
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 shortcut keybinds
- [x] Customizable (and pluggable) 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 go out from a browser. So this small tool
provides a way to do 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.
## Installation
@ -28,29 +34,66 @@ provides a way to do it.
$ npm install -g mstdn
```
### Via [yarn][]
```
$ yarn global add mstdn --prefix /usr/local
```
### As an isolated app
Download a package archive from [Release page][], put unarchived app to proper place, and open it.
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.
## Usage
If you installed this app via npm, below command is available to start app.
If you installed this app via npm or yarn, the following command is available to
start app:
```
$ open-mstdn-app
```
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"`.
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.
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).
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).
Supported platforms are macOS (confirmed with 10.12), Linux (hopefully) and Windows (hopefully).
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.
## Customization
Mstdn can be customized with JSON config file at `{app dir}/config.json`
Mstdn can be customized using the JSON config file at `{app dir}/config.json`
The `{app dir}` is:
@ -58,59 +101,114 @@ The `{app dir}` is:
- `~/.config/Mstdn` for Linux
- `%APPDATA%\Mstdn` for Windows.
The JSON file can contain below key-values:
The JSON file can contain the following key-values:
### `hot_key`
`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`.
`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`.
### `icon_color`
Color of icon in menubar. `"black"` or `"white"` can be specified.
The 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 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.
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.
### `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. 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.
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.
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 |
| 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 |
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>
@ -122,6 +220,7 @@ 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",
@ -160,14 +259,244 @@ By default, some key shortcuts for tab items are set in addition to above table.
## Multi account
If you set multiple accounts to `accounts` array in `config.json`, `Accounts` menu item will appear in application menu.
If you add multiple accounts to the `accounts` array in `config.json`, an
`Accounts` menu item will appear in the 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 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.
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)
[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]: electron.atom.io
[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/

View File

@ -4,7 +4,13 @@ import log from './log';
import {Account} from './config';
export function partitionForAccount(account: Account) {
return `persist:mstdn:${account.name}:${account.host}`;
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}`;
}
export default class AccountSwitcher extends EventEmitter {
@ -13,7 +19,7 @@ export default class AccountSwitcher extends EventEmitter {
constructor(accounts: Account[]) {
super();
const submenu = [] as Electron.MenuItemOptions[];
const submenu = [] as Electron.MenuItemConstructorOptions[];
for (const account of accounts.filter(a => a.name !== '')) {
let name = account.name;
@ -43,7 +49,8 @@ export default class AccountSwitcher extends EventEmitter {
submenu,
});
const menu = Menu.getApplicationMenu();
// Note: Electron's type definitions don't support nullable types yet.
const menu = Menu.getApplicationMenu() as Electron.Menu | null;
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} from './config';
import {Config, Account, hostUrl} 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 = `https://${a.host}${a.default_page}`;
const url = hostUrl(a) + a.default_page;
this.win.open(url);
log.debug('Application started', a, url);
}
@ -36,20 +36,7 @@ export class App {
return;
}
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);
}
globalShortcut.register(this.config.hot_key, () => this.win.toggle());
log.debug('Hot key was set to:', this.config.hot_key);
}
@ -62,28 +49,14 @@ export class App {
const icon = trayIcon(this.config.icon_color);
const tray = new Tray(icon);
tray.on('click', this.toggleNormalWindow);
tray.on('double-click', this.toggleNormalWindow);
const toggle = this.win.toggle.bind(this.win);
tray.on('click', toggle);
tray.on('double-click', toggle);
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,10 +3,13 @@ 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,38 +1,45 @@
import {app, systemPreferences, dialog, shell} from 'electron';
import * as fs from 'fs';
import log from './log';
import {CONFIG_FILE} from './common';
import {CONFIG_FILE, IS_DARWIN, IS_WINDOWS, DATA_DIR} 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 = (process.platform === 'darwin') && systemPreferences.isDarkMode();
const menubarBroken = process.platform === 'win32';
const IsDarkMode = IS_DARWIN && systemPreferences.isDarkMode();
const menubarBroken = IS_WINDOWS;
return {
hot_key: 'CmdOrCtrl+Shift+S',
hot_key: 'CmdOrCtrl+Shift+Enter',
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',
@ -66,6 +73,14 @@ 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) => {
@ -96,6 +111,10 @@ 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.MenuItemOptions[] = [
const template: Electron.MenuItemConstructorOptions[] = [
{
label: 'Edit',
submenu: [
@ -138,7 +138,7 @@ export default function defaultMenu() {
]
});
(template[1].submenu as Electron.MenuItemOptions[]).push(
(template[1].submenu as Electron.MenuItemConstructorOptions[]).push(
{
type: 'separator'
},

View File

@ -1,4 +1,5 @@
import {app} from 'electron';
import {app, dialog} from 'electron';
import * as contextMenu from 'electron-context-menu';
import log from './log';
import startApp from './app';
import loadConfig from './config';
@ -7,10 +8,22 @@ 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) => {
log.error('FATAL: Unhandled rejection! Reason:', reason);
const msg = 'FATAL: Unhandled rejection! Reason: ' + reason;
appReady.then(() =>
dialog.showMessageBox({
type: 'error',
buttons: ['OK'],
message: msg,
}, () => {
app.quit();
})
);
log.error(msg);
});
app.on('will-quit', () => {

View File

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

View File

@ -1,25 +1,42 @@
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} from './config';
import {Config, Account, hostUrl} from './config';
import {partitionForAccount} from './account_switcher';
import log from './log';
import {IS_DEBUG, IS_DARWIN, APP_ICON, PRELOAD_JS, trayIcon} from './common';
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;
}
export default class Window {
static create(account: Account, config: Config, mb: Menubar.MenubarApp | null = null) {
if (config.normal_window) {
return startNormalWindow(account, config);
} else {
return startMenuBar(account, config, mb);
}
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;
});
}
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.
@ -27,10 +44,17 @@ export default class Window {
}
browser.webContents.on('will-navigate', (e, url) => {
if (!url.startsWith(`https://${this.account.host}`)) {
e.preventDefault();
shell.openExternal(url);
const host = hostUrl(this.account);
if (url.startsWith(host)) {
return;
}
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) => {
@ -42,7 +66,7 @@ export default class Window {
browser.webContents.session.setPermissionRequestHandler((contents, permission, callback) => {
const url = contents.getURL();
const grantedByDefault =
url.startsWith(`https://${this.account.host}`) &&
url.startsWith(hostUrl(this.account)) &&
permission !== 'geolocation' &&
permission !== 'media';
@ -68,21 +92,80 @@ export default class Window {
open(url: string) {
log.debug('Open URL:', url);
this.browser.loadURL(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);
}
}
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 listners was removed
// menubar.windowClear() won't be called because all listeners were 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> {
@ -99,13 +182,13 @@ function startNormalWindow(account: Account, config: Config): Promise<Window> {
y: state.y,
icon: APP_ICON,
show: false,
useContentSize: true,
autoHideMenuBar: true,
autoHideMenuBar: !!config.hide_menu,
webPreferences: {
nodeIntegration: false,
sandbox: true,
sandbox: sandboxFlag(config, account),
preload: PRELOAD_JS,
partition: partitionForAccount(account),
zoomFactor: config.zoom_factor,
},
});
win.once('ready-to-show', () => {
@ -122,10 +205,6 @@ 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) {
@ -137,11 +216,25 @@ 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: 350,
defaultWidth: 320,
defaultHeight: 420,
});
const icon = trayIcon(config.icon_color);
@ -151,15 +244,15 @@ function startMenuBar(account: Account, config: Config, bar: Menubar.MenubarApp
height: state.height,
alwaysOnTop: IS_DEBUG || !!config.always_on_top,
tooltip: 'Mstdn',
useContentSize: true,
autoHideMenuBar: true,
autoHideMenuBar: !!config.hide_menu,
show: false,
showDockIcon: true,
webPreferences: {
nodeIntegration: false,
sandbox: true,
sandbox: sandboxFlag(config, account),
preload: PRELOAD_JS,
partition: partitionForAccount(account),
zoomFactor: config.zoom_factor,
},
});
mb.once('after-create-window', () => {
@ -167,10 +260,6 @@ 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));
@ -182,6 +271,7 @@ 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.1.1",
"version": "0.2.6",
"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 renderer/index.js -o renderer/preload.js",
"build:bundle": "NODE_ENV=development browserify -x electron renderer/index.js -o renderer/preload.js",
"build": "npm run build:ts && npm run build:bundle",
"build:bundle:release": "NODE_ENV=production browserify renderer/index.js -o renderer/preload.js",
"build:bundle:release": "NODE_ENV=production browserify -x electron 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 $(git ls-files | grep -E \"\\.ts$\")",
"lint": "tslint --project tsconfig.json --type-check",
"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": "^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"
"@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"
},
"dependencies": {
"electron": "^1.6.2",
"electron": "^1.7.5",
"electron-context-menu": "^0.9.1",
"electron-window-state": "^4.1.1",
"loglevel": "^1.4.1",
"menubar": "github:rhysd/menubar#mstdn",

View File

@ -1,81 +1,11 @@
import * as Mousetrap from 'mousetrap';
import {Config, Account} from '../main/config';
import * as Ipc from './ipc';
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;
import setupKeymaps from './key_handler';
import PluginsLoader from './plugins';
Ipc.on('mstdn:config', (c: Config, a: Account) => {
config = c;
setupKeybinds(config.keymaps, a.host);
const loader = new PluginsLoader(c, a);
loader.loadAfterAppPrepared();
setupKeymaps(c, a, loader);
document.title = `${document.title} @${a.name}@${a.host}`;
});

View File

@ -1,10 +1,8 @@
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, (_, ...args: any[]) => {
ipc.on(channel, (_: any, ...args: any[]) => {
log.info('IPC: Received from:', channel, args);
callback(...args);
});

121
renderer/key_handler.ts Normal file
View File

@ -0,0 +1,121 @@
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();
});
}
}
}

119
renderer/plugins.ts Normal file
View File

@ -0,0 +1,119 @@
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);
}
}
}

View File

@ -1,4 +0,0 @@
// 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.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
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
}
function make-dist() {
@ -40,10 +40,12 @@ function make-dist() {
cp LICENSE README.md "$dir"
zip --symlinks "dist/${dir}-${version}.zip" -r "$dir"
done
rm -r Mstdn-*
rm -rf 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": "es2015",
"target": "es2016",
"sourceMap": true
},
"include": [

View File

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

21
typings/electron-context-menu.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
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;
}

15
typings/request-idle-callback.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
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;
}