6
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

JavaScriptからWebSocketでコメントを送れる棒読みちゃんプラグインを作った

Last updated at Posted at 2018-05-23

#■はじめに
JavaScriptから棒読みちゃんを使う方法をいくつか試してみました。
棒読みちゃんはTCPソケット通信をサポートしているので、TCPソケットを扱う方法を探してみました。

##JavaScriptからTCPソケットで通信する
jSocketを使う方法しかないようです。ただし、jSokcetはサイズ0のFlashをロードする形になるため、chromeでは思った通りに動作しませんでした。(サイト毎のFlashを許可するに登録すれば動作しますが、サイズが400x300以上のFlashでなければ将来ブロックされるでしょうというメッセージがコンソールに出ます)
この方法にはもう一つ問題があって、Flashを使っているためサーバーは<policy-file-request>に対応している必要があります。棒読みちゃんはこれに対応していません。

##chrome.socket.tcp
Chrome Extensionで実現する方法がないか調べましたが、なさそうです。以前はchrome.experimental.socketというのがあったようですが、いまはchrome.experimentalはバッサリ削除されています。
Chrome ExtensionでなくChrome Appでならchrome.socket.tcpが使えます。これを使えばTCPソケットで棒読みちゃんと通信できました。
しかし、Chrome Appもモバイル向け以外は廃止されそうなので採用するのをやめました。

##WebSocket
結局、素直にWebSocketを使うのが一番いいだろうという結論になりました。ただ、棒読みちゃんはWebSocketに対応していないので、プラグインを用意する必要があります。
ということで本題です。

#■1. 棒読みちゃんプラグインの雛形
##1.1. クラスライブラリプロジェクトの作成
まず素直にVisual Studio 2017のクラスライブラリ(.NET Standard)のプロジェクトを新規作成する方法では、棒読みちゃんがプラグインのロードに失敗します。
どうやら.NETのバージョンが新しすぎるようです。
棒読みちゃんのSampleSrcのPlugin_Zihouを見ると、ターゲットは、.NET Framework 2.0になっています。.NET Standard 2.0ではありません。
では、ターゲットを変えればいいんだと思って、新規作成したプロジェクトのプロパティを見ると、.NET Standardしかなく、.NET Framework 2.0を設定できません。
仕方ないので、前述のSampleSrcのプロジェクトをコピー&テキストエディタでプロジェクト名変更することで.NET Framework 2.0のプロジェクトを作成しました。
なお、dllは「Plugin_*.dll」という形式にする必要があります。ここでは、Plugin_BymChnWebSocket.dllを作成します。

#1.2. 参照
参照で、bouyomichan.exeを指定します。
これにより棒読みちゃんのプラグインインターフェースIPluginが使えるようになります。

#1.3. 雛形作成

空のプラグインクラスを作成します。

Plugin_BymChnWebSocket.cs
using System;
using System.Collections.Generic;
using System.Text;
using FNF.BouyomiChanApp;

namespace Plugin_BymChnWebSocket
{
    public class Plugin_BymChnWebSocket : IPlugin
    {
    }
}

インターフェースの自動生成を行うと次のようになります。

Plugin_BymChnWebSocket.cs
using System;
using System.Collections.Generic;
using System.Text;
using FNF.BouyomiChanApp;
using FNF.XmlSerializerSetting;

namespace Plugin_BymChnWebSocket
{
    public class Plugin_BymChnWebSocket : IPlugin
    {
        public string Name => throw new NotImplementedException();

        public string Version => throw new NotImplementedException();

        public string Caption => throw new NotImplementedException();

        public ISettingFormData SettingFormData => throw new NotImplementedException();

        public void Begin()
        {
            throw new NotImplementedException();
        }

        public void End()
        {
            throw new NotImplementedException();
        }
    }
}

まずプロパティを実装します。

Plugin_BymChnWebSocket.cs
using System;
using System.Collections.Generic;
using System.Text;
using FNF.BouyomiChanApp;
using FNF.XmlSerializerSetting;

namespace Plugin_BymChnWebSocket
{
    public class Plugin_BymChnWebSocket : IPlugin
    {
        string IPlugin.Name => "棒読みちゃんWebSocket";

