Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Linux location provider #2990

Open
sarayourfriend opened this issue Nov 25, 2024 · 5 comments · May be fixed by #2999
Open

Linux location provider #2990

sarayourfriend opened this issue Nov 25, 2024 · 5 comments · May be fixed by #2999
Labels
enhancement New features, or improvements to existing features. linux The issue relates Linux support.

Comments

@sarayourfriend
Copy link
Contributor

sarayourfriend commented Nov 25, 2024

What is the problem or limitation you are having?

Location services are not implemented for Linux.

Describe the solution you'd like

Using libgeoclue, it should be possible to implement a location provider for Linux, or at least for GTK. Qt looks like it might have its own approach to this.

Describe alternatives you've considered

Not implementing location services for Linux.

Additional context

I've done a bit of research on this today, but it's my first foray into Gtk GObject, Gio, all that stuff.

One up-front problem that exists is that Mozilla recently shut down its location service (MLS), so wifi location services won't work for the most common case (a typical consumer laptop). However, it will work fine if a hard-coded location is set on the OS (I managed to get this working locally), and presumably would work if using naive IP-based geolocation or if a real GPS module is available.

Work is underway to address MLS's shutdown, which is causing issues across GNOME's suite of apps: https://gitlab.freedesktop.org/geoclue/geoclue/-/issues/186

Beyond that, I'm having a difficult time sorting out how to watch for location changes. I'm pretty sure this comes down to my lack of experience working with DBus. I've tried to mimic the approach used by redshift but keep getting stuck. Edit: After reading a bit about dbus-python I have a strong suspicion the issue I ran into was due to me not connecting to the GLibMainLoop. There's a bit of discussion about how to integrate with DBus in #907. My main take away from that issue is to avoid a new dependency, if possible, so I'll keep trying to get things going with the regular python-gobject bindings.

Getting a one-off location is very easy using the "Simple" interface to Geoclue:

from gi.repository import Geoclue, Gio

cancellable = Gio.Cancellable()
simple = Geoclue.Simple.new_sync("sarayourfriend", Geoclue.AccuracyLevel.CITY, cancellable=cancellable)
location = simple.props.location
print(location.get_properties("latitude", "longitude", "altitude"))
# => (40.67174991864991, -74.0445531, 96.0)

(I've set my location manually to the example from man 5 geoclue, the Statue of Liberty's torch)

