From 0fa42166f798d2c93afbbbfe158d960e585f9977 Mon Sep 17 00:00:00 2001 From: Alex Tsitsimpis Date: Tue, 1 Jan 2019 20:46:21 +0200 Subject: [PATCH] Add basic MPRIS support on Linux (#451) * Add basic MPRIS support for Linux Add an option to register StreamKeys as an MPRIS player in DBus on Linux. This is optional and currently requires single player mode to be enabled. Some advantages of using MPRIS on Linux are: * Desktop integration, e.g. on Gnome users should be able to see and control StreamKeys from the notifications panel. * Media keys working without having to set them in globals. In fact, it is better to not use them when MPRIS is used, as chrome hijacks the media keys when they are global, resulting in no other program being able to use them. Signed-off-by: Alex Tsitsimpis * bandcamp: Add support to get cover art Signed-off-by: Alex Tsitsimpis * Add MPRIS host installation script Add an installation script and instructions on how to install the MPRIS host. The process is (for now at least) manual. Signed-off-by: Alex Tsitsimpis * mpris: Support updating position and track length Signed-off-by: Alex Tsitsimpis --- README.md | 28 ++ code/html/options.html | 6 + code/js/background.js | 148 ++++++ code/js/controllers/BandcampController.js | 13 +- code/js/modules/Sitelist.js | 3 + code/js/options.js | 27 ++ code/manifest.json | 3 +- code/native/mpris.py | 532 ++++++++++++++++++++++ code/native/mpris_host_setup.py | 138 ++++++ 9 files changed, 896 insertions(+), 2 deletions(-) create mode 100755 code/native/mpris.py create mode 100755 code/native/mpris_host_setup.py diff --git a/README.md b/README.md index 34f27e6c..1594741b 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,34 @@ To run the tests locally, simply $ npm test ``` +## Linux MPRIS support + +On Linux you can enable basic MPRIS support in options. Currently this requires +`single player mode` to be enabled. It requires an extra host script to be +installed. + +#### Install host script + +To install the host script, locate the extension ID from the Chrome extensions page +and run the following commands: + +```bash +$ extension_id="....." +$ installer=$(find $HOME/.config -name "mpris_host_setup.py" | grep ${extension_id}) +$ python3 ${installer} install ${extension_id} +``` + +#### Uninstall host script + +To uninstall the host script, locate the extension ID from the Chrome extensions page +and run the following commands: + +```bash +$ extension_id="....." +$ installer=$(find $HOME/.config -name "mpris_host_setup.py" | grep ${extension_id}) +$ python3 ${installer} uninstall +``` + ## License (MIT) Copyright (c) 2018 Alex Gabriel under the MIT license. diff --git a/code/html/options.html b/code/html/options.html index bbf9abce..48380687 100644 --- a/code/html/options.html +++ b/code/html/options.html @@ -138,6 +138,12 @@

General Settings

+

+ + +

diff --git a/code/js/background.js b/code/js/background.js index e26b6be0..d89275a1 100644 --- a/code/js/background.js +++ b/code/js/background.js @@ -151,6 +151,7 @@ if(request.action === "inject_controller") { console.log("Inject: " + request.file + " into: " + sender.tab.id); chrome.tabs.executeScript(sender.tab.id, {file: request.file}); + if (mprisPort) mprisPort.postMessage({ command: "add_player" }); } if(request.action === "check_music_site") { /** @@ -172,6 +173,7 @@ stateData: request.stateData, fromTab: sender.tab }); + if (mprisPort) handleStateData(updateMPRISState); } if(request.action === "get_music_tabs") { var musicTabs = window.skSites.getMusicTabs(); @@ -274,4 +276,150 @@ window.skSites = new Sitelist(); window.skSites.loadSettings(); }); + + + /** + * MPRIS support + */ + var connections = 0; + var mprisPort = null; + + var hmsToSecondsOnly = function(str) { + var p = str.split(":"); + var s = 0; + var m = 1; + + while (p.length > 0) { + s += m * parseInt(p.pop(), 10); + m *= 60; + } + + return s; + }; + + var handleNativeMsg = function(msg) { + switch(msg.command) { + case "play": + case "pause": + case "playpause": + sendAction("playPause"); + break; + case "stop": + sendAction("stop"); + break; + case "next": + sendAction("playNext"); + break; + case "previous": + sendAction("playPrev"); + break; + default: + console.log("Cannot handle native message command: " + msg.command); + } + }; + + /** + * Get the state of the player that a command will end up affecting and pass + * it to a function to handle them, along with the tab that corresponds to + * that state data. + * For "single player mode" (which is required for MPRIS support), a command + * will end up affecting the best tab if there are active tabs but no + * playing tabs, or all playing tabs if there are playing tabs (see + * sendActionSinglePlayer). With that in mind: + * - If there is no active tab, then the state and the tab are null. + * - If there are active tabs but no playing tabs, use the best tab. + * - If there are any playing tabs, just use the state of the best playing + * tab. The command will be sent to all playing tabs anyway. + */ + var handleStateData = function(func) { + var activeMusicTabs = window.skSites.getActiveMusicTabs(); + activeMusicTabs.then(function(tabs) { + if (_.isEmpty(tabs)) { + func(null, null); + } else { + var bestTab = null; + var playingTabs = getPlayingTabs(tabs); + if (_.isEmpty(playingTabs)){ + bestTab = getBestSinglePlayerTab(tabs); + } else { + bestTab = getBestSinglePlayerTab(playingTabs); + } + + func(tabStates[bestTab.id].state, bestTab); + } + }); + }; + + /** + * If stateData is null, then state is stopped with NoTrack. Otherwise update + * with the state of the player that a command will end up affecting. + */ + var updateMPRISState = function(stateData, tab) { + if (stateData === null) { + mprisPort.postMessage({ command: "remove_player" }); + } else { + var metadata = { + "mpris:trackid": stateData.song ? tab.id : null, + "xesam:title": stateData.song, + "xesam:artist": stateData.artist ? [stateData.artist.trim()] : null, + "xesam:album": stateData.album, + "mpris:artUrl": stateData.art, + "mpris:length": hmsToSecondsOnly((stateData.totalTime || "").trim()) * 1000000 + }; + var args = [{ "CanGoNext": stateData.canPlayNext, + "CanGoPrevious": stateData.canPlayPrev, + "PlaybackStatus": (stateData.isPlaying ? "Playing" : "Paused"), + "CanPlay": stateData.canPlayPause, + "CanPause": stateData.canPlayPause, + "Metadata": metadata, + "Position": hmsToSecondsOnly((stateData.currentTime || "").trim()) * 1000000}]; + + mprisPort.postMessage({ command: "update_state", args: args }); + } + }; + + /** + * Connect to the native messaging host for MPRIS support + */ + chrome.storage.sync.get(function(obj) { + + if (obj.hasOwnProperty("hotkey-use_mpris") && obj["hotkey-use_mpris"]) { + if (!connections) { + connections += 1; + console.log("Starting native messaging host"); + mprisPort = chrome.runtime.connectNative("org.mpris.streamkeys_host"); + mprisPort.onMessage.addListener(handleNativeMsg); + + chrome.runtime.onSuspend.addListener(function() { + if (!--connections) + mprisPort.postMessage({ command: "quit" }); + mprisPort.onMessage.removeListener(handleNativeMsg); + mprisPort.disconnect(); + }); + + /** + * When a music tab is removed, we must remove it from tabStates and + * update the state of the MPRIS player. + */ + chrome.tabs.onRemoved.addListener(function(tabId) { + if (tabStates.hasOwnProperty(tabId)) { + delete tabStates[tabId]; + handleStateData(updateMPRISState); + } + }); + + /** + * When the active tab changes, the best single tab might change too. + * Thus we need to update the state of the MPRIS player. + */ + chrome.tabs.onActivated.addListener(function(activeInfo) { + if (tabStates.hasOwnProperty(activeInfo.tabId)) { + handleStateData(updateMPRISState); + } + }); + + } + } + }); + })(); diff --git a/code/js/controllers/BandcampController.js b/code/js/controllers/BandcampController.js index 1d18f400..0c68f2a1 100644 --- a/code/js/controllers/BandcampController.js +++ b/code/js/controllers/BandcampController.js @@ -3,7 +3,7 @@ var BaseController = require("BaseController"); - new BaseController({ + var controller = new BaseController({ siteName: "Bandcamp", playPause: ".playbutton", playNext: ".nextbutton", @@ -12,10 +12,21 @@ playState: ".playbutton.playing", song: "a.title_link > span.title", artist: "[itemprop=byArtist]", + art: ".popupImage", hidePlayer: true, currentTime: ".time_elapsed", totalTime: ".time_total" }); + + + controller.getArtData = function(selector) { + var dataEl = this.doc().querySelector(selector); + if(dataEl && dataEl.attributes && dataEl.attributes.href) { + return dataEl.attributes.href.value; + } + + return null; + }; })(); diff --git a/code/js/modules/Sitelist.js b/code/js/modules/Sitelist.js index 55c44d52..97656cd3 100644 --- a/code/js/modules/Sitelist.js +++ b/code/js/modules/Sitelist.js @@ -237,6 +237,9 @@ if(!obj.hasOwnProperty("hotkey-open_on_update")) { chrome.storage.sync.set({ "hotkey-open_on_update": true }); } + if(!obj.hasOwnProperty("hotkey-use_mpris")) { + chrome.storage.sync.set({ "hotkey-use_mpris": false }); + } if(!obj.hasOwnProperty("hotkey-youtube_restart")) { chrome.storage.sync.set({ "hotkey-youtube_restart": false }); } diff --git a/code/js/options.js b/code/js/options.js index 8405b000..b1220e9b 100644 --- a/code/js/options.js +++ b/code/js/options.js @@ -27,6 +27,10 @@ var OptionsViewModel = function OptionsViewModel() { }); }; + chrome.runtime.getPlatformInfo(function(platformInfo){ + self.supportsMPRIS = (platformInfo.os === chrome.runtime.PlatformOs.LINUX); + }); + // Load localstorage settings into observables chrome.storage.sync.get(function(obj) { self.openOnUpdate = ko.observable(obj["hotkey-open_on_update"]); @@ -34,6 +38,28 @@ var OptionsViewModel = function OptionsViewModel() { chrome.storage.sync.set({ "hotkey-open_on_update": value }); }); + self.useMPRIS = ko.observable(obj["hotkey-use_mpris"]); + self.useMPRIS.subscribe(function(value) { + if (value) { + chrome.permissions.contains({ + permissions: ["nativeMessaging"], + }, function (alreadyHaveNativeMessagingPermissions) { + if (alreadyHaveNativeMessagingPermissions) { + chrome.storage.sync.set({ "hotkey-use_mpris": value }); + } + else { + chrome.permissions.request({ + permissions: ["nativeMessaging"], + }, function (granted) { + chrome.storage.sync.set({ "hotkey-use_mpris": granted }); + }); + } + }); + } else { + chrome.storage.sync.set({ "hotkey-use_mpris": value }); + } + }); + self.youtubeRestart = ko.observable(obj["hotkey-youtube_restart"]); self.youtubeRestart.subscribe(function(value) { chrome.storage.sync.set({ "hotkey-youtube_restart": value }); @@ -42,6 +68,7 @@ var OptionsViewModel = function OptionsViewModel() { self.singlePlayerMode = ko.observable(obj["hotkey-single_player_mode"]); self.singlePlayerMode.subscribe(function(value) { chrome.storage.sync.set({ "hotkey-single_player_mode": value }); + if (!value) self.useMPRIS(false); }); self.settingsInitialized(true); diff --git a/code/manifest.json b/code/manifest.json index 22e513f7..8241ec16 100644 --- a/code/manifest.json +++ b/code/manifest.json @@ -30,7 +30,8 @@ "js/controllers/*" ], "permissions": ["tabs", "storage", "http://*/*", "https://*/*"], - "optional_permissions": [ "notifications", "http://*/*", "https://*/*"], + "optional_permissions": [ "notifications", "http://*/*", "https://*/*", + "nativeMessaging"], "commands": { "playPause": { "suggested_key": { diff --git a/code/native/mpris.py b/code/native/mpris.py new file mode 100755 index 00000000..c98cb120 --- /dev/null +++ b/code/native/mpris.py @@ -0,0 +1,532 @@ +#!/usr/bin/env python3 + +import sys +import enum +import json +import struct +import logging + +from pydbus import SessionBus +from pydbus.generic import signal +from collections import namedtuple +from gi.repository import GLib, Gio + +log = logging.getLogger(__name__) + +NO_TRACK = "/org/mpris/MediaPlayer2/TrackList/NoTrack" + + +class PlaybackStatus(enum.Enum): + """Supported playback statuses.""" + + PLAYING = "Playing" + PAUSED = "Paused" + STOPPED = "Stopped" + + +# Class for the org.mpris.MediaPlayer2 interface +class Root(object): # noqa + """ + + + + + + + + + + + + + + + + + + + + + + + + """ # noqa + + PropertiesChanged = signal() + + def __init__(self, can_quit=False, can_raise=False, + can_set_fullscreen=False, has_tracklist=False, + supported_mime_types=None, supported_uri_schemes=None, + fullscreen=False, identity="MediaPlayer2", desktop_entry=None, + **kwargs): + super().__init__(**kwargs) + self.CanQuit = can_quit + self.CanRaise = can_raise + self.HasTrackList = has_tracklist + self.SupportedMimeTypes = (supported_mime_types + if supported_mime_types is not None else []) + self.SupportedUriSchemes = (supported_uri_schemes + if supported_uri_schemes is not None else []) # noqa: E501 + + self.CanSetFullscreen = can_set_fullscreen + self.Fullscreen = fullscreen + self.DesktopEntry = desktop_entry + + self.Identity = identity + + def Raise(self): # noqa: N802 + pass + + def Quit(self): # noqa: N802 + if not self.CanQuit: + return + + +# Class for the org.mpris.MediaPlayer2.Player interface +class Player(object): # noqa + """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ # noqa + + Seeked = signal() + + def __init__(self, loop_status="None", metadata=None, position=0, + playback_status=PlaybackStatus.STOPPED.value, rate=1, + shuffle=False, volume=1, can_control=True, can_pause=True, + can_play=True, can_seek=False, minimum_rate=1, maximum_rate=1, + can_go_next=True, can_go_previous=True, **kwargs): + super().__init__(**kwargs) + self._loop_status = loop_status + if metadata is None: + metadata = { + "mpris:trackid": GLib.Variant("o", NO_TRACK) + } + self.Metadata = metadata + self.Position = position + + self.PlaybackStatus = playback_status + self.Rate = rate + self.Shuffle = shuffle + self.Volume = volume + self.CanControl = can_control + self.CanPlay = can_play + self.CanPause = can_pause + self.CanSeek = can_seek + self.CanGoNext = can_go_next + self.CanGoPrevious = can_go_previous + self.MinimumRate = minimum_rate + self.MaximumRate = maximum_rate + + @property + def LoopStatus(self): # noqa: N802 + return self._loop_status + + @LoopStatus.setter + def LoopStatus(self, value): # noqa: N802 + if not self.CanControl: + raise ValueError + + def Next(self): # noqa: N802 + if self.CanControl and self.CanGoNext: + raise NotImplementedError + + def OpenUri(self, uri): # noqa: N802 + pass + + def Pause(self): # noqa: N802 + if self.CanControl and self.CanPause: + raise NotImplementedError + + def Play(self): # noqa: N802 + if self.CanControl and self.CanPlay: + raise NotImplementedError + + def PlayPause(self): # noqa: N802 + if self.CanControl and self.CanPause: + raise NotImplementedError + if not self.CanPause: + raise NotImplementedError + + def Previous(self): # noqa: N802 + if self.CanControl and self.CanGoPrevious: + raise NotImplementedError + + def Seek(self, offset): # noqa: N802 + if self.CanControl and self.CanSeek: + raise NotImplementedError + + def SetPosition(self, trackid, position): # noqa: N802 + if self.CanControl and self.CanSeek: + raise NotImplementedError + + def Stop(self): # noqa: N802 + raise NotImplementedError + + +class MediaPlayer(Player, Root): + """A class representing a generic MPRIS media player.""" + + # Used by pydbus + dbus = [Root.__doc__, Player.__doc__] + + # The name of the media player. Subclasses should set that. + name = None + + # The annotation that indicates if a signal should be emited when a + # property changes. + CHANGED_ANNOT = "org.freedesktop.DBus.Property.EmitsChangedSignal" + + def __setattr__(self, name, value): + """Override to set the DBus object properties and emit signals.""" + if name == "_properties": + return super().__setattr__(name, value) + + props = [p.name for i in self._properties for p in self._properties[i]] + if name in props: + self.set_properties({name: value}) + else: + super().__setattr__(name, value) + + def __init__(self, name=None, **kwargs): + # NOTE: Ensure this is always first, as ther attribute accesses might + # fail due to __setattr__ needing self._properties. + self._properties = {} + + self.name = name if name is not None else self.name + if self.name is None: + raise ValueError("Attribute `name' must be set") + + super().__init__(**kwargs) + + # Initialize _properties, a dict that maps DBus interfaces to lists of + # DBus properties. + node_info = [Gio.DBusNodeInfo.new_for_xml(i) for i in self.dbus] + interfaces = sum((ni.interfaces for ni in node_info), []) + + for iface in interfaces: + self._properties[iface] = [ + p for p in iface.properties + if p.flags & Gio.DBusPropertyInfoFlags.READABLE + ] + + @staticmethod + def _patch_metadata(metadata, player_name): + """Patch the metadata. + + Replace metadata values with GLib.Variants. Also handle the trackId. + """ + metadata_types = { + "mpris:trackid": "o", + "mpris:length": "x", + "mpris:artUrl": "s", + "xesam:url": "s", + "xesam:title": "s", + "xesam:artist": "as", + "xesam:album": "s", + } + + # Handle track id + if "mpris:trackid" in metadata: + if metadata["mpris:trackid"] is None: + trackid = NO_TRACK + else: + trackid = (("/mpris/%s/tracks/" % player_name) + + str(metadata["mpris:trackid"]).lstrip("/")) + metadata["mpris:trackid"] = trackid + + # Patch metadata, replacing values with GLib.Variants. + patched_metadata = { + k: GLib.Variant(metadata_types[k], v) for k, v in metadata.items() + if k in metadata_types and v is not None + } + return patched_metadata + + def set_properties(self, changed_props): + """Set interface properties, emitting signals when needed. + + We use this method to set attributes that are interface properties + to be able to correctly emit signals. Otherwise a simple `attr = value' + would suffice. We also handle the Metadata attribute. + """ + if "Metadata" in changed_props: + changed_props["Metadata"] = ( + self._patch_metadata(changed_props["Metadata"], self.name)) + + for iface, props in self._properties.items(): + iface_changed_props = {} + props_to_signal = {} + for p in props: + if p.name in changed_props: + iface_changed_props[p.name] = changed_props[p.name] + + for a in p.annotations: + if (a.key == self.CHANGED_ANNOT + and a.value.lower() == "false"): + break + else: + # We didn't found the CHANGED_ANNOT or we found it and + # it was not "false". We should signal for this + # property. + props_to_signal[p.name] = changed_props[p.name] + + # NOTE; Use super().__setattr__() since self.__setattr__() calls us + for name, value in iface_changed_props.items(): + super().__setattr__(name, value) + + # Emit signal for changed properties + if props_to_signal: + self.PropertiesChanged(iface.name, props_to_signal, []) + + def publish(self, bus=None): + """Publish the media player to DBus.""" + if bus is None: + bus = SessionBus() + + pub = bus.publish("org.mpris.MediaPlayer2.%s" % self.name, + ("/org/mpris/MediaPlayer2", self)) + return (bus, pub) + + +class StreamKeysMPRIS(MediaPlayer): + """The StreamKeys MPRIS media player.""" + + def __init__(self): + super().__init__(name="streamkeys", + identity="Chrome StreamKeys extension") + + def Pause(self): # noqa: N802 + status = PlaybackStatus(self.PlaybackStatus) + if self.CanPause and status == PlaybackStatus.PLAYING: + send_msg(Message(command=Command.PAUSE)) + + def Play(self): # noqa: N802 + status = PlaybackStatus(self.PlaybackStatus) + if self.CanPlay and status != PlaybackStatus.PLAYING: + send_msg(Message(command=Command.PLAY)) + + def PlayPause(self): # noqa: N802 + if self.CanPause: + status = PlaybackStatus(self.PlaybackStatus) + if status in [PlaybackStatus.PAUSED, PlaybackStatus.STOPPED]: + send_msg(Message(command=Command.PLAY)) + elif status == PlaybackStatus.PLAYING: + send_msg(Message(command=Command.PAUSE)) + + def Next(self): # noqa: N802 + if self.CanGoNext: + send_msg(Message(command=Command.NEXT)) + + def Previous(self): # noqa: N802 + if self.CanGoPrevious: + send_msg(Message(command=Command.PREVIOUS)) + + def Stop(self): # noqa: N802 + send_msg(Message(command=Command.STOP)) + + +def make_streams_binary(): + # Messages should be in UTF-8, preceded with 32-bit message length. + sys.stdin = sys.stdin.detach() + sys.stdout = sys.stdout.detach() + + +def encode_msg(msg): + """Encode a message before sending it.""" + # Each message is serialized using JSON, UTF-8 encoded and is preceded with + # 32-bit message length in native byte order. + try: + text = json.dumps(msg) + except ValueError: + return 0 + + data = text.encode("utf-8") + length_bytes = struct.pack("@i", len(data)) + return length_bytes + data + + +class Message(namedtuple("Message", ["command", "args"])): + """A class represending a message.""" + + # Maintain the efficiency of a named tuple + __slots__ = () + + def __new__(cls, command, args=None): + # add default values + args = [] if args is None else args + return super(Message, cls).__new__(cls, command, args) + + +def send_msg(msg): + """Send a message to the extension.""" + enc_msg = encode_msg({"command": msg.command.value, "args": msg.args}) + written = sys.stdout.write(enc_msg) + # We flush to make sure that Chrome gets the message *right now* + sys.stdout.flush() + return written + + +def recv_msg(): + """Receive a message from the extension.""" + # Each message is serialized using JSON, UTF-8 encoded and is preceded with + # 32-bit message length in native byte order. + # Read the message length (first 4 bytes). + length_bytes = sys.stdin.read(4) + if len(length_bytes) < 4: + raise ValueError("unexpected end of input") + + # Unpack message length as 4 byte integer. + length = struct.unpack("@i", length_bytes)[0] + + # Read the text (JSON object) of the message. + text = sys.stdin.read(length).decode("utf-8") + msg = json.loads(text) + command = msg["command"] + args = msg.get("args", None) + return Message(command=command, args=args) + + +class Command(enum.Enum): + """Supported commands in messages.""" + + PLAY = "play" # no args + PAUSE = "pause" # no args + PLAYPAUSE = "playpause" # no args + STOP = "stop" # no args + NEXT = "next" # no args + PREVIOUS = "previous" # no args + UPDATE_STATE = "update_state" # args: List of a dict with updated state + SEEK = "seek" # args: The number of microseconds to seek forward/backwards + ADD_PLAYER = "add_player" # no args + REMOVE_PLAYER = "remove_player" # no args + QUIT = "quit" # no args + + +def setup_logger(level=logging.DEBUG): + log = logging.getLogger() + log.setLevel(level) + stream = logging.StreamHandler(sys.stdout) + stream.setLevel(level) + log.addHandler(stream) + return log + + +class StreamKeysDBusService(object): + + def __init__(self): + self.bus = None + self.player = None + self.dbus_pub = None + self.loop = None + + def run(self): + self.loop = GLib.MainLoop() + chan = GLib.IOChannel.unix_new(sys.stdin.fileno()) + GLib.io_add_watch(chan, GLib.IOCondition.IN, self.message_handler) + GLib.io_add_watch(chan, GLib.IOCondition.HUP, + lambda *_: self.loop.quit()) + self.loop.run() + + def add_player(self): + if not self.player: + self.player = StreamKeysMPRIS() + self.bus, self.dbus_pub = self.player.publish() + + def remove_player(self): + if self.dbus_pub: + self.dbus_pub.unpublish() + self.player = None + self.dbus_pub = None + + def message_handler(self, chan, condition): + try: + msg = recv_msg() + cmd = Command(msg.command) + + if cmd == Command.QUIT: + self.loop.quit() + elif cmd == Command.ADD_PLAYER: + self.add_player() + elif cmd == Command.REMOVE_PLAYER: + self.remove_player() + elif cmd in [Command.PLAY, Command.PAUSE, Command.STOP, + Command.NEXT, Command.PREVIOUS]: + func = getattr(self.player, cmd.value.capitalize()) + func() + elif cmd == Command.PLAYPAUSE: + self.player.PlayPause() + elif cmd == Command.UPDATE_STATE: + self.player.set_properties(*msg.args) + elif cmd == Command.SEEK: + self.player.Seeked(*msg.args) + else: + raise NotImplementedError("Cannot handle command `%s'" % cmd) + except Exception as e: # noqa: F841 + # FIXME: stdout/stderr is detached + # log.exception(e) + pass + + # Return true, otherwise GLib will remove our watch + return True + + +def main(): + global log + make_streams_binary() + log = setup_logger() + StreamKeysDBusService().run() + + +if __name__ == "__main__": + main() diff --git a/code/native/mpris_host_setup.py b/code/native/mpris_host_setup.py new file mode 100755 index 00000000..7ec7b4f0 --- /dev/null +++ b/code/native/mpris_host_setup.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +import os +import sys +import json +import logging +import argparse + +log = logging.getLogger(__name__) + +HOST_FILENAME = "streamkeys_mpris.py" +HOST_MANIFEST_FILENAME = "org.mpris.streamkeys_host.json" +HOST_MANIFEST = { + "name": "org.mpris.streamkeys_host", + "description": "Streamkeys MPRIS native messaging host", + "path": None, + "type": "stdio", + "allowed_origins": [] +} +XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME", + default=os.path.expanduser("~/.config")) + + +def initialize_parser(): + parser = argparse.ArgumentParser() + parser.add_argument("--dir", dest="install_dir", + default=os.path.join(XDG_CONFIG_HOME, "streamkeys"), + help="The directory to install the host script") + subparsers = parser.add_subparsers(title="Commands", metavar="") + + p = subparsers.add_parser("install", + help="Install the native messaging host") + p.add_argument("id", help="The extension ID") + p.set_defaults(func=main_install) + + p = subparsers.add_parser("uninstall", + help="Uninstall the native messaging host") + p.set_defaults(func=main_uninstall) + return parser + + +def get_xdg_config_paths(): + return [os.path.join(XDG_CONFIG_HOME, "chromium"), + os.path.join(XDG_CONFIG_HOME, "google-chrome")] + + +def install_host(ext_id, install_dir): + # Copy the host script + host_path = os.path.join(install_dir, HOST_FILENAME) + os.makedirs(install_dir, exist_ok=True) + src = os.path.join(os.path.dirname(os.path.realpath(__file__)), "mpris.py") + with open(src, "rb") as f: + data = f.read() + with open(host_path, "wb") as f: + f.write(data) + os.chmod(host_path, 0o744) + + # Create the manifest file + xdg_paths = get_xdg_config_paths() + manifest = dict(HOST_MANIFEST) + manifest["path"] = host_path + manifest["allowed_origins"].append("chrome-extension://%s/" % ext_id) + for path in xdg_paths: + if not os.path.exists(path): + continue + message_hosts = os.path.join(path, "NativeMessagingHosts") + manifest_path = os.path.join(message_hosts, HOST_MANIFEST_FILENAME) + + os.makedirs(message_hosts, exist_ok=True) + with open(manifest_path, "w") as f: + json.dump(manifest, f, indent=2) + os.chmod(manifest_path, 0o644) + + +def uninstall_host(install_dir): + # Remove host script + host_path = os.path.join(install_dir, HOST_FILENAME) + if os.path.isfile(host_path): + os.remove(host_path) + + # Remove manifest file + xdg_paths = get_xdg_config_paths() + for path in xdg_paths: + if not os.path.exists(path): + continue + message_hosts = os.path.join(path, "NativeMessagingHosts") + manifest_path = os.path.join(message_hosts, HOST_MANIFEST_FILENAME) + if os.path.isfile(manifest_path): + os.remove(manifest_path) + + +def setup_logger(level=logging.DEBUG): + log = logging.getLogger() + log.setLevel(level) + stream = logging.StreamHandler(sys.stdout) + stream.setLevel(level) + log.addHandler(stream) + return log + + +def main(): + global log + + parser = initialize_parser() + args = parser.parse_args() + log = setup_logger() + + return args.func(args) + + +def main_install(args): + # Chrome's extension IDs are in hexadecimal but using a-p, referred + # internally as "mpdecimal". See https://stackoverflow.com/a/2050916 + if (len(args.id) != 32 + or any(ord(c) not in range(97, 113) for c in args.id)): + raise RuntimeError("Not valid extension ID: %s" % args.id) + + try: + from gi.repository import GLib, Gio # noqa: F401 + except ImportError: + raise RuntimeError("Required dependency `python3-gobject' not" + " found") + + try: + import pydbus # noqa: F401 + except ImportError: + raise RuntimeError("Required dependency `python3-pydbus' not" + " found") + + install_host(args.id, args.install_dir) + + +def main_uninstall(args): + uninstall_host(args.install_dir) + + +if __name__ == "__main__": + sys.exit(main())