1# Copyright 2014 Christoph Reiter <reiter.christoph@gmail.com>
2#
3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation; either version 2 of the License, or
6# (at your option) any later version.
7
8from contextlib import contextmanager
9
10from gi.repository import GLib
11from gi.repository import Gio
12
13from quodlibet.util import print_d
14
15
16def alternative_service_name(name):
17    if "#" in name:
18        name, num = name.rsplit("#", 1)
19        num = int(num)
20    else:
21        num = 1
22    return "%s#%d" % (name, num + 1)
23
24
25class AvahiPublishFlags(object):
26    NONE = 0
27    UNIQUE = 1 << 0
28    NO_PROBE = 1 << 1
29    NO_ANNOUNCE = 1 << 2
30    ALLOW_MULTIPLE = 1 << 3
31    NO_REVERSE = 1 << 4
32    NO_COOKIE = 1 << 5
33    UPDATE = 1 << 6
34    USE_WIDE_AREA = 1 << 7
35    USE_MULTICAST = 1 << 8
36
37
38class AvahiEntryGroupState(object):
39    UNCOMMITED = 0
40    REGISTERING = 1
41    ESTABLISHED = 2
42    COLLISION = 3
43    FAILURE = 4
44
45
46class AvahiServerState(object):
47    INVALID = 0
48    REGISTERING = 1
49    RUNNING = 2
50    COLLISION = 3
51    FAILURE = 4
52
53
54class AvahiProtocol(object):
55    INET = 0
56    INET6 = 1
57    UNSPEC = -1
58
59
60AVAHI_IF_UNSPEC = -1
61
62
63class AvahiError(Exception):
64    pass
65
66
67@contextmanager
68def ignored(*exceptions):
69    try:
70        yield
71    except exceptions:
72        pass
73
74
75class AvahiService(object):
76    """Register a single network service using zeroconf/avahi
77
78    service = AvahiService()
79    service.register("foo", 4242, "_mpd._tcp")
80    service.register("foo", 2424, "_mpd._tcp")
81    service.unregister()
82
83    http://avahi.org/download/doxygen/
84    """
85
86    DBUS_NAME = "org.freedesktop.Avahi"
87    DBUS_PATH_SERVER = "/"
88    DBUS_INTERFACE_ENTRY_GROUP = "org.freedesktop.Avahi.EntryGroup"
89    DBUS_INTERFACE_SERVER = "org.freedesktop.Avahi.Server"
90
91    def register(self, name, port, stype):
92        """Register the service with avahi.
93
94        Can be called multiple times and will update the service entry
95        each time. In case Avahi isn't running or ready, the service
96        will be registered when it is ready.
97
98        Can raise AvahiError
99        """
100
101        try:
102            GLib.Variant('q', port)  # guint16
103        except OverflowError as e:
104            raise AvahiError(e)
105
106        self.name = name
107        self._real_name = name
108        self.port = port
109        self.stype = stype
110
111        try:
112            bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
113            if not self._watch:
114                self._watch = Gio.bus_watch_name_on_connection(
115                    bus, self.DBUS_NAME, Gio.BusNameWatcherFlags.NONE,
116                    self._owner_appeared, self._owner_vanished)
117            else:
118                self._try_update_service()
119        except GLib.Error as e:
120            raise AvahiError(e)
121
122    def unregister(self):
123        """Unregister the service.
124
125        Can be called multiple times. In case you want to update,
126        call register() with new data instead.
127
128        Will not raise.
129        """
130
131        if self._watch:
132            with ignored(GLib.Error):
133                Gio.bus_unwatch_name(self._watch)
134            self._watch = None
135
136        self._remove_server()
137
138    def __init__(self):
139        self.name = None
140        self.stype = None
141        self.port = None
142
143        self._group = None
144        self._group_id = None
145        self._server = None
146        self._server_id = None
147        self._watch = None
148        self._real_name = None
149        self._last_server_state = None
150
151    def _on_group_signal(self, proxy, sender, signal, *args):
152        if signal == 'StateChanged':
153            self._group_state_change(args[0])
154
155    def _group_state_change(self, state, *args):
156        if state == AvahiEntryGroupState.COLLISION:
157            self._real_name = alternative_service_name(self._real_name)
158            self._try_update_service()
159
160    def _group_add_service_and_commit(self, group, flags):
161        print_d("name=%s, flags=%x, stype=%s, port=%d" % (
162            self._real_name, flags, self.stype, self.port))
163        group.AddService('(iiussssqaay)',
164             AVAHI_IF_UNSPEC, AvahiProtocol.UNSPEC, flags,
165             self._real_name, self.stype, '', '', self.port, [])
166        group.Commit()
167
168    def _add_service(self):
169        assert not self._group
170        assert not self._group_id
171
172        try:
173            bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
174            server = Gio.DBusProxy.new_sync(
175                bus, Gio.DBusProxyFlags.NONE, None, self.DBUS_NAME,
176                self.DBUS_PATH_SERVER, self.DBUS_INTERFACE_SERVER, None)
177
178            group_path = server.EntryGroupNew()
179            group = Gio.DBusProxy.new_sync(
180                bus, Gio.DBusProxyFlags.NONE, None, self.DBUS_NAME,
181                group_path, self.DBUS_INTERFACE_ENTRY_GROUP, None)
182
183            self._group_id = group.connect('g-signal', self._on_group_signal)
184
185            self._group_add_service_and_commit(group, AvahiPublishFlags.NONE)
186            self._group = group
187        except GLib.Error:
188            self._remove_service()
189
190    def _try_update_service(self):
191        if not self._group:
192            return
193        assert self._group_id
194
195        try:
196            group = self._group
197            # XXX: http://markmail.org/message/b5d5wa2tdcplxpk2
198            # It's "documented" that Reset() shouldn't be called in this case
199            # but it doesn't work otherwise...
200            group.Reset()
201
202            self._group_add_service_and_commit(group, AvahiPublishFlags.UPDATE)
203        except GLib.Error:
204            self._remove_service()
205
206    def _remove_service(self):
207        if self._group:
208            if self._group_id:
209                with ignored(GLib.Error):
210                    self._group.disconnect(self._group_id)
211                self._group_id = None
212
213            with ignored(GLib.Error):
214                self._group.Free()
215            self._group = None
216
217    def _remove_server(self):
218        if self._server:
219            if self._server_id:
220                with ignored(GLib.Error):
221                    self._server.disconnect(self._server_id)
222                self._server_id = None
223            self._server = None
224
225        self._last_server_state = None
226
227        self._remove_service()
228
229    def _add_server(self):
230        assert not self._server_id
231
232        try:
233            server = Gio.DBusProxy.new_for_bus_sync(
234                Gio.BusType.SYSTEM, Gio.DBusProxyFlags.NONE, None,
235                self.DBUS_NAME, self.DBUS_PATH_SERVER,
236                self.DBUS_INTERFACE_SERVER, None)
237            self._server_id = server.connect('g-signal',
238                                             self._on_server_signal)
239            self._server_state_changed(server.GetState())
240            self._server = server
241        except GLib.Error:
242            self._remove_server()
243
244    def _on_server_signal(self, proxy, sender, signal, *args):
245        if signal == 'StateChanged':
246            self._server_state_changed(args[0])
247
248    def _server_state_changed(self, state, *args):
249        # protect from calling this twice in a row for the same state
250        # because we have to call this manually on start and can't
251        # be sure if the signal fires as well
252        if state == self._last_server_state:
253            return
254        self._last_server_state = state
255
256        if state == AvahiServerState.RUNNING:
257            self._add_service()
258        elif state in (AvahiServerState.COLLISION,
259                       AvahiServerState.REGISTERING):
260            self._remove_service()
261
262    def _owner_appeared(self, bus, name, owner):
263        self._add_server()
264
265    def _owner_vanished(self, bus, owner):
266        self._remove_server()
267