        string IPlugin.Version => "1.0.0.0";

        string IPlugin.Caption => "棒読みちゃんWebSocket";

        ISettingFormData IPlugin.SettingFormData => null;

        void IPlugin.Begin()
        {
        }

        void IPlugin.End()
        {
        }
    }
}

#■2. WebSocketサーバーの実装
#2.1. .NET Framework 2.0でWebSocketサーバーを実装する
.NET StandardならWebSocketに関するライブラリが整備されていますが、今回の対象は.NET Framework 2.0です。何もありません。
TCPサーバーで自前でWebSocketに対応する必要があります。また、Threadを使わないといけなくなります。

WebSocketServer.cs
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using System.Security.Cryptography;

namespace Plugin_BymChnWebSocket
{
    delegate void Connected(TcpClient client);
    delegate void Disconnected(TcpClient client);
    delegate void DataReceived(TcpClient client, byte[] data);

    class WebSocketServer
    {
        /// <summary>
        /// ポート
        /// </summary>
        public int Port { get; set; }

        public string Path { get; set; }

        /// <summary>
        /// イベントハンドラ
        /// </summary>
        public event Connected OnConnected;
        public event Disconnected OnDisconnected;
        public event DataReceived OnDataReceived;

        private Thread listenerThread;
        TcpListener listener;

        /// <summary>
        /// コンストラクタ
        /// </summary>
        public WebSocketServer()
        {
        }

        /// <summary>
        /// 開始する
        /// </summary>
        public void Start()
        {
            listenerThread = new Thread(listenerThreadProc);
            listenerThread.Start();
        }

        /// <summary>
        /// 終了する
        /// </summary>
        public void Stop()
        {
            listener.Stop();
            listenerThread.Abort();
        }

        /// <summary>
        /// リッスンスレッド
        /// </summary>
        private void listenerThreadProc()
        {
            IPAddress ipAddress = IPAddress.Parse("::1");
            var clientThreadList = new List<Thread>();

            // リッスン開始
            listener = new TcpListener(ipAddress, Port);
            listener.Start(100);

            // メインループ
            while (true)
            {
                try
                {
                    // 接続要求を処理する
                    var client = listener.AcceptTcpClient();
                    System.Diagnostics.Debug.WriteLine("AcceptTcpClient : {0}", client.Client.RemoteEndPoint.ToString());
                    var clientThread = new Thread(threadClient);
                    clientThread.Start(client);
                    clientThreadList.Add(clientThread);
                }
                catch (Exception exception)
                {
                    System.Diagnostics.Debug.WriteLine("listenerThreadProc exception:" + exception.ToString());
                    break;
                }
            }

            // 後片付け
            foreach (var clientThread in clientThreadList)
            {
                if (clientThread.IsAlive)
                {
                    clientThread.Abort();
                }
            }
            listener.Stop();
        }

        /// <summary>
        /// クライアントスレッド
        /// </summary>
        /// <param name="arg"></param>
        private void threadClient(object arg)
        {
            TcpClient client = arg as TcpClient;

            bool ret;
            ret = handleHandshake(client);
            if  (ret)
            {
                OnConnected(client);
                while (ret && client != null && client.Client != null)
                {
                    try
                    {
                        ret = handleRecvData(client);
                    }
                    catch (Exception exception)
                    {
                        System.Diagnostics.Debug.WriteLine("threadClient exception=" + exception.ToString());
                        break;
                    }
                }
                OnDisconnected(client);
            }
            client.Close();
        }

