app_links icon indicating copy to clipboard operation
app_links copied to clipboard

chrome ignores deep link config on linux

Open bsutton opened this issue 1 year ago • 4 comments

Describe the bug

I'm trying to use app_link (6.1.1) to launch my flutter app when a user access a custom schema in chrome.

I've successfully added a .desktop file and registered a mime type.

cat ~/.local/share/applications/hmb.desktop 
[Desktop Entry]
Name=Hold My Beer deep links such as xero auth.
Comment=Launch Hold My Beer with deep linking URL
Path=<path to dart project>
Exec=flutter run -d linux <path to dart project>/lib/main.dart
Icon=web-browser
Terminal=false
Type=Application
Categories=Network;WebBrowser;
MimeType=x-scheme-handler/hmb;

xdg-mime reports:

xdg-mime query default x-scheme-handler/hmb
hmb.desktop

xdg-open correctly launches my app.

The problem is that when I enter hmb://test into the chrome address bar it does a search rather than launching my app.

Does it related to [x] linux deep link (?) [ ] App Links (Android)
[ ] Deep Links (Android)
[ ] Universal Links (iOS)
[ ] or Custom URL schemes? (iOS)

Does the example project work?

[ ] Yes
[ ] No
[x ] Irrelevant here

Did you fully read the instructions for the targeted platform before submitting this issue?

The instruction for linux are rather short on detail.

Uploaded your files to webserver, HTTPS, direct connection, scheme pattern setup, ...

  • not certain what you are asking here?

[ ] Yes
[ ] No
[ ] Irrelevant here

bsutton avatar Jun 24 '24 08:06 bsutton

So rather than trying to enter the schema into the address bar I created a test html page:

<html>

<body>
    <h1>Deep link below</h1>
    <a href="hmb:/xero/auth_callback">Click me to launch hmb</a>
</body>

</html>

This successfully launched my app however the applink listen isn't being called:

In my main I have:


