iOS 18 Control Widget that opens a URL
Asked Answered
L

6

6

I already have an iOS 17 App Intent that works with a URL:

@available(iOS 16, *)
struct MyAppIntent: AppIntent {
    static let title : LocalizedStringResource = "My App Inent"
    static let openAppWhenRun   : Bool = true
    
    @MainActor
    func perform() async throws -> some IntentResult{
        await UIApplication.shared.open(URL(string: "myapp://myappintent")!)
        return .result()
    }
}

Now, with iOS 18 and Control Widgets, I want to create a Control Widget button that simply opens the app with the same URL. However, UIApplication code is not allowed within extensions. For this, Apple says to use OpenIntent which is shown here:

Documentation Link

Apple Sample Code from the link:

import AppIntents

struct LaunchAppIntent: OpenIntent {
    static var title: LocalizedStringResource = "Launch App"
    @Parameter(title: "Target")
    var target: LaunchAppEnum
}


enum LaunchAppEnum: String, AppEnum {
    case timer
    case history


    static var typeDisplayRepresentation = TypeDisplayRepresentation("Productivity Timer's app screens")
    static var caseDisplayRepresentations = [
        LaunchAppEnum.timer : DisplayRepresentation("Timer"),
        LaunchAppEnum.history : DisplayRepresentation("History")
    ]
}

WWDC session video about this does not cover this particular method in detail and also this sample code is a bit confusing.

So how can I alter this code to just open the app with a URL?

Lith answered 6/7 at 22:22 Comment(2)
import UIkit ? will work ?Flatto
Won't let you. Widget extensions are SwiftUI only.Lith
L
9

EDIT: It appears that custom URL schemes are not supported, even though it was working in earlier iOS 18 betas, as per this post

You'll have to implement Universal Links as a workaround.


ORIGINAL:

I'm making use of the OpenURLIntent, and it's working perfectly.

@available(iOS 18.0, watchOS 11.0, macOS 15.0, visionOS 2.0, *)
struct MyIntent: AppIntent {
    static let title: LocalizedStringResource = "My Intent"
    static var openAppWhenRun: Bool = true

    init() {}

    @MainActor
    func perform() async throws -> some IntentResult & OpensIntent {
        guard let url = URL(string: "myapp://myappintent") else {
            // throw an error of your choice here
        }

        return .result(opensIntent: OpenURLIntent(deepLink))
    }
}
Logarithm answered 16/7 at 23:56 Comment(11)
This is very close as of now. I am getting a not found on AppIntentsError.invalidURLLith
@Lith edited for clarity - that error is my own custom error that I've defined in my codebase. I'd recommend you do the same to fail gracefullyLogarithm
There is one thing, that is mentioned in Apple's documentation, and needs to be considered for this to work. The file where this Intent is in must have the App as target as well. Otherwise, the app won't open. ⌥ + ⌘ + 1 opens the File inspector. There, under Target Membership, add your App as Target.Stria
This works, but only opens the app for me. It doesn't seem to call the UIApplicationDelegate's "application(_:open:options:)" method, so I can't read the link string that was passed in.Hedley
Same for me @ZS. Did you find a solution?Faye
@ZS see my updated answer. Custom URL schemes are not officially supported by Control Widgets, even though they were working in earlier iOS 18 beta builds, and we have to use Universal Links instead.Logarithm
@NicoS. see above comment (I can't tag more than 1 person in a comment)Logarithm
@Logarithm any update on this? Beta 8 is out and my universal link is still not working...Faye
@NicoS.I don't know what your universal link implementation looks like. My universal links are working other than the fact they take the user to Safari first (this is an acknowledged Apple bug - i haven't tested on the latest beta though). I recommend asking a new question with some sample codeLogarithm
The universal link is not working for me either (also tried with the URL scheme). The app launches to the foreground due to the app intent and then it opens the browser.Noun
Apparently Apple fixed the issue in iOS 18.1. I couldn't test that yet, maybe someone else can verify that?Faye
P
4

XCode 16 RC. has bug with handling URLSchema. so you need just use EnvironmentValues().openURL() var to achieve correct handling your URL.