        /// <summary>
        /// WebSocketのハンドシェイク
        /// </summary>
        /// <param name="client"></param>
        /// <returns></returns>
        private bool handleHandshake(TcpClient client)
        {
            const string RequestPattern = @"^(?<method>[^\s]+)\s(?<path>[^\s]+)\sHTTP\/1\.1\r\n" +
                                   @"((?<field_name>[^:\r\n]+):\s(?<field_value>[^\r\n]+)\r\n)+" +
                                   @"\r\n" +
                                   @"(?<body>.+)?";
            bool ret = false;
            if (client == null || client.Client == null)
            {
                return ret;
            }
            // 受信処理
            int bufSize = client.Client.ReceiveBufferSize;
            byte[] buf = new byte[bufSize];
            client.Client.Receive(buf);

            String handshake = Encoding.UTF8.GetString(buf, 0, bufSize);
            System.Diagnostics.Debug.WriteLine("handshake=");
            System.Diagnostics.Debug.WriteLine(handshake);
            var regex = new Regex(RequestPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
            var match = regex.Match(handshake);
            var method = match.Groups["method"].Value;
            var path = match.Groups["path"].Value;
            var fields = match.Groups["field_name"].Captures;
            var values = match.Groups["field_value"].Captures;
            var headers = new Dictionary<string, string>();
            for (int i = 0; i < fields.Count; i++)
            {
                var field = fields[i].Value;
                var value = values[i].Value;
                headers[field] = value;
            }
            if (method != "GET")
            {
                return ret;
            }
            if (path != Path)
            {
                return ret;
            }
            if (headers["Upgrade"] != "websocket")
            {
                return ret;
            }
            if (headers["Connection"] != "Upgrade")
            {
                return ret;
            }
            if (headers["Sec-WebSocket-Version"] != "13")
            {
                return ret;
            }
            var secWebsocketKey = headers["Sec-WebSocket-Key"];
            var originalText = secWebsocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
            byte[] b = Encoding.UTF8.GetBytes(originalText);

            var sha1 = new SHA1CryptoServiceProvider();
            var hash = sha1.ComputeHash(b);
            string secWebSocketAccept = Convert.ToBase64String(hash);

            string response =
                "HTTP/1.1 101 OK\r\n" +
                "Upgrade: websocket\r\n" +
                "Connection: Upgrade\r\n" +
                "Sec-WebSocket-Accept: " + secWebSocketAccept + "\r\n" +
                "\r\n";
            System.Diagnostics.Debug.WriteLine("response=");
            System.Diagnostics.Debug.WriteLine(response);
            byte[] bytes = Encoding.UTF8.GetBytes(response);
            client.Client.Send(bytes);

            ret = true;
            return ret;
        }

        /// <summary>
        /// データの受信
        /// </summary>
        /// <param name="client"></param>
        /// <returns></returns>
        private bool handleRecvData(TcpClient client)
        {
            bool ret = false;
            if (client == null || client.Client == null)
            {
                return ret;
            }
            // 受信処理
            int bufSize = client.Client.ReceiveBufferSize;
            byte[] buf = new byte[bufSize];
            client.Client.Receive(buf);

            Int64 payloadLen = buf[1] & 0x7F;
            int offset = 2;
            if (payloadLen == 126)
            {
                // ペイロード長拡張16ビット
                payloadLen = (buf[2] << 8) + buf[3];
                offset += 2;
            }
            else if (payloadLen == 127)
            {
                // ペイロード長拡張64ビット
                payloadLen = (buf[2] << 56) + (buf[3] << 48) +
                    (buf[4] << 40) + (buf[5] << 32) +
                    (buf[6] << 24) + (buf[7] << 16) +
                    (buf[8] << 8) + buf[9];
                offset += 8;
            }
            else
            {
                // 拡張無し
            }
            bool mask = ((buf[1] & 0x80) == 0x80);
            if (mask)
            {
                var maskingKey = new byte[4];
                for (int i = 0; i < 4; i++)
                {
                    maskingKey[i] = buf[offset + i];
                }
                offset += 4;

                // unmask
                for (int i = 0; i < payloadLen; i++)
                {
                    buf[offset + i] ^= maskingKey[i % 4];
                }
            }

            System.Diagnostics.Debug.WriteLine("payloadLen={0}", payloadLen.ToString());

            byte[] data = new byte[payloadLen];
            int pos = offset;
            for (int i = 0; i < payloadLen; i++)
            {
                data[i] = buf[pos++];
            }
            OnDataReceived(client, data);

            ret = true;
            return ret;
        }
    }
}

#2.2. IPluginの実装
IPlugin.Begin()でサーバーを起動し、IPlugin.End()でサーバーを終了しています。
データ受信イベントが発生したらServer_OnDataReceived()が呼ばれます。ここでデータを解析して、Pub.AddTalkTaskで棒読みちゃんの読み上げタスクを追加しています。

Plugin_BymChnWebSocket.cs
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using System.IO;
using System.Drawing;
using System.Threading;
using System.ComponentModel;
using System.Windows.Forms;
using FNF.Utility;
using FNF.Controls;
using FNF.XmlSerializerSetting;
using FNF.BouyomiChanApp;

namespace Plugin_BymChnWebSocket {
    public class Plugin_BymChnWebSocket : IPlugin
    {
        /// <summary>
        /// 名前(IPlugin必須)
        /// </summary>
        string IPlugin.Name => "棒読みちゃんWebSocket";