Just wanted to share what I've uncovered so far. In the worst case scenario, it would be possible to poll the location on an interval using the Simple API. Presumably that isn't to be preferred over getting a handle to a DBus signal for location update events. To complicate things, I have no way to test DBus location updates (MLS doesn't work, so I can't just physically move, and it doesn't seem to notify when I change /etc/geolocation). Again, I'm sure this is just my lack of experience. My hope is someone else might know how to move this forward and documenting this so far will "get the ball rolling" a bit 🙂

@sarayourfriend sarayourfriend added the enhancement New features, or improvements to existing features. label Nov 25, 2024
@freakboy3742 freakboy3742 added the linux The issue relates Linux support. label Nov 25, 2024
@freakboy3742
Copy link
Member

Thanks for that research. You're already miles ahead of where my understanding of the problem was!

@sarayourfriend
Copy link
Contributor Author

sarayourfriend commented Nov 26, 2024

After much documentation reading, and the help of one very useful example based on the Bluez DBus, I've got a working, and testable implementation of Geoclue in Python using just PyGObject:

import gi

gi.require_version("Geoclue", "2.0")
from gi.repository import Gio, GLib, Geoclue


class GeoclueDBusClient:
    def __init__(self):
        self.client = Geoclue.ClientProxy.create_full_sync(
            "org.freedesktop.GeoClue2",
            Geoclue.AccuracyLevel.CITY,
            Geoclue.ClientProxyCreateFlags.AUTO_DELETE,
            None,
        )

        self.cancel_start = Gio.Cancellable()

        self.client.connect("location-updated", self.on_location_updated)

        self.client.call_start(self.cancel_start, self.on_call_start)

    def on_call_start(self, client, task):
        self.client.call_start_finish(task)
        print("Started Geoclue connection")

    def on_location_updated(self, client, old_location, new_location):
        print("on_location_updated", old_location, new_location)
        self.current_location = Geoclue.LocationProxy.new_sync(
            client.props.g_connection,
            Gio.DBusProxyFlags.NONE,
            "org.freedesktop.GeoClue2",
            new_location,
            None,
        )
        print(self.current_location.get_properties("latitude", "longitude", "altitude"))


if __name__ == "__main__":
    mainloop = GLib.MainLoop()
    geoclue = GeoclueDBusClient()

    breakpoint()
    try:
        mainloop.run()
    except KeyboardInterrupt:
        mainloop.quit()

That script will print the location as it changes, and responds to modifications in /etc/geolocation. This alone confirms it is possible, and not even too messy, to interact directly with DBus for this, rather than leveraging the available libraries.

However, now I need to sort out how the GLib mainloop works in Toga applications. I haven't looked at all yet, but @freakboy3742 any tips on where I should start? My understanding is Toga applications already run with an asyncio loop, so I'm thinking it should be possible to spawn that GLib.MainLoop() and start it in a coroutine of the existing asyncio loop. Any ideas whether that is feasible? Scratch that. The Gtk application is, of course, already running a Gio mainloop, started when toga-gtk calls .run() on the Gtk.Application. I'll see whether the code above "just works" if I do some naive wiring...

@sarayourfriend
Copy link
Contributor Author

sarayourfriend commented Nov 26, 2024

Some more information, and progress towards the end goal 🎉

Here is a new implementation that uses Geoclue.Simple. It turns out you can watch for changes on Geoclue.Simple, through the generic GObject notify API. Once you have a Geoclue.Simple instance, simple.connect("notify::location", callback...) is all you need to get notified when the location changes, which can then be retrieved directly off the Geoclue.Simple instance.

Also, Geoclue.Simple will automatically do the "right thing" with respect to retrieving the location directly from DBus when available, or via the portal API when sandboxed (e.g., Flatpak). It removes a lot of complexity to use Geoclue.Simple!

I've collapsed this code block for readability, expand it to see a working implementation of the Toga helloworld app that will display the current location
"""
My first application
"""

from enum import StrEnum, auto
import toga
from toga.style import Pack
from toga.style.pack import COLUMN, ROW

from gi.repository import Geoclue, Gio, GLib


class _Geoclue:
    class State(StrEnum):
        INITIAL = auto()
        STOPPED = auto()
        ACTIVE = auto()
        FAILED = auto()
        DENIED = auto()

    def __init__(self, location_listener, state_listener):
        self.location_listener = location_listener
        self.state_listener = state_listener
        self.state = _Geoclue.State.INITIAL
        self.simple = None
        self.notify_location_listener = None
        self.notify_active_listener = None

    def start(self):
        def new_finish(object, async_result):
            try:
                self.simple = Geoclue.Simple.new_finish(async_result)
            except GLib.Error as e:
                if e.matches(Gio.DBusError, Gio.DBusError.ACCESS_DENIED):
                    # TODO: In practice, I cannot get this to occur. Maybe it is KDE's portal
                    # implementation, but when I deny location permissions in the prompt,
                    # the failure is a generic DBusError.FAILED, not a permission denied
                    # failure.
                    self.state = _Geoclue.State.DENIED
                else:
                    self.state = _Geoclue.State.FAILED
                    print("Failed to get location", e.message, e.code)

                return

            self.notify_location_listener = self.simple.connect(
                "notify::location", self.notify_location
            )

            self.state = _Geoclue.State.ACTIVE
            # Manually call to initialise the starting location
            self.update_location()

        Geoclue.Simple.new(
            "com.example.helloworld", Geoclue.AccuracyLevel.CITY, None, new_finish
        )

    def stop(self):
        if self.notify_location_listener is not None:
            self.simple.disconnect(self.notify_location_listener)
        self.state = _Geoclue.State.STOPPED

    @property
    def state(self):
        return self._state

    @state.setter
    def state(self, state):
        self._state = state
        if self.state_listener:
            self.state_listener(self._state)

    def notify_location(self, *args):
        self.update_location()

    def update_location(self):
        self.current_location = self.simple.get_location()
        self.location_listener(self.current_location)


class HelloWorld(toga.App):
    def startup(self):
        """Construct and show the Toga application.

        Usually, you would add your application to a main content box.
        We then create a main window (with a name matching the app), and
        show the main window.
        """
        main_box = toga.Box()
        self.label = toga.Label("Waiting...")
        self.state_label = toga.Label("Waiting...")
        main_box.add(self.label, self.state_label)

        self.main_window = toga.MainWindow(title=self.formal_name)
        self.main_window.content = main_box
        self.main_window.show()

        self.geoclue = _Geoclue(self.on_location_updated, self.on_state_updated)
        self.geoclue.start()

    def on_location_updated(self, location):
        self.label.text = (
            f"lat: {location.props.latitude}; lng: {location.props.longitude}"
        )

    def on_state_updated(self, state):
        self.state_label.text = f"State: {str(state)}"


def main():
    return HelloWorld()

Obviously there is a bit of work that will be needed to get that into a place that works correctly with toga's Location API, but all the building blocks of the basic functionality are there (watch for location changes, get the current location).

When running the application directly on the host, libgeoclue (whatever equivalents on each platform) will need to be in the system_requires for the app. Flatpak can't do that, of course, so consumers of the application will need to know to have Geoclue installed. KDE and GNOME install it by default, which are probably the likeliest consumers of Flatpak applications anyway.

I have not quite sorted out how permissions should work. When running on the host directly (not Flatpak), there's no permission management, as far as I can tell, you can always just get the location (this is a criticism of Geoclue, if you look around the web for it). Flatpak does isolate things, though, and KDE gives me a prompt for location permission. However, if I deny permission, the error is a generic DBusError.FAILED rather than the more sensible DBusError.ACCESS_DENIED. I don't know whether that's a KDE issue or if GNOME behaves the same way, I'll install GNOME and see what happens when I get back to this. Furthermore, I cannot see any obvious way to force a re-request of the location permission, or how you could tell from the code on subsequent requests that they failed because of access issues. Presumably the error should be access denied every time, but as I cannot get it even at the time I do deny access, it won't work.

Some of this permissions stuff is really Linux usability issues. For example, the only way I've found right now to undo the location request response is using Flatseal, despite the fact that KDE has its own Flatpak permissions pane in the system settings. I had a brief look at how Flatseal manages this, I presume it itself requires permissions that would defeat trying to respect the Flatpak sandbox anyway.

In the absolute worst case scenario, there is a bad user experience if someone rejects the location permission (they'd need to somehow find out to use Flatseal to undo that rejection). In the easiest case, there are no permissions to consider at all. In that sense, the permission probing is going to be kind of ugly, and with no great way to give feedback to the toga user so they can build a notice into their app regarding permissions. And Toga will need to deduce whether permissions are even relevant based on the environment.

Anyway, made a lot of progress today, and feeling very good about being able to get at least the most basic implementation of Toga's Location for Linux, even if it starts off with some very rough edges 🙂

@sarayourfriend
Copy link
Contributor Author

sarayourfriend commented Nov 26, 2024

I've tested on GNOME, and there is no difference in the deny/allow behaviour with respect to the error message emitted. It's a generic failure there as well. I've forced the only other error case I know how to trigger, which is to force the use of MLS, which in turn means Geoclue can't find a location. Unfortunately, Geoclue.Simple doesn't pick up on this, and instead the callback to Geoclue.Simple.new never gets called. It does not hang the thread, at least. I'll have to do some digging to see whether it's possible to detect this error case... but it might end up being another caveat on the pile of "desktop Linux usability issues" 😅

Quick update: Yep, seems like it's just something to deal with, at least based on this GNOME maps discussion: https://mail.gnome.org/archives/commits-list/2017-March/msg03936.html. The solution there was to track "starting" as a distinct state when getting a handle on Geoclue. That seems like a good enough start, Toga could eventually configure a timeout around that.

@sarayourfriend
Copy link
Contributor Author

Alright, a very rough and untested pass at this is available in this branch: https://github.com/sarayourfriend/toga/tree/add/gtk-geoclue-geolocation

It is completely untested aside from being based on my initial exploration in a helloworld app, so the next step for me is to actually wire it up to a helloworld app configured to use toga-gtk from my local environment.

Overall, I think the code is turning out okay, and GObject has been super fun to learn about 🙂. Feeling optimistic about finding a good endpoint.

@sarayourfriend sarayourfriend linked a pull request Nov 26, 2024 that will close this issue
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New features, or improvements to existing features. linux The issue relates Linux support.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants