MarshallOfSound/Google-Play-Music-Desktop-Player-UNOFFICIAL-

View on GitHub
src/main/features/core/websocketAPI.js

Summary

Maintainability
C
1 day
Test Coverage
import { app } from 'electron';
import fs from 'fs';
import os from 'os';
import path from 'path';
import uuid from 'uuid';
import { spawnSync } from 'child_process';
import WebSocket, { Server as WebSocketServer } from 'ws';

let server;
let oldTime = {};
let uniqID = uuid.v4();

let runas = () => {};
if (process.platform === 'win32') {
  runas = require('runas');
}

let mdns;
try {
  mdns = require('mdns');
} catch (e) {
  Logger.error('Failed to load bonjour with error: %j', e);
  console.error('Bonjour is required to use Chromecast Support or to enable ZeroConf for the PlaybackAPI'); // eslint-disable-line
  if (process.platform === 'win32') {
    console.error('On windows you need to install Bonjour Print Services'); // eslint-disable-line
  } else if (process.platform === 'darwin') {
    console.error('On macOS Bonjour should "just work" so if you see this you have much bigger problems'); // eslint-disable-line
  } else {
    console.error('On linux you need to install "avahi"'); // eslint-disable-line
  }
  if (process.platform === 'win32') {
    Emitter.sendToWindowsOfName('main', 'bonjour-install');
  }
}

const changeEvents = ['track', 'state', 'rating', 'lyrics', 'shuffle', 'repeat', 'playlists', 'queue', 'search-results', 'library', 'volume'];
const API_VERSION = JSON.parse(fs.readFileSync(path.resolve(`${__dirname}/../../../../package.json`))).apiVersion;

let ad;
let authCode = Math.floor(Math.random() * 9999);
authCode = '0000'.substr(0, 4 - authCode.length) + authCode;
let connectClient;
let connectClientShouldReconnect = true;

changeEvents.forEach((channel) => {
  PlaybackAPI.on(`change:${channel}`, (newValue) => {
    if (server && server.broadcast) {
      server.broadcast(channel === 'state' ? 'playState' : channel, newValue);
    }
    if (connectClient) {
      connectClient.channel(channel === 'state' ? 'playState' : channel, newValue);
    }
  });
});

const settingsChangeEvents = ['themeColor', 'theme', 'themeType'];

settingsChangeEvents.forEach((channel) => {
  Settings.onChange(channel, (newValue) => {
    if (server && server.broadcast) {
      server.broadcast(`settings:${channel}`, newValue);
    }
    if (connectClient) {
      connectClient.channel(`settings:${channel}`, newValue);
    }
  });
});

PlaybackAPI.on('change:time', (timeObj) => {
  if (JSON.stringify(timeObj) !== JSON.stringify(oldTime)) {
    oldTime = timeObj;
    if (server && server.broadcast) {
      server.broadcast('time', timeObj);
    }
    if (connectClient) {
      connectClient.channel('time', timeObj);
    }
  }
});

const requireCode = (ws) => {
  authCode = Math.floor(Math.random() * 9999).toString();
  authCode = '0000'.substr(0, 4 - authCode.length) + authCode;
  // DEV: Always be 000 when testing
  authCode = Settings.__TEST__ ? '0000' : authCode;
  Emitter.sendToWindowsOfName('main', 'show:code_controller', {
    authCode,
  });
  ws.json({
    channel: 'connect',
    payload: 'CODE_REQUIRED',
  });
};

const sendInitialBurst = (ws) => {
  ws.channel('API_VERSION', API_VERSION);
  ws.channel('playState', PlaybackAPI.isPlaying());
  ws.channel('shuffle', PlaybackAPI.currentShuffle());
  ws.channel('repeat', PlaybackAPI.currentRepeat());
  ws.channel('queue', PlaybackAPI.getQueue());
  ws.channel('search-results', PlaybackAPI.getResults());
  ws.channel('volume', PlaybackAPI.getVolume());
  if (PlaybackAPI.currentSong(true)) {
    ws.channel('track', PlaybackAPI.currentSong(true));
    ws.channel('time', PlaybackAPI.currentTime());
    ws.channel('lyrics', PlaybackAPI.currentSongLyrics(true));
    ws.channel('rating', PlaybackAPI.getRating());
  }
  if (!Settings.__TEST__) {
    settingsChangeEvents.forEach((channel) => {
      ws.channel(`settings:${channel}`, Settings.get(channel));
    });
  }
  // We send library and playlists last as they take a while to stringify
  ws.channel('playlists', PlaybackAPI.getPlaylists());
  ws.channel('library', PlaybackAPI.getLibrary());
};

const addWSPrototypes = (ws) => {
  ws.json = (obj) => { // eslint-disable-line
    if (ws.readyState !== WebSocket.OPEN) return;
    ws.send(JSON.stringify(obj));
  };
  ws.channel = (channel, obj) => { // eslint-disable-line
    ws.json({
      channel,
      payload: obj,
    });
  };
};