@available(iOS 18.0, watchOS 11.0, macOS 15.0, visionOS 2.0, *)
struct MyIntent: AppIntent {
    static let title: LocalizedStringResource = "My Intent"
    static var openAppWhenRun: Bool = true

    init() {}

    @MainActor
    func perform() async throws -> some IntentResult & OpensIntent {
        guard let url = URL(string: "myapp://myappintent") else {
            // throw an error of your choice here
        }
        // Here is the magic! 
        EnvironmentValues().openURL(url)

        return .result(opensIntent: OpenURLIntent(url))
    }
}

And of course you have to add ControlCenter file to your app target. It's a hack but it works for latest Xcode 16 RC. and the appDelegate method application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool getting called !

Just make sure that you also added your default app target to ControlCenter file enter image description here

Pushcart answered 12/9 at 15:37 Comment(5)
The open url: appDelegate method still not firing for me...Ink
works for me, but will this work for all device/users?Offbeat
what iOS 18 version?Ink
user7289922 I'm using Xcode 16 Version 16.0 (16A242) and iOS 18.0. I've tested it on simulators(iPad/iPhone) and on physical device iPhone 14 Pro Max (iOS 18.0)Pushcart
Guilherme I'm not sure it it's will work for all users but I've tested it on my iPhone and simulators and it's work, to be honest I think Apple will fix it in the next release of Xcode + iOS and we will not need this workaround - but for now it's good solution...Pushcart
F
3

I got feedback from Apple and they say they fixed the issue in iOS 18.1:

screenshot of written feedback from Apple engineer saying that iOS 18.1 fixes the issue

I couldn't verify it myself but the code below should work on iOS 18.1:

import AppIntents

@available(iOS 18, *)
struct NFCScanIntent: AppIntent {
    static var title: LocalizedStringResource = "Scan NFC"
    static var openAppWhenRun: Bool = true

    @MainActor
    func perform() async throws -> some IntentResult & OpensIntent {
        return .result(opensIntent: OpenURLIntent(URL(string: "https://nfc.cool/widget-scan-nfc")!))
    }
}

A workaround for iOS 18.0 is using NotificationCenter. But don't forget to add your Intent also to your app target, otherwise it won't work.

import AppIntents

@available(iOS 18, *)
struct NFCScanIntent: AppIntent {
    static var title: LocalizedStringResource = "Scan NFC"
    static var openAppWhenRun: Bool = true

    @MainActor
    func perform() async throws -> some IntentResult & OpensIntent {
        if #available(iOS 18.1, *) {
            return .result(opensIntent: OpenURLIntent(WidgetLinkType.scanNfc.universalLink))
        } else {
            NotificationCenter.default.post(name: .nfcScan, object: nil)
            return .result()
        }
    }
}
Faye answered 2/9 at 14:41 Comment(6)
not work on xcode 16.1 beta with deepLinkCheeks
Did you update your iPhone to 18.1 as well @Cheeks ?Faye
on the simulator, I don't own an iphone 15 proCheeks
@Cheeks any real iPhone that supports iOS 18.1 should be fine. Simulator behaviour is often different.Faye
@NFCcool OK thank you. I will try again when iOS 18.1 comes out for my iPhone 14 Pro tooCheeks
@Cheeks you can already install the beta on your iPhoneFaye
S
1

You just need to setup your ControlWidget first and then link your OpenIntent.

Something like:

struct OpenAppnButton: ControlWidget {
    var body: some ControlWidgetConfiguration {
        
        StaticControlConfiguration(
            kind: "your_id"
        ) {
            ControlWidgetButton(action: LaunchAppIntent()) { // <-- HERE
                Label("Something, image: "arrow.up")
            }
        }
        .displayName("Open app")
    }
}
Sweepback answered 15/7 at 14:29 Comment(1)
how have you configured OpenIntent?Cheeks
S
1

If you don't use universal links for your app, OpenURLIntent doesn't work. Refer to this link.

Note that you need to use a universal link for your URL representation, you can’t use a custom URL scheme. For more information about universal links, see Allowing apps and websites to link to your content.

That said, it seems that there are issues with universal links too.

To avoid using URLs, I first tried to perform a deep link type of action in the main app target inside perform() but ran into trouble. If the main app is open, whatever you do in perform() works. If the main app is closed, perform() is called before the app has had a chance to initialise properly and it is just a mess.

There is no app delegate method that allows you to handle "communication" from the widget in the main app target.

I wanted to stick to using a custom URL scheme. I opted to write the custom url to user defaults inside perform().

import AppIntents
import Foundation

@available(iOS 18, *)
struct OpenAppIntent: AppIntent {