void main(List<String> args) async {
  if (args.isNotEmpty) {
    print('Got a link $args');
  } else {
    print('no args');
  }

  WidgetsFlutterBinding.ensureInitialized();
  // await TimeMachine.initialize({'rootBundle': rootBundle});
  // final tzdb = await DateTimeZoneProviders.tzdb;
  // final currentTimeZone = await FlutterNativeTimezone.getLocalTimezone();
  // // log.info('Loading current timezone of [$currentTimeZone]');
  // await tzdb[currentTimeZone];

  /// Implement deep linking
  final _appLinks = AppLinks(); // AppLinks is singleton

// Subscribe to all events (initial link and further)
  _appLinks.uriLinkStream.listen((uri) {
    print('Hi from app link');
    HMBToast.notice(navigatorKey.currentContext!, 'Got a link $uri');
    if (uri.path == ('/xero/auth_callback')) {
      HMBToast.notice(navigatorKey.currentContext!, 'Some asked for xero');
    }
  });

  runApp(const MyApp());

Just in case my problems was around using a debug build I switch to launching a compiled version of my flutter app.

When I launch the app a second time, the second app doesn't launch (or rather I suspect it launches then immediately shuts down), however I can see that the focus returns to the first instance of the app,

What clearly doesn't happen is that the applink listener doesn't fire (I can see in the terminal window that I get xdg-open to launch the app via - and the print('Hi from app link'); isn't output.

Here is my current .desktop:

[Desktop Entry]
Name==Hold My Beer - I'm a handyman.
Comment=Launch Hold My Beer with deep linking URL
Path=/home/bsutton/git/handyman/handyman
#; Exec=flutter run --start-paused -d linux /home/bsutton/git/handyman/handyman/lib/main.dart ||  read -n1
#; Exec=gnome-terminal -e "bash -c 'flutter run --start-paused -d linux /home/bsutton/git/handyman/handyman/lib/main.dart;$SHELL'"
Exec=gnome-terminal -e "bash -c '/home/bsutton/git/handyman/handyman/build/linux/x64/release/bundle/handyman;$SHELL'"

Icon=web-browser
Terminal=true
Type=Application
Categories=Network;WebBrowser;
MimeType=x-scheme-handler/hmb;

bsutton avatar Jun 24 '24 09:06 bsutton

I don't have linux so I'm blind to help you here. But there's a new version 6.1.2 with dedicated package to linux platform that has just been released. It may worth a try.

llfbandit avatar Jun 25 '24 14:06 llfbandit

@llfbandit I've just upgraded to 6.1.4 but still not working. On launch I see this error:

Launching lib/main.dart on Linux in debug mode...
✓ Built build/linux/x64/debug/bundle/handyman
Gtk-Message: 17:32:43.858: Failed to load module "xapp-gtk3-module"
flutter: `app_links_linux` threw an error: 'package:flutter/src/services/platform_channel.dart': Failed assertion: line 579 pos 7: '_binaryMessenger != null || BindingBase.debugBindingType() != null': Cannot set the method call handler before the binary messenger has been initialized. This happens when you call setMethodCallHandler() before the WidgetsFlutterBinding has been initialized. You can fix this by either calling WidgetsFlutterBinding.ensureInitialized() before this or by passing a custom BinaryMessenger instance to MethodChannel().. The app may not function as expected until you remove this plugin from pubspec.yaml

It would appear that the plugin is taking an action before main is called as my main.dart contains:

void main(List<String> args) async {
  WidgetsFlutterBinding.ensureInitialized();

  if (args.isNotEmpty) {
    print('Got a link $args');
  } else {
    print('no args');
  }

  /// Implement deep linking
  final _appLinks = AppLinks(); // AppLinks is singleton

I'm wondering of the linux plug has some static initialiser that gets called as the app launches.

bsutton avatar Jul 05 '24 07:07 bsutton

app_links_platform_interface 2.0.2 and app_links_linux 1.0.3 have just been released to fix this. Feedback welcome!

llfbandit avatar Jul 05 '24 15:07 llfbandit

So my solution wasn't quite inline with my question.

It turns out that xero (the accounting package) doesn't support custom schemes so I had to pass a real url.

On desktop this means I had to run up a mini http server listening on 127.0.0.1. As you can't listen on ports below 1024 without running as sudo I selected a random port above that - 12335.

Then when I start the xero auth I start the http server up and it receives the redirect request from the browser.

So here is the code I wrote which uses app_link on mobiles and a local http server on desktop.

import 'dart:async';
import 'dart:io';

import 'redirect_handler.dart';
import 'xero_auth.dart';

/// Used to handle the xero auth
/// redirect on completion.
/// Starts a micro http server that handles the xero/auth_complete
/// request.
class LocalHostServer extends RedirectHandler {
  factory LocalHostServer() => _instance;

  // Private constructor with port configuration
  LocalHostServer._(this.port);

  factory LocalHostServer.self() => _instance;

  /// Note this port MUST match the port configured via
  /// https://developer.xero.com/ under the list
  /// fo Redirect URIs.
  /// http://localhost:12335/xero/auth_complete
  static int portNo = 12335;
  final int port;
  bool running = false;
  HttpServer? server;

  // Singleton instance for the server
  static final LocalHostServer _instance = LocalHostServer._(portNo);

  // Stream controller for managing subscriptions to auth notifications
  final StreamController<Uri> _authStreamController =
      StreamController<Uri>.broadcast();

  // Start the server
  @override
  Future<void> start() async {
    if (running) {
      return;
    }

    running = true;
    server = await HttpServer.bind(
      InternetAddress.loopbackIPv4,
      port,
    );
    print('Listening on http://${server!.address.host}:${server!.port}');

    await for (final HttpRequest request in server!) {
      if (request.uri.path == '/${XeroAuth2.redirectPath}') {
        // Notify all subscribers about the auth completion
        _authStreamController.add(request.requestedUri);

        // Send response to the browser
        request.response
          ..statusCode = HttpStatus.ok
          ..headers.contentType = ContentType.html
          ..write('''
<html><body><h1>Authentication Complete!</h1>
<p>You can return to HMB.</p></body></html>''');
        await request.response.close();
      } else {
        // Handle other paths or provide a 404
        request.response
          ..statusCode = HttpStatus.notFound
          ..write('404: Not Found');
        await request.response.close();
      }
    }
  }

  // Subscribe to the auth completion stream
  Stream<Uri> get onAuthComplete => _authStreamController.stream;

  @override
  Uri get redirectUri =>
      Uri.parse('http://localhost:$port/${XeroAuth2.redirectPath}');

  // Close the stream controller when no longer needed
  @override
  Future<void> stop() async {
    await _authStreamController.close();
    await server?.close(force: true);
  }

  @override
  Stream<Uri> get stream => onAuthComplete;
}

import 'dart:async';

import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart';

import '../redirect_handler.dart';
import '../xero_auth.dart';

class AppLinkRedirectHandler extends RedirectHandler {
  final appLinks = AppLinks();
  @override
  Future<void> start() async {}

  @override
  Uri get redirectUri {
    if (kIsWeb) {
      return Uri.parse('http://localhost:22433/redirect.html');
    }

    /// android and IOS
    return Uri.parse(
        'https://ivanhoehandyman.com.au/${XeroAuth2.redirectPath}');
  }

  @override
  Stream<Uri> get stream => appLinks.uriLinkStream;

  @override
  Future<void> stop() async {}
}

import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';

import 'local_host_server.dart';
import 'models/app_link_redirect_handler.dart';

abstract class RedirectHandler {
  Uri get redirectUri;

  Future<void> start();
  Future<void> stop();

  Stream<Uri> get stream;
}

RedirectHandler initRedirectHandler() {
  if (kIsWeb) {
    return AppLinkRedirectHandler();
  }
  if (Platform.isAndroid || Platform.isIOS) {
    return AppLinkRedirectHandler();
  }

  // desktop
  return LocalHostServer.self();
}

```dart

  Future<void> login() async {
    // If we have an existing client and it's not expired, use it.
    if (client != null && !client!.credentials.isExpired) {
      log('Access token is valid, no login required.');
      return;
    }

    // If the token is expired, attempt to refresh it.
    if (client != null && client!.credentials.isExpired) {
      try {
        log('Access token expired, attempting to refresh.');
        await refreshToken();
        log('Token refreshed successfully.');
        return;
        // ignore: avoid_catches_without_on_clauses
      } catch (e) {
        log('Token refresh failed: $e. Proceeding to full login.');
      }
    }

    final loginComplete = Completer<void>();

    final credentials = await _fetchCredentials();
    final authorizationEndpoint =
        Uri.parse('https://login.xero.com/identity/connect/authorize');
    final tokenEndpoint = Uri.parse('https://identity.xero.com/connect/token');

    final redirectHandler = initRedirectHandler();

    final redirectUri = redirectHandler.redirectUri;

    grant = oauth2.AuthorizationCodeGrant(
      credentials.clientId,
      authorizationEndpoint,
      tokenEndpoint,
      secret: credentials.clientSecret,
    );

    final authorizationUrl = grant!.getAuthorizationUrl(
      redirectUri,
      scopes: [
        'openid',
        'profile',
        'email',
        'offline_access',
        'accounting.transactions',
        'accounting.contacts',
      ],
    );

    await redirectHandler.start();

    late StreamSubscription<Uri> sub;
    sub = redirectHandler.stream.listen((uri) {
      if (uri.toString().startsWith(redirectHandler.redirectUri.toString())) {
        sub.cancel();
        redirectHandler.stop();
        log('applink - matched calling completeLogin');
        completeLogin(loginComplete, uri);
      }
    });

    if (await canLaunchUrl(authorizationUrl)) {
      await launchUrl(authorizationUrl);
    }

    return loginComplete.future;
  }

  Future<void> completeLogin(
      Completer<void> loginComplete, Uri responseUri) async {
    log('completeLogin with: $responseUri');
    if (grant == null) {
      log('grant not initialized');
      loginComplete.completeError('Grant not initialized');
      throw XeroException('Grant not initialized');
    }

    try {
      client =
          await grant!.handleAuthorizationResponse(responseUri.queryParameters);
      log('Login completed successfully');
      loginComplete.complete();
    } catch (e) {
      log('failed to complete login: $e');
      HMBToast.error('Failed to complete login: $e');
      loginComplete.completeError('Failed to complete login: $e');
    }
  }

bsutton avatar Nov 08 '24 08:11 bsutton