        /// <summary>
        /// バージョン(IPlugin必須)
        /// </summary>
        string IPlugin.Version => "1.0.0.0";

        /// <summary>
        /// キャプション(IPlugin必須)
        /// </summary>
        string IPlugin.Caption => "棒読みちゃんWebSocket";

        /// <summary>
        /// 設定フォームデータ(IPlugin必須)
        /// </summary>
        ISettingFormData IPlugin.SettingFormData => null;

        /// <summary>
        /// WebSocketサーバー
        /// </summary>
        WebSocketServer server;

        /// <summary>
        /// プラグインが開始された(IPlugin必須)
        /// </summary>
        void IPlugin.Begin()
        {
            StartServer();
        }

        /// <summary>
        /// プラグインが終了された(IPlugin必須)
        /// </summary>
        void IPlugin.End()
        {
            StopServer();
        }


        /// <summary>
        /// WebSocketサーバーを開始する
        /// </summary>
        public void StartServer()
        {
            server = new WebSocketServer();
            server.Port = 50002;
            server.Path = "/ws/";
            server.OnConnected += Server_OnConnected;
            server.OnDisconnected += Server_OnDisconnected;
            server.OnDataReceived += Server_OnDataReceived;
            server.Start();
        }

        /// <summary>
        /// WebSocketサーバーを終了する
        /// </summary>
        public void StopServer()
        {
            server.Stop();
        }

        /// <summary>
        /// WebSocketが接続された
        /// </summary>
        /// <param name="client"></param>
        private void Server_OnConnected(System.Net.Sockets.TcpClient client)
        {
            System.Diagnostics.Debug.WriteLine("Server_OnConnected");
        }

        /// <summary>
        /// WebSocketが切断された
        /// </summary>
        /// <param name="client"></param>
        private void Server_OnDisconnected(System.Net.Sockets.TcpClient client)
        {
            System.Diagnostics.Debug.WriteLine("Server_OnDisconnected");
        }

        /// <summary>
        /// データを受信した
        /// </summary>
        /// <param name="client"></param>
        /// <param name="data"></param>
        private void Server_OnDataReceived(System.Net.Sockets.TcpClient client, byte[] data)
        {
            System.Diagnostics.Debug.WriteLine("Server_OnDataReceived {data.Length:{0}", data.Length.ToString());

            short command; //[0-1]  (16Bit) コマンド          ( 0:メッセージ読み上げ)
            short speed; //[2-3]  (16Bit) 速度              (-1:棒読みちゃん画面上の設定)
            short tone; //[4-5]  (16Bit) 音程              (-1:棒読みちゃん画面上の設定)
            short volume; //[6-7]  (16Bit) 音量              (-1:棒読みちゃん画面上の設定)
            short voice; //[8-9]  (16Bit) 声質              ( 0:棒読みちゃん画面上の設定、1:女性1、2:女性2、3:男性1、4:男性2、5:中性、6:ロボット、7:機械1、8:機械2、10001~:SAPI5)
            byte code; //[10]   ( 8Bit) 文字列の文字コード( 0:UTF-8, 1:Unicode, 2:Shift-JIS)
            long len; //[11-14](32Bit) 文字列の長さ
            byte[] buf; // 文字列

            int pos = 0;
            command = (short)(data[pos++] + (data[pos++] << 8));
            speed = (short)(data[pos++] + (data[pos++] << 8));
            tone = (short)(data[pos++] + (data[pos++] << 8));
            volume = (short)(data[pos++] + (data[pos++] << 8));
            voice = (short)(data[pos++] + (data[pos++] << 8));
            code = data[pos++];
            len = data[pos++] + (data[pos++] << 8) +
                      (data[pos++] << 16) + (data[pos++] << 24);
            buf = new byte[len];
            for (int i = 0; i < len; i++)
            {
                buf[i] = data[pos++];
            }
            var text = Encoding.UTF8.GetString(buf);

            System.Diagnostics.Debug.WriteLine("command = {0}", command.ToString());
            System.Diagnostics.Debug.WriteLine("speed = {0}", speed.ToString());
            System.Diagnostics.Debug.WriteLine("tone = {0}", tone.ToString());
            System.Diagnostics.Debug.WriteLine("volume = {0}", volume.ToString());
            System.Diagnostics.Debug.WriteLine("voice = {0}", voice.ToString());
            System.Diagnostics.Debug.WriteLine("code = {0}", code.ToString());
            System.Diagnostics.Debug.WriteLine("len = {0}", len.ToString());
            System.Diagnostics.Debug.WriteLine("text = " + text);

            Pub.AddTalkTask(text, speed, tone, volume, (VoiceType)voice);

            client.Close();
        }
    }
}

#2.3. ビルドと配置
プロジェクトをビルドしてできたPlugin_BymChnWebSocket.dllを棒読みちゃんbouyomichan.exeと同じフォルダに格納します。棒読みちゃんを再起動するとプラグインがロードされます。

20180523_棒読みちゃんWebSocket_2.jpg

#■3. html & JavaScript

##3.1 html
コメントを入力し、送信ボタンをクリックしたら棒読みちゃんへ送信するようにします。

index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
    <script src="js/bouyomichan_client.js"></script>
    <script>
    /**
     * フォームが送信された
     */
    function onSubmit(e) {
        let cmntStr = $('#comment').val();
        console.log(cmntStr);

        // 棒読みちゃんにコメントを送信する
        let bouyomiChanClient = new BouyomiChanClient();
        bouyomiChanClient.talk(cmntStr);

        return false;
    }
    
    $(function() {
        $('#form').submit(onSubmit);
    });
    </script>
  </head>
  <body>
    <form method="get" id="form" action="javascript:void(0)">
      <input type="text" name="comment" id="comment" value="">
      <input type="submit" value="送信">
    </form>