const handleWSMessage = (ws) =>
  (data) => {
    try {
      const command = JSON.parse(data);
      if (command.type === 'disconnect') {
        connectClientShouldReconnect = false;
      }
      if (command.namespace && command.method) {
        const args = command.arguments || [];
        // Attempt to handle client connectection and authorization
        if (command.namespace === 'connect' && command.method === 'connect') {
          if (Settings.get('authorized_devices', []).indexOf(args[1]) > -1) {
            Emitter.sendToGooglePlayMusic('register_controller', {
              name: args[0],
            });
            ws.authorized = true; // eslint-disable-line
          } else if (args[1] === authCode) {
            const code = uuid.v4();
            Settings.set('authorized_devices', Settings.get('authorized_devices', []).concat([code]));
            Emitter.sendToWindowsOfName('main', 'hide:code_controller');
            ws.json({
              channel: 'connect',
              payload: code,
            });
          } else {
            requireCode(ws);
          }
          return;
        }
        if (command.namespace === 'inital_burst') {
          sendInitialBurst(ws);
          return;
        }
        // Attempt to execute the globa magical controller
        if (!Array.isArray(args)) {
          throw Error('Bad arguments');
        }
        if (!ws.authorized) {
          requireCode(ws);
          return;
        }
        Emitter.sendToGooglePlayMusic('execute:gmusic', {
          namespace: command.namespace,
          method: command.method,
          requestID: command.requestID || uniqID,
          args,
        });
        if (typeof command.requestID !== 'undefined') {
          Emitter.once(`execute:gmusic:result_${command.requestID}`, (event, result) => {
            ws.json(result);
          });
        }
        uniqID = uuid.v4();
      } else {
        throw Error('Bad command');
      }
    } catch (err) {
      console.log(err);
      Logger.error('WebSocketAPI Error: Invalid message recieved', { err, data });
    }
  };

if (Settings.get('__gpmdp_connect__')) {
  const reconnectConnectClient = () => {
    const email = Settings.get('gpmdp_connect_email');
    if (!email) return;
    if (connectClient) {
      connectClient.close();
      connectClient = null;
    }
    connectClient = new WebSocket('wss://connect.gpmdp.xyz');
    addWSPrototypes(connectClient);
    connectClient.on('open', () => {
      connectClient.json({ type: 'connect', email, clientType: 'player' });
      connectClient.on('message', handleWSMessage(connectClient));
    });
    connectClient.on('error', () => {});
    connectClient.on('close', () => {
      if (connectClientShouldReconnect) {
        Logger.warn('Attempting to reconnect to wss://connect.gpmdp.xyz');
        setTimeout(() => reconnectConnectClient(), 500);
      }
    });
  };
  Settings.onChange('gpmdp_connect_email', reconnectConnectClient);
  reconnectConnectClient();
}

const enableAPI = () => {
  let portOpen = true;
  if (process.platform === 'win32') {
    const testResult = spawnSync(
      'netsh',
      ['advfirewall', 'firewall', 'show', 'rule', 'name=GPMDP\ PlaybackAPI'] // eslint-disable-line
    );
    portOpen = testResult.status === 0;
  }
  if (!portOpen) {
    Emitter.once('openport:confirm', () => {
      runas(
        'netsh',
        [
          'advfirewall', 'firewall', 'add', 'rule', 'name=GPMDP\ PlaybackAPI', // eslint-disable-line
          'dir=in', 'action=allow', 'protocol=TCP', 'localport=5672',
        ],
        {
          admin: true,
        });
    });
    Emitter.sendToWindowsOfName('main', 'openport:request');
  }
  server = new WebSocketServer({
    host: process['env'].GPMDP_API_HOST || '0.0.0.0', // eslint-disable-line
    port: global.API_PORT || process['env'].GPMDP_API_PORT || 5672, // eslint-disable-line
  }, () => {
    if (ad) {
      ad.stop();
      ad = null;
    }

    try {
      ad = mdns.createAdvertisement(mdns.tcp('GPMDP'), global.API_PORT || process['env'].GPMDP_API_PORT || 5672, { // eslint-disable-line
        name: os.hostname(),
        txtRecord: {
          API_VERSION,
        },
      });

      ad.start();
    } catch (e) {
      Logger.error('Could not initialize bonjour service with error: %j', e);
    }
    if (ad) ad.on('error', () => {});

    server.broadcast = (channel, data) => {
      server.clients.forEach((client) => {
        client.channel(channel, data);
      });
    };

    server.on('connection', (websocket) => {
      const ws = websocket;

      addWSPrototypes(ws);

      ws.on('message', handleWSMessage(ws));

      // Send initial PlaybackAPI Values
      sendInitialBurst(ws);
    });
  });

  server.on('error', () => {
    Emitter.sendToWindowsOfName('main', 'error', {
      title: 'Could not start Playback API',
      message: 'The playback API attempted (and failed) to start on port 5672.  Another application is probably using this port',  // eslint-disable-line
    });
    server = null;
  });
};

Emitter.on('playbackapi:toggle', (event, state) => {
  if (!state.state && server) {
    server.close();
    server = null;
  }
  if (state.state) {
    if (!server) {
      enableAPI();
    }
  } else if (ad) {
    ad.stop();
    ad = null;
  }
  Settings.set('playbackAPI', state.state);
});

app.on('will-quit', () => {
  if (server) {
    server.close();
    server = null;
  }
});

if (Settings.get('playbackAPI', false)) {
  enableAPI();
}