    static var title: LocalizedStringResource = "Do Something"

    @Parameter(title: "Name")
    var someParameter: String?

    static var description = IntentDescription(
        "Do something with some parameter"
    )

    static var openAppWhenRun: Bool = true //false by default

    init() {}

    init(parameter: String) {
        self.someParameter = parameter
    }

    func perform() async throws -> some IntentResult & OpensIntent {
        let url = URL(string: "customscheme://dosomething?parameter=\(someParameter ?? "default")")!
        UserDefaults(suiteName: "SharedAppGroupName.SharedUserDefaults")?.set(url, forKey: "keyToUse")
        return .result(opensIntent: OpenURLIntent(url))
    }

}

I know the code still calls...

return .result(opensIntent: OpenURLIntent(url))

..which doesn't make sense because I'm using a custom url scheme and OpenURLIntent won't work. But if I just return .result(), the app doesn't open, so, I left the OpenURLIntent as is.

When the app becomes active I read the default to see if there is a deep link.

if let haveDeepLinkExternalUrl = UserDefaults(suiteName: "SharedAppGroupName.SharedUserDefaults")?.string(forKey: "keyToUse"), let url = URL(string: haveDeepLinkExternalUrl) {
    //process deep link in main app target here
}
//ALWAYS clear this value
UserDefaults(suiteName: "SharedAppGroupName.SharedUserDefaults")?.set(nil, forKey: "keyToUse")

This works if you read the user defaults from:

func applicationDidBecomeActive(_ application: UIApplication) {

or

func sceneDidBecomeActive(_ scene: UIScene) {

or both.

Splotch answered 27/9 at 8:53 Comment(1)
This was the route I eventually went, worked well for meKacey
D
1

If you just want to open your app, there's no need of Universal links or other things. The one thing it's not mentioned in Apple's docs, is that the enum you define, should only have one case, because App Intents are also meant to be used by Siri, and if there is more than one case, then it needs disambiguation to actually execute the intent. Here's my working code:

import AppIntents
import Foundation
import SwiftUI
import WidgetKit

@available(iOSApplicationExtension 16.0, iOS 16.0, *)
enum LaunchAppEnum: String, AppEnum {
  /// This is the important part, you should just have one case
  case home

  static var typeDisplayRepresentation = TypeDisplayRepresentation("BookPlayer Home")
  static var caseDisplayRepresentations = [
    LaunchAppEnum.home: DisplayRepresentation("Home")
  ]
}

/// Intent definition to be used in the widget
@available(iOSApplicationExtension 16, iOS 16.0, *)
struct LaunchAppIntent: OpenIntent {
  static var title: LocalizedStringResource = "Launch App"

  @Parameter(title: "Target")
  var target: LaunchAppEnum
}

/// Widget definition
@available(iOSApplicationExtension 18.0, iOS 18.0, *)
struct LaunchAppButton: ControlWidget {
  var body: some ControlWidgetConfiguration {
    StaticControlConfiguration(
      kind: "com.your.identifier.widget"
    ) {
      ControlWidgetButton(action: LaunchAppIntent()) {
        Label("Your app", image: "custom SF icon")
      }
    }
    .displayName("Your app")
  }
}
Denn answered 2/10 at 18:11 Comment(2)
This worked for me. The issue however is how do you set with the AppEnum where you want to open in the app?Kacey
@Kacey I looked into this for a bit, but couldn't find a delegate call that was triggered with the action, in my use case I don't really want to route, I just want the app launch, but I think you could look into adding a new parameter marked with @Dependency, and on your main app, add it with AppDependencyManager.shared.add(dependency:) so on your intent, you can record the value on the dependency in the perform function, and the main app will handle the change. I'm still looking into this for a separate thing I want to do though, so no concrete answers yet on my endDenn

© 2022 - 2024 — McMap. All rights reserved.