  </body>

</html>

##3.2. JavaScript
makeBouyomiChanDataToSend()で棒読みちゃんに送信するバイナリデータ(Uint8Array)を生成し、WebSocket経由で棒読みちゃんにデータを送っています。

js/bouyomichan_client.js
/**
 * 接続先
 */
const HOST = 'localhost';
const PORT = 50002;  // WebSocketサーバー経由

/**
 * コンストラクタ
 */
const BouyomiChanClient = function() {
}

/**
 * 読み上げる
 */
BouyomiChanClient.prototype.talk = function(cmntStr) {
    this.cmntStr = cmntStr;
    let _socket = new WebSocket('ws://' + HOST + ':' + PORT + '/ws/');
    this.socket = _socket;
    this.socket.binaryType = 'arraybuffer';
    this.socket.onopen = this.socket_onopen.bind(this);
    this.socket.onerror = this.socket_onerror.bind(this);
    this.socket.onclose = this.socket_onclose.bind(this);
    this.socket.onmessage = this.socket_onmessage.bind(this);
}

/**
 * WebSocketが接続した
 */
BouyomiChanClient.prototype.socket_onopen = function(e) {
    console.log("socket_onopen");
    
    // 棒読みちゃんデータを生成
    let data = this.makeBouyomiChanDataToSend(this.cmntStr);
    console.log(data);
    // 送信
    this.socket.send(data.buffer);
}


/**
 * WebSocketが接続に失敗した
 */
BouyomiChanClient.prototype.socket_onerror = function(e) {
    console.log("socket_onerror");

    this.socket.close();
}

/**
 * WebSocketがクローズした
 */
BouyomiChanClient.prototype.socket_onclose = function(e) {
    console.log("socket_onclose");
}

/**
 * WebSocketがデータを受信した
 */
BouyomiChanClient.prototype.socket_onmessage = function(e) {
    console.log("socket_onmessage");
    console.log(e.data);

    // データ受信したら切断する
    this.socket.close();
}


/**
 * 棒読みちゃんへ送信するデータの生成
 */
BouyomiChanClient.prototype.makeBouyomiChanDataToSend = function(cmntStr) {
    let command = 0x0001; //[0-1]  (16Bit) コマンド          ( 0:メッセージ読み上げ)
    let speed = -1; //[2-3]  (16Bit) 速度              (-1:棒読みちゃん画面上の設定)
    let tone = -1; //[4-5]  (16Bit) 音程              (-1:棒読みちゃん画面上の設定)
    let volume = -1; //[6-7]  (16Bit) 音量              (-1:棒読みちゃん画面上の設定)
    let voice = 0; //[8-9]  (16Bit) 声質              ( 0:棒読みちゃん画面上の設定、1:女性1、2:女性2、3:男性1、4:男性2、5:中性、6:ロボット、7:機械1、8:機械2、10001~:SAPI5)
    let code = 0; //[10]   ( 8Bit) 文字列の文字コード( 0:UTF-8, 1:Unicode, 2:Shift-JIS)
    let len = 0; //[11-14](32Bit) 文字列の長さ

    let cmntByteArray = stringToUtf8ByteArray(cmntStr);
    
    len = cmntByteArray.length;
    let bytesLen = 2 + 2 + 2 + 2 + 2 + 1 + 4 + cmntByteArray.length;
    let data = new Uint8Array(bytesLen);
    let pos = 0;
    data[pos++] = command & 0xFF;
    data[pos++] = (command >> 8) & 0xFF;
    data[pos++] = speed & 0xFF;
    data[pos++] = (speed >> 8) & 0xFF;
    data[pos++] = tone & 0xFF;
    data[pos++] = (tone >> 8) & 0xFF;
    data[pos++] = volume & 0xFF;
    data[pos++] = (volume >> 8) & 0xFF;
    data[pos++] = voice & 0xFF;
    data[pos++] = (voice >> 8) & 0xFF;
    data[pos++] = code & 0xFF;
    data[pos++] = len & 0xFF;
    data[pos++] = (len >> 8) & 0xFF;
    data[pos++] = (len >> 16) & 0xFF;
    data[pos++] = (len >> 24) & 0xFF;
    for (let i = 0; i < cmntByteArray.length; i++) {
        data[pos++] = cmntByteArray[i];
    }
    return data;
}

///////////////////////////////////////////////////////////////////////////////////////
// Util
/**
 * string --> UTF8 byteArray変換
 */
function stringToUtf8ByteArray(str) {
    let out = [], p = 0;
    for (var i = 0; i < str.length; i++) {
        let c = str.charCodeAt(i);
        if (c < 128) {
            out[p++] = c;
        }
        else if (c < 2048) {
            out[p++] = (c >> 6) | 192;
            out[p++] = (c & 63) | 128;
        }
        else if (
            ((c & 0xFC00) == 0xD800) && (i + 1) < str.length &&
            ((str.charCodeAt(i + 1) & 0xFC00) == 0xDC00)) {

            // Surrogate Pair
            c = 0x10000 + ((c & 0x03FF) << 10) + (str.charCodeAt(++i) & 0x03FF);
            out[p++] = (c >> 18) | 240;
            out[p++] = ((c >> 12) & 63) | 128;
            out[p++] = ((c >> 6) & 63) | 128;
            out[p++] = (c & 63) | 128;
        }
        else {
            out[p++] = (c >> 12) | 224;
            out[p++] = ((c >> 6) & 63) | 128;
            out[p++] = (c & 63) | 128;
        }
    }
    return out;
}

20180523_棒読みちゃんWebSocket_1.jpg

#■まとめ

  • Web用途でJavaScriptからRaw Socketを使用する方法はなさそう。
  • 棒読みちゃんプラグインは.NET Framework 2.0をターゲットにする必要があります。
  • WebSocketサーバーをライブラリ無しで書く必要がありました。

#■作成したプラグイン
https://github.com/ryujimiya/Plugin_BymChnWebSocket

6
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?