mirror of
https://github.com/rhysd/Mstdn.git
synced 2025-04-06 23:57:38 +02:00
Compare commits
58 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
9789c367ee | ||
|
f96d3fdcab | ||
|
496cacb8c8 | ||
|
33c3b4df5f | ||
|
1a7745345b | ||
|
150b42bcfb | ||
|
10754d1305 | ||
|
262e839e25 | ||
|
ec6ecdf337 | ||
|
6cf895a13c | ||
|
02a406b821 | ||
|
070924b707 | ||
|
8a4b8a1dda | ||
|
b272c2cb97 | ||
|
5163f82b34 | ||
|
8f60edd353 | ||
|
84b740839b | ||
|
777d96e92e | ||
|
754ca9e7c1 | ||
|
e03537f4f8 | ||
|
bfbb758ff7 | ||
|
f01dea80ea | ||
|
92c27957f7 | ||
|
acedcf750b | ||
|
3d4237e677 | ||
|
73c0a73e14 | ||
|
282b24d7b0 | ||
|
840ecde8ef | ||
|
6184a3999f | ||
|
f1059130be | ||
|
79a096b604 | ||
|
969df0ca60 | ||
|
506055084a | ||
|
f8e7d64b08 | ||
|
da55de25d1 | ||
|
5429c0150c | ||
|
aa6d196f2b | ||
|
88cf44e01a | ||
|
2bf5bcc2a1 | ||
|
ffdab65515 | ||
|
64ab056fa9 | ||
|
49c45619a5 | ||
|
1e7df65f57 | ||
|
bf069f58fc | ||
|
d70f264d46 | ||
|
9c3f0fe970 | ||
|
cca0475686 | ||
|
fa4b99871c | ||
|
241b97f0b4 | ||
|
dad67cd8d4 | ||
|
4dbe2b7f08 | ||
|
734ccc4d86 | ||
|
04aab35ba2 | ||
|
7746e26338 | ||
|
26911d441a | ||
|
ae833be107 | ||
|
8bb5b78ef9 | ||
|
0f449b8b2b |
13
.github/CONTRIBUTING.md
vendored
Normal file
13
.github/CONTRIBUTING.md
vendored
Normal 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
9
.github/ISSUE_TEMPLATE.md
vendored
Normal 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 -->
|
||||||
|
|
@ -12,3 +12,4 @@
|
|||||||
*.ts
|
*.ts
|
||||||
*.js.map
|
*.js.map
|
||||||
/typings
|
/typings
|
||||||
|
/.github
|
||||||
|
409
README.md
409
README.md
@ -1,24 +1,30 @@
|
|||||||
Web-based Desktop Client for [Mastodon][]
|
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"/>
|
<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:
|
Features:
|
||||||
|
|
||||||
- [x] Small window on your menubar (or isolated normal window)
|
- [x] Small window on your menubar (or isolated normal window)
|
||||||
- [x] Desktop notification
|
- [x] Desktop notification
|
||||||
- [x] Customizable shortcut keybinds
|
- [x] Customizable (and pluggable) shortcut keybinds
|
||||||
- [x] Multi-account (switching among accounts)
|
- [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,
|
Mastodon is an open source project. So if you want to make a new UI, you can
|
||||||
implement your favorite UI and host it on your place. Then you can participate Mastodon networks from it.
|
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
|
However, Mastodon is a web application. So we can't use it outside of a browser.
|
||||||
provides a way to do it.
|
This small tool provides the ability to use the Mastodon page in a desktop
|
||||||
|
application window outside of a browser.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@ -28,29 +34,66 @@ provides a way to do it.
|
|||||||
$ npm install -g mstdn
|
$ npm install -g mstdn
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Via [yarn][]
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn global add mstdn --prefix /usr/local
|
||||||
|
```
|
||||||
|
|
||||||
### As an isolated app
|
### 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
|
## 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
|
$ 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.
|
At first, a dialog which recommends that you create a config is shown and JSON
|
||||||
You need to fill up `"name"` and `"host"` keys in first element of `"accounts"`.
|
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.
|
Then please try to start app again. Usage is the same as web client on mobile
|
||||||
Some shortcuts are available by default (please see below 'Customization' section).
|
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 (confirmed with Windows 8.1).
|
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
|
## 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:
|
The `{app dir}` is:
|
||||||
|
|
||||||
@ -58,28 +101,32 @@ The `{app dir}` is:
|
|||||||
- `~/.config/Mstdn` for Linux
|
- `~/.config/Mstdn` for Linux
|
||||||
- `%APPDATA%\Mstdn` for Windows.
|
- `%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`
|
||||||
|
|
||||||
`hot_key` is a key sequence to toggle application window. The shortcut key is defined globally.
|
`hot_key` is a key sequence to toggle application window. The shortcut key is
|
||||||
The format is a [Electron's accelerator](https://github.com/electron/electron/blob/master/docs/api/accelerator.md).
|
defined globally. The format is an [Electron's
|
||||||
Please see the document to know how to configure this value.
|
accelerator](https://github.com/electron/electron/blob/master/docs/api/accelerator.md).
|
||||||
Default value is `"CmdOrCtrl+Shift+S"`. If you want to disable, please set empty string or `null`.
|
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`
|
### `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`
|
### `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`
|
### `normal_window`
|
||||||
|
|
||||||
When this value is set to `true`, application will be launched as a normal window application.
|
When this value is set to `true`, application will be launched as a normal
|
||||||
If menu bar behavior does not work for you, please use set this value to `true` to avoid it.
|
window application. If the menu bar behavior does not work for you, please set
|
||||||
Default value is `false` on macOS or Linux, `true` on Windows because window position is broken in some version of Windows.
|
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`
|
### `hide_menu`
|
||||||
|
|
||||||
@ -91,34 +138,77 @@ On Windows, typing Alt key shows the hidden menubar.
|
|||||||
|
|
||||||
### `zoom_factor`
|
### `zoom_factor`
|
||||||
|
|
||||||
Font zoom factor in application. It should be positive number. For example, `0.7` means `70%` font zooming.
|
Font zoom factor in application. It should be positive number. For example,
|
||||||
Default font size is a bit bigger because the UI is originally for mobile devices. So default value is `0.9`.
|
`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`
|
### `accounts`
|
||||||
|
|
||||||
Array of your accounts. An element should has `"name"`, `"host"` and `"default_page"` keys.
|
Array of your accounts. Each element should have `"name"`, `"host"` and
|
||||||
`"name"` represents your screen name. `"host"` represents a host part of URL of your mastodon instance.
|
`"default_page"` keys.
|
||||||
`"default_page"` is a page firstly shown.
|
|
||||||
|
- `"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.
|
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`
|
### `keymaps`
|
||||||
|
|
||||||
Object whose key is a key sequence and whose value is an action name.
|
Object whose key is a key sequence and whose value is an action name.
|
||||||
|
|
||||||
| Action Name | Description | Default Key |
|
| Action Name | Description | Default Key |
|
||||||
|-----------------|---------------------------------|-------------|
|
|-------------------|---------------------------------|-------------|
|
||||||
| `scroll-down` | Scroll down window | `j` |
|
| `scroll-down` | Scroll down window | `j` |
|
||||||
| `scroll-up` | Scroll up window | `k` |
|
| `scroll-up` | Scroll up window | `k` |
|
||||||
| `scroll-top` | Scroll up to top of window | `i` |
|
| `scroll-top` | Scroll up to top of window | `i` |
|
||||||
| `scroll-bottom` | Scroll down to bottom of window | `m` |
|
| `scroll-bottom` | Scroll down to bottom of window | `m` |
|
||||||
| `next-account` | Switch to next account | N/A |
|
| `next-account` | Switch to next account | N/A |
|
||||||
| `prev-account` | Switch to previous 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 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,
|
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`.
|
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.
|
By default, some key shortcuts for tab items are set in addition to above table.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@ -130,6 +220,7 @@ By default, some key shortcuts for tab items are set in addition to above table.
|
|||||||
"always_on_top": false,
|
"always_on_top": false,
|
||||||
"normal_window": false,
|
"normal_window": false,
|
||||||
"zoom_factor": 0.9,
|
"zoom_factor": 0.9,
|
||||||
|
"chromium_sandbox": true,
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
"name": "Linda_pp",
|
"name": "Linda_pp",
|
||||||
@ -168,14 +259,244 @@ By default, some key shortcuts for tab items are set in addition to above table.
|
|||||||
|
|
||||||
## Multi account
|
## 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
It will show the list of your account. Check mark is added for current user.
|
It will show the list of your accounts. The check mark indicates the current
|
||||||
When you click menu item of non-current user, application window will be recreated and switch page to the account.
|
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
|
[Mastodon]: https://github.com/tootsuite/mastodon
|
||||||
[npm]: https://www.npmjs.com/package/mstdn
|
[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
|
[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/
|
||||||
|
@ -4,7 +4,13 @@ import log from './log';
|
|||||||
import {Account} from './config';
|
import {Account} from './config';
|
||||||
|
|
||||||
export function partitionForAccount(account: Account) {
|
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 {
|
export default class AccountSwitcher extends EventEmitter {
|
||||||
@ -13,7 +19,7 @@ export default class AccountSwitcher extends EventEmitter {
|
|||||||
|
|
||||||
constructor(accounts: Account[]) {
|
constructor(accounts: Account[]) {
|
||||||
super();
|
super();
|
||||||
const submenu = [] as Electron.MenuItemOptions[];
|
const submenu = [] as Electron.MenuItemConstructorOptions[];
|
||||||
|
|
||||||
for (const account of accounts.filter(a => a.name !== '')) {
|
for (const account of accounts.filter(a => a.name !== '')) {
|
||||||
let name = account.name;
|
let name = account.name;
|
||||||
@ -43,7 +49,8 @@ export default class AccountSwitcher extends EventEmitter {
|
|||||||
submenu,
|
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) {
|
if (menu !== null) {
|
||||||
// Insert item before 'Help'
|
// Insert item before 'Help'
|
||||||
menu.insert(menu.items.length - 1, item);
|
menu.insert(menu.items.length - 1, item);
|
||||||
|
39
main/app.ts
39
main/app.ts
@ -1,6 +1,6 @@
|
|||||||
import {app, Menu, globalShortcut, Tray} from 'electron';
|
import {app, Menu, globalShortcut, Tray} from 'electron';
|
||||||
import log from './log';
|
import log from './log';
|
||||||
import {Config, Account} from './config';
|
import {Config, Account, hostUrl} from './config';
|
||||||
import AccountSwitcher from './account_switcher';
|
import AccountSwitcher from './account_switcher';
|
||||||
import defaultMenu from './default_menu';
|
import defaultMenu from './default_menu';
|
||||||
import Window from './window';
|
import Window from './window';
|
||||||
@ -26,7 +26,7 @@ export class App {
|
|||||||
|
|
||||||
start() {
|
start() {
|
||||||
const a = this.switcher.current;
|
const a = this.switcher.current;
|
||||||
const url = `https://${a.host}${a.default_page}`;
|
const url = hostUrl(a) + a.default_page;
|
||||||
this.win.open(url);
|
this.win.open(url);
|
||||||
log.debug('Application started', a, url);
|
log.debug('Application started', a, url);
|
||||||
}
|
}
|
||||||
@ -36,20 +36,7 @@ export class App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.win.menubar) {
|
globalShortcut.register(this.config.hot_key, () => this.win.toggle());
|
||||||
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);
|
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 icon = trayIcon(this.config.icon_color);
|
||||||
const tray = new Tray(icon);
|
const tray = new Tray(icon);
|
||||||
tray.on('click', this.toggleNormalWindow);
|
const toggle = this.win.toggle.bind(this.win);
|
||||||
tray.on('double-click', this.toggleNormalWindow);
|
tray.on('click', toggle);
|
||||||
|
tray.on('double-click', toggle);
|
||||||
if (IS_DARWIN) {
|
if (IS_DARWIN) {
|
||||||
tray.setHighlightMode('never');
|
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) => {
|
private onAccountSwitch = (next: Account) => {
|
||||||
this.win.close();
|
this.win.close();
|
||||||
Window.create(next, this.config, this.win.menubar).then(win => {
|
Window.create(next, this.config, this.win.menubar).then(win => {
|
||||||
|
@ -4,11 +4,12 @@ import {app} from 'electron';
|
|||||||
export const IS_DEBUG = process.env.NODE_ENV === 'development';
|
export const IS_DEBUG = process.env.NODE_ENV === 'development';
|
||||||
export const IS_DARWIN = process.platform === 'darwin';
|
export const IS_DARWIN = process.platform === 'darwin';
|
||||||
export const IS_WINDOWS = process.platform === 'win32';
|
export const IS_WINDOWS = process.platform === 'win32';
|
||||||
export const IS_LINUX = process.platform === 'linux';
|
|
||||||
export const APP_ICON = path.join(__dirname, '..', 'resources', 'icon', 'icon.png');
|
export const APP_ICON = path.join(__dirname, '..', 'resources', 'icon', 'icon.png');
|
||||||
export const PRELOAD_JS = path.join(__dirname, '..', 'renderer', 'preload.js');
|
export const PRELOAD_JS = path.join(__dirname, '..', 'renderer', 'preload.js');
|
||||||
export const DATA_DIR = app.getPath('userData');
|
export const DATA_DIR = app.getPath('userData');
|
||||||
export const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
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) {
|
export function trayIcon(color: string) {
|
||||||
return path.join(__dirname, '..', 'resources', 'icon', `tray-icon-${
|
return path.join(__dirname, '..', 'resources', 'icon', `tray-icon-${
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import {app, systemPreferences, dialog, shell} from 'electron';
|
import {app, systemPreferences, dialog, shell} from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import log from './log';
|
import log from './log';
|
||||||
import {CONFIG_FILE, IS_DARWIN, IS_WINDOWS} from './common';
|
import {CONFIG_FILE, IS_DARWIN, IS_WINDOWS, DATA_DIR} from './common';
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
host: string;
|
host: string;
|
||||||
name: string;
|
name: string;
|
||||||
default_page: string;
|
default_page: string;
|
||||||
|
plugins: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
@ -17,6 +18,8 @@ export interface Config {
|
|||||||
normal_window: boolean;
|
normal_window: boolean;
|
||||||
zoom_factor: number;
|
zoom_factor: number;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
|
chromium_sandbox: boolean;
|
||||||
|
__DATA_DIR?: string;
|
||||||
keymaps: {[key: string]: string};
|
keymaps: {[key: string]: string};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,16 +28,18 @@ function makeDefaultConfig(): Config {
|
|||||||
const menubarBroken = IS_WINDOWS;
|
const menubarBroken = IS_WINDOWS;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hot_key: 'CmdOrCtrl+Shift+S',
|
hot_key: 'CmdOrCtrl+Shift+Enter',
|
||||||
icon_color: IsDarkMode ? 'white' : 'black',
|
icon_color: IsDarkMode ? 'white' : 'black',
|
||||||
always_on_top: false,
|
always_on_top: false,
|
||||||
hide_menu: false,
|
hide_menu: false,
|
||||||
normal_window: menubarBroken,
|
normal_window: menubarBroken,
|
||||||
zoom_factor: 0.9,
|
zoom_factor: 0.9,
|
||||||
|
chromium_sandbox: true,
|
||||||
accounts: [{
|
accounts: [{
|
||||||
name: '',
|
name: '',
|
||||||
host: '',
|
host: '',
|
||||||
default_page: '/web/timelines/home',
|
default_page: '/web/timelines/home',
|
||||||
|
plugins: [],
|
||||||
}],
|
}],
|
||||||
keymaps: {
|
keymaps: {
|
||||||
j: 'scroll-down',
|
j: 'scroll-down',
|
||||||
@ -68,6 +73,14 @@ function recommendConfigAndDie(file: string) {
|
|||||||
showDyingDialog(title, detail);
|
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> {
|
export default function loadConfig(): Promise<Config> {
|
||||||
return new Promise<Config>(resolve => {
|
return new Promise<Config>(resolve => {
|
||||||
fs.readFile(CONFIG_FILE, 'utf8', (err, json) => {
|
fs.readFile(CONFIG_FILE, 'utf8', (err, json) => {
|
||||||
@ -98,6 +111,10 @@ export default function loadConfig(): Promise<Config> {
|
|||||||
if (!config.accounts || config.accounts[0].host === '' || config.accounts[0].name === '') {
|
if (!config.accounts || config.accounts[0].host === '' || config.accounts[0].name === '') {
|
||||||
recommendConfigAndDie(CONFIG_FILE);
|
recommendConfigAndDie(CONFIG_FILE);
|
||||||
} else {
|
} else {
|
||||||
|
config.__DATA_DIR = DATA_DIR;
|
||||||
|
if (config.chromium_sandbox === undefined) {
|
||||||
|
config.chromium_sandbox = true;
|
||||||
|
}
|
||||||
resolve(config);
|
resolve(config);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -2,7 +2,7 @@ import * as path from 'path';
|
|||||||
import {Menu, shell, app} from 'electron';
|
import {Menu, shell, app} from 'electron';
|
||||||
|
|
||||||
export default function defaultMenu() {
|
export default function defaultMenu() {
|
||||||
const template: Electron.MenuItemOptions[] = [
|
const template: Electron.MenuItemConstructorOptions[] = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
submenu: [
|
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'
|
type: 'separator'
|
||||||
},
|
},
|
||||||
|
@ -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 log from './log';
|
||||||
import startApp from './app';
|
import startApp from './app';
|
||||||
import loadConfig from './config';
|
import loadConfig from './config';
|
||||||
@ -7,10 +8,22 @@ import loadConfig from './config';
|
|||||||
// loading this application. We need to disable the callback.
|
// loading this application. We need to disable the callback.
|
||||||
app.removeAllListeners();
|
app.removeAllListeners();
|
||||||
|
|
||||||
|
contextMenu();
|
||||||
|
|
||||||
const appReady = new Promise<void>(resolve => app.once('ready', resolve));
|
const appReady = new Promise<void>(resolve => app.once('ready', resolve));
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason: string) => {
|
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', () => {
|
app.on('will-quit', () => {
|
||||||
|
152
main/window.ts
152
main/window.ts
@ -1,27 +1,42 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
import {app, BrowserWindow, shell, dialog, Menu} from 'electron';
|
import {app, BrowserWindow, shell, dialog, Menu} from 'electron';
|
||||||
import windowState = require('electron-window-state');
|
import windowState = require('electron-window-state');
|
||||||
import * as menubar from 'menubar';
|
import * as menubar from 'menubar';
|
||||||
import {Config, Account} from './config';
|
import {Config, Account, hostUrl} from './config';
|
||||||
import {partitionForAccount} from './account_switcher';
|
import {partitionForAccount} from './account_switcher';
|
||||||
import log from './log';
|
import log from './log';
|
||||||
import {IS_DEBUG, IS_DARWIN, IS_WINDOWS, IS_LINUX, 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';
|
||||||
|
|
||||||
const ELECTRON_ISSUE_9230 = IS_WINDOWS || IS_LINUX;
|
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 {
|
export default class Window {
|
||||||
static create(account: Account, config: Config, mb: Menubar.MenubarApp | null = null) {
|
static create(account: Account, config: Config, mb: Menubar.MenubarApp | null = null) {
|
||||||
if (config.normal_window) {
|
return (config.normal_window ? startNormalWindow(account, config) : startMenuBar(account, config, mb))
|
||||||
return startNormalWindow(account, config);
|
.then(win => {
|
||||||
} else {
|
win.browser.webContents.on('dom-ready', () => {
|
||||||
return startMenuBar(account, config, mb);
|
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(
|
constructor(
|
||||||
public browser: Electron.BrowserWindow,
|
public browser: Electron.BrowserWindow,
|
||||||
public state: any /*XXX: ElectronWindowState.WindowState */,
|
public state: any /*XXX: ElectronWindowState.WindowState */,
|
||||||
public account: Account,
|
public account: Account,
|
||||||
|
/* tslint:disable:no-shadowed-variable */
|
||||||
public menubar: Menubar.MenubarApp | null,
|
public menubar: Menubar.MenubarApp | null,
|
||||||
|
/* tslint:enable:no-shadowed-variable */
|
||||||
) {
|
) {
|
||||||
if (!IS_DARWIN) {
|
if (!IS_DARWIN) {
|
||||||
// Users can still access menu bar with pressing Alt key.
|
// Users can still access menu bar with pressing Alt key.
|
||||||
@ -29,19 +44,20 @@ export default class Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
browser.webContents.on('will-navigate', (e, url) => {
|
browser.webContents.on('will-navigate', (e, url) => {
|
||||||
if (!url.startsWith(`https://${this.account.host}`)) {
|
const host = hostUrl(this.account);
|
||||||
e.preventDefault();
|
if (url.startsWith(host)) {
|
||||||
shell.openExternal(url);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldOpenInternal(host, url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
shell.openExternal(url);
|
||||||
log.debug('Opened URL with external browser (will-navigate)', url);
|
log.debug('Opened URL with external browser (will-navigate)', url);
|
||||||
});
|
});
|
||||||
browser.webContents.on('new-window', (e, url) => {
|
browser.webContents.on('new-window', (e, url) => {
|
||||||
if (ELECTRON_ISSUE_9230) {
|
|
||||||
// XXX:
|
|
||||||
// On Windows or Linux, rel="noopener" lets app crash on preventing the event.
|
|
||||||
// Issue: https://github.com/electron/electron/issues/9230
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
log.debug('Opened URL with external browser (new-window)', url);
|
log.debug('Opened URL with external browser (new-window)', url);
|
||||||
@ -50,7 +66,7 @@ export default class Window {
|
|||||||
browser.webContents.session.setPermissionRequestHandler((contents, permission, callback) => {
|
browser.webContents.session.setPermissionRequestHandler((contents, permission, callback) => {
|
||||||
const url = contents.getURL();
|
const url = contents.getURL();
|
||||||
const grantedByDefault =
|
const grantedByDefault =
|
||||||
url.startsWith(`https://${this.account.host}`) &&
|
url.startsWith(hostUrl(this.account)) &&
|
||||||
permission !== 'geolocation' &&
|
permission !== 'geolocation' &&
|
||||||
permission !== 'media';
|
permission !== 'media';
|
||||||
|
|
||||||
@ -76,21 +92,80 @@ export default class Window {
|
|||||||
|
|
||||||
open(url: string) {
|
open(url: string) {
|
||||||
log.debug('Open URL:', url);
|
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() {
|
close() {
|
||||||
log.debug('Closing window:', this.account);
|
log.debug('Closing window:', this.account);
|
||||||
|
this.state.saveState();
|
||||||
this.state.unmanage();
|
this.state.unmanage();
|
||||||
this.browser.webContents.removeAllListeners();
|
this.browser.webContents.removeAllListeners();
|
||||||
this.browser.removeAllListeners();
|
this.browser.removeAllListeners();
|
||||||
if (this.menubar) {
|
if (this.menubar) {
|
||||||
// Note:
|
// 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;
|
delete this.menubar.window;
|
||||||
}
|
}
|
||||||
this.browser.close();
|
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> {
|
function startNormalWindow(account: Account, config: Config): Promise<Window> {
|
||||||
@ -107,13 +182,13 @@ function startNormalWindow(account: Account, config: Config): Promise<Window> {
|
|||||||
y: state.y,
|
y: state.y,
|
||||||
icon: APP_ICON,
|
icon: APP_ICON,
|
||||||
show: false,
|
show: false,
|
||||||
useContentSize: true,
|
autoHideMenuBar: !!config.hide_menu,
|
||||||
autoHideMenuBar: config.hide_menu,
|
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: true,
|
sandbox: sandboxFlag(config, account),
|
||||||
preload: PRELOAD_JS,
|
preload: PRELOAD_JS,
|
||||||
partition: partitionForAccount(account),
|
partition: partitionForAccount(account),
|
||||||
|
zoomFactor: config.zoom_factor,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
win.once('ready-to-show', () => {
|
win.once('ready-to-show', () => {
|
||||||
@ -130,10 +205,6 @@ function startNormalWindow(account: Account, config: Config): Promise<Window> {
|
|||||||
}
|
}
|
||||||
state.manage(win);
|
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', () => {
|
win.webContents.once('dom-ready', () => {
|
||||||
log.debug('Normal window application was launched');
|
log.debug('Normal window application was launched');
|
||||||
if (IS_DEBUG) {
|
if (IS_DEBUG) {
|
||||||
@ -145,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> {
|
function startMenuBar(account: Account, config: Config, bar: Menubar.MenubarApp | null): Promise<Window> {
|
||||||
log.debug('Setup a menubar window');
|
log.debug('Setup a menubar window');
|
||||||
return new Promise<Window>(resolve => {
|
return new Promise<Window>(resolve => {
|
||||||
const state = windowState({
|
const state = windowState({
|
||||||
defaultWidth: 350,
|
defaultWidth: 320,
|
||||||
defaultHeight: 420,
|
defaultHeight: 420,
|
||||||
});
|
});
|
||||||
const icon = trayIcon(config.icon_color);
|
const icon = trayIcon(config.icon_color);
|
||||||
@ -159,15 +244,15 @@ function startMenuBar(account: Account, config: Config, bar: Menubar.MenubarApp
|
|||||||
height: state.height,
|
height: state.height,
|
||||||
alwaysOnTop: IS_DEBUG || !!config.always_on_top,
|
alwaysOnTop: IS_DEBUG || !!config.always_on_top,
|
||||||
tooltip: 'Mstdn',
|
tooltip: 'Mstdn',
|
||||||
useContentSize: true,
|
autoHideMenuBar: !!config.hide_menu,
|
||||||
autoHideMenuBar: config.hide_menu,
|
|
||||||
show: false,
|
show: false,
|
||||||
showDockIcon: true,
|
showDockIcon: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: true,
|
sandbox: sandboxFlag(config, account),
|
||||||
preload: PRELOAD_JS,
|
preload: PRELOAD_JS,
|
||||||
partition: partitionForAccount(account),
|
partition: partitionForAccount(account),
|
||||||
|
zoomFactor: config.zoom_factor,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mb.once('after-create-window', () => {
|
mb.once('after-create-window', () => {
|
||||||
@ -175,10 +260,6 @@ function startMenuBar(account: Account, config: Config, bar: Menubar.MenubarApp
|
|||||||
if (IS_DEBUG) {
|
if (IS_DEBUG) {
|
||||||
mb.window.webContents.openDevTools({mode: 'detach'});
|
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);
|
state.manage(mb.window);
|
||||||
|
|
||||||
resolve(new Window(mb.window, state, account, mb));
|
resolve(new Window(mb.window, state, account, mb));
|
||||||
@ -190,6 +271,7 @@ function startMenuBar(account: Account, config: Config, bar: Menubar.MenubarApp
|
|||||||
log.debug('Recreate menubar window with different partition:', account);
|
log.debug('Recreate menubar window with different partition:', account);
|
||||||
const pref = mb.getOption('webPreferences');
|
const pref = mb.getOption('webPreferences');
|
||||||
pref.partition = partitionForAccount(account);
|
pref.partition = partitionForAccount(account);
|
||||||
|
pref.sandbox = sandboxFlag(config, account);
|
||||||
mb.setOption('webPreferences', pref);
|
mb.setOption('webPreferences', pref);
|
||||||
mb.showWindow();
|
mb.showWindow();
|
||||||
} else {
|
} else {
|
||||||
|
32
package.json
32
package.json
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mstdn",
|
"name": "mstdn",
|
||||||
"productName": "Mstdn",
|
"productName": "Mstdn",
|
||||||
"version": "0.1.3",
|
"version": "0.2.6",
|
||||||
"description": "Tiny web-based mastodon client for your desktop",
|
"description": "Tiny web-based mastodon client for your desktop",
|
||||||
"main": "main/index.js",
|
"main": "main/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
@ -9,12 +9,12 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:ts": "tsc --pretty -p .",
|
"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": "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",
|
"build:release": "npm run build:ts && npm run build:bundle:release",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"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",
|
"watch": "guard --watchdir main renderer typings",
|
||||||
"debug": "NODE_ENV=development electron .",
|
"debug": "NODE_ENV=development electron .",
|
||||||
"start": "NODE_ENV=production electron .",
|
"start": "NODE_ENV=production electron .",
|
||||||
@ -37,20 +37,20 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/rhysd/Mstdn#readme",
|
"homepage": "https://github.com/rhysd/Mstdn#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/electron": "^1.4.37",
|
"@types/electron-window-state": "^2.0.29",
|
||||||
"@types/electron-window-state": "^2.0.28",
|
"@types/loglevel": "^1.4.30",
|
||||||
"@types/loglevel": "^1.4.29",
|
"@types/menubar": "^5.1.4",
|
||||||
"@types/menubar": "^5.1.3",
|
"@types/mousetrap": "^1.5.34",
|
||||||
"@types/mousetrap": "^1.5.33",
|
"@types/node": "^8.0.26",
|
||||||
"@types/node": "^7.0.13",
|
"browserify": "^14.4.0",
|
||||||
"browserify": "^14.3.0",
|
"electron-packager": "^9.0.0",
|
||||||
"electron-packager": "^8.6.0",
|
"npm-run-all": "^4.1.1",
|
||||||
"npm-run-all": "^4.0.2",
|
"tslint": "^5.7.0",
|
||||||
"tslint": "^5.1.0",
|
"typescript": "^2.4.2"
|
||||||
"typescript": "^2.2.2"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron": "^1.6.5",
|
"electron": "^1.7.5",
|
||||||
|
"electron-context-menu": "^0.9.1",
|
||||||
"electron-window-state": "^4.1.1",
|
"electron-window-state": "^4.1.1",
|
||||||
"loglevel": "^1.4.1",
|
"loglevel": "^1.4.1",
|
||||||
"menubar": "github:rhysd/menubar#mstdn",
|
"menubar": "github:rhysd/menubar#mstdn",
|
||||||
|
@ -1,82 +1,11 @@
|
|||||||
import * as Mousetrap from 'mousetrap';
|
|
||||||
import {Config, Account} from '../main/config';
|
import {Config, Account} from '../main/config';
|
||||||
import * as Ipc from './ipc';
|
import * as Ipc from './ipc';
|
||||||
import log from './log';
|
import setupKeymaps from './key_handler';
|
||||||
|
import PluginsLoader from './plugins';
|
||||||
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) => {
|
Ipc.on('mstdn:config', (c: Config, a: Account) => {
|
||||||
config = c;
|
const loader = new PluginsLoader(c, a);
|
||||||
setupKeybinds(config.keymaps, a.host);
|
loader.loadAfterAppPrepared();
|
||||||
|
setupKeymaps(c, a, loader);
|
||||||
document.title = `${document.title} @${a.name}@${a.host}`;
|
document.title = `${document.title} @${a.name}@${a.host}`;
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
|
import {ipcRenderer as ipc} from 'electron';
|
||||||
import log from './log';
|
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) {
|
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);
|
log.info('IPC: Received from:', channel, args);
|
||||||
callback(...args);
|
callback(...args);
|
||||||
});
|
});
|
||||||
|
121
renderer/key_handler.ts
Normal file
121
renderer/key_handler.ts
Normal 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
119
renderer/plugins.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
|
@ -24,9 +24,9 @@ function pack-app() {
|
|||||||
local electron_version=$(electron --version)
|
local electron_version=$(electron --version)
|
||||||
electron_version=${electron_version#v}
|
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=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.ico --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.ico --electron-version=$electron_version --version-string=$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() {
|
function make-dist() {
|
||||||
@ -40,10 +40,12 @@ function make-dist() {
|
|||||||
cp LICENSE README.md "$dir"
|
cp LICENSE README.md "$dir"
|
||||||
zip --symlinks "dist/${dir}-${version}.zip" -r "$dir"
|
zip --symlinks "dist/${dir}-${version}.zip" -r "$dir"
|
||||||
done
|
done
|
||||||
rm -r Mstdn-*
|
rm -rf Mstdn-*
|
||||||
open dist
|
open dist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export PATH=./node_modules/.bin:$PATH
|
||||||
|
|
||||||
prepare-app
|
prepare-app
|
||||||
pack-app
|
pack-app
|
||||||
make-dist
|
make-dist
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noEmitOnError": true,
|
"noEmitOnError": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"target": "es2015",
|
"target": "es2016",
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
6
typings/browserify_workaround.d.ts
vendored
6
typings/browserify_workaround.d.ts
vendored
@ -1,6 +0,0 @@
|
|||||||
declare namespace NodeJS {
|
|
||||||
interface Global {
|
|
||||||
require: NodeRequireFunction;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
21
typings/electron-context-menu.d.ts
vendored
Normal file
21
typings/electron-context-menu.d.ts
vendored
Normal 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
15
typings/request-idle-callback.d.ts
vendored
Normal 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;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user