mirror of
https://github.com/rhysd/Mstdn.git
synced 2025-04-05 22:57:36 +02:00
Compare commits
68 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 | ||
|
d2315a576b | ||
|
969c8cee21 | ||
|
80eb09877f | ||
|
5aebef7619 | ||
|
a74b4f38cf | ||
|
2f12c63d93 | ||
|
c9c74dcf6b | ||
|
e323625cca | ||
|
4169bcf902 | ||
|
f67c6f15e8 |
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
|
||||
*.js.map
|
||||
/typings
|
||||
/.github
|
||||
|
417
README.md
417
README.md
@ -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 (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
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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/
|
||||
|
@ -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);
|
||||
|
39
main/app.ts
39
main/app.ts
@ -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 => {
|
||||
|
@ -8,6 +8,8 @@ export const APP_ICON = path.join(__dirname, '..', 'resources', 'icon', 'icon.pn
|
||||
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-${
|
||||
|
@ -1,21 +1,25 @@
|
||||
import {app, systemPreferences, dialog, shell} from 'electron';
|
||||
import * as fs from 'fs';
|
||||
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 {
|
||||
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};
|
||||
}
|
||||
|
||||
@ -24,15 +28,18 @@ function makeDefaultConfig(): Config {
|
||||
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) {
|
||||
|
@ -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'
|
||||
},
|
||||
|
@ -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', () => {
|
||||
|
@ -5,6 +5,5 @@ if (process.env.NODE_ENV === 'development') {
|
||||
} else {
|
||||
log.setLevel('info');
|
||||
}
|
||||
log.setLevel('debug');
|
||||
|
||||
export default log;
|
||||
|
151
main/window.ts
151
main/window.ts
@ -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, IS_WINDOWS, 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,18 +44,20 @@ 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) => {
|
||||
if (IS_WINDOWS) {
|
||||
// XXX:
|
||||
// On Windows, rel="noopener" lets app crash on preventing the event.
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
shell.openExternal(url);
|
||||
log.debug('Opened URL with external browser (new-window)', url);
|
||||
@ -47,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';
|
||||
|
||||
@ -73,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> {
|
||||
@ -104,13 +182,13 @@ function startNormalWindow(account: Account, config: Config): Promise<Window> {
|
||||
y: state.y,
|
||||
icon: APP_ICON,
|
||||
show: false,
|
||||
useContentSize: true,
|
||||
autoHideMenuBar: false,
|
||||
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', () => {
|
||||
@ -127,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) {
|
||||
@ -142,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);
|
||||
@ -156,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: false,
|
||||
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', () => {
|
||||
@ -172,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));
|
||||
@ -187,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 {
|
||||
|
32
package.json
32
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mstdn",
|
||||
"productName": "Mstdn",
|
||||
"version": "0.1.2",
|
||||
"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",
|
||||
|
@ -1,82 +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}`;
|
||||
});
|
||||
|
@ -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
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)
|
||||
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
|
||||
|
@ -11,7 +11,7 @@
|
||||
"noUnusedParameters": true,
|
||||
"noEmitOnError": true,
|
||||
"strictNullChecks": true,
|
||||
"target": "es2015",
|
||||
"target": "es2016",
|
||||
"sourceMap": true
|
||||
},
|
||||
"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