Cordova:将浏览器 URL 共享到我的 iOS 应用(Clipper ios 共享扩展)

Cordova: sharing browser URL to my iOS app (Clipper ios share extension)

我想要的

在 Iphone 上,当在 Safari 或 Chrome 中访问网站时,可以将内容共享到其他应用程序。在这种情况下,您可以看到我可以将内容(基本上是 URL)共享到名为 Pocket 的应用程序。

可以吗?尤其是 Cordova?

编辑:迟早一个简单的移动网站可能会接收到本地应用程序共享的内容。检查 Web Share Target 协议

我正在回答我自己的问题,因为我们终于成功地为 Cordova 应用程序实施了 iOS 共享扩展。

首先,共享扩展系统仅适用于 iOS >= 8

然而,将它集成到 Cordova 项目中有点痛苦,因为没有特殊的 Cordova 配置可以这样做。创建共享扩展时,Cordova 团队很难 reverse-engineer XCode xproj 文件添加共享扩展,因此将来可能也很难...

您有 2 个选择:

  • 版本化一些 iOS 平台文件(如 xproj 文件)
  • 在使用 cordova
  • 生成 iOS 平台后包括一个手动过程

我们决定使用第二个选项,因为我们的扩展非常稳定,我们不会经常修改它。

手动创建共享扩展

非常重要:创建共享扩展,Action.js 通过 XCode 界面!它们必须在 xproj 文件中注册,否则根本无法工作。 See

通过XCode创建文件

要为 Cordova 应用程序创建共享扩展,您必须像任何 iOS developer would do

  • 在XCode
  • 上打开ios平台xproj
  • 文件 > 新建 > 目标 > 共享扩展
  • Select Swift 作为一种语言(只是因为 ObjC 对我来说似乎不愉快)

您会在 XCode 中获得一个新文件夹,其中包含您必须自定义的一些文件。

您还需要在该共享扩展文件夹中有一个额外的 Action.js 文件。创建一个新的空文件(通过XCode!)Action.js

处理浏览器数据提取

输入Action.js以下代码:

var Action = function() {};

Action.prototype = {

run: function(parameters) {
    parameters.completionFunction({"url": document.URL, "title": document.title });
},

finalize: function(parameters) {

}

};

var ExtensionPreprocessingJS = new Action

当您的共享扩展在浏览器上 select 编辑时(我认为它只适用于 Safari),此 JS 将 运行 并允许您检索您想要的数据在你的 Swift 控制器的那个页面上(这里我想要 url 和标题)。

自定义Info.plist

现在您需要自定义 Info.plist 文件来描述您正在创建哪种类型的共享扩展,以及您可以将哪种内容共享到您的应用程序。在我的例子中,我主要想分享 urls,所以这是一个用于从 Chrome 或 Safari 分享 urls 的配置。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
   <key>CFBundleDevelopmentRegion</key>
   <string>en</string>
   <key>CFBundleDisplayName</key>
   <string>MyClipper</string>
   <key>CFBundleExecutable</key>
   <string>$(EXECUTABLE_NAME)</string>
   <key>CFBundleIdentifier</key>
   <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
   <key>CFBundleInfoDictionaryVersion</key>
   <string>6.0</string>
   <key>CFBundleName</key>
   <string>$(PRODUCT_NAME)</string>
   <key>CFBundlePackageType</key>
   <string>XPC!</string>
   <key>CFBundleShortVersionString</key>
   <string>1.0</string>
   <key>CFBundleSignature</key>
   <string>????</string>
   <key>CFBundleVersion</key>
   <string>1</string>
   <key>NSExtension</key>
   <dict>
      <key>NSExtensionAttributes</key>
      <dict>
         <key>NSExtensionJavaScriptPreprocessingFile</key>
         <string>Action</string>
         <key>NSExtensionActivationRule</key>
         <dict>
            <key>NSExtensionActivationSupportsText</key>
            <true/>
            <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
            <integer>1</integer>
         </dict>
      </dict>
      <key>NSExtensionMainStoryboard</key>
      <string>MainInterface</string>
      <key>NSExtensionPointIdentifier</key>
      <string>com.apple.share-services</string>
   </dict>
</dict>
</plist>

请注意,我们在该 plist 文件中注册了 Action.js 文件。

自定义 ShareViewController.swift

通常您必须自己实施 Swift 视图,这些视图将 运行 在现有应用之上(对我来说在浏览器应用之上)。

默认情况下,控制器将提供您可以使用的默认视图,您可以从那里向后端执行请求。 Here is an example 我从中启发了自己这样做。

但就我而言,我不是 iOS 开发人员,我希望当用户 select 我的扩展程序时,它会打开我的应用程序而不是显示 iOS 视图。所以我使用 custom URL scheme 打开我的应用程序剪辑器:myAppScheme://openClipper?url=SomeUrl 这允许我在 HTML / JS 中设计我的剪辑器,而不必创建 iOS 视图。

请注意,我为此使用了 hack,Apple 可能会禁止在未来的 iOS 版本中从共享扩展打开您的应用程序。但是,此 hack 目前适用于 iOS 8.x 和 9.0.

这是代码。它适用于 Chrome 和 iOS.

上的 Safari
//
//  ShareViewController.swift
//  MyClipper
//
//  Created by Sébastien Lorber on 15/10/2015.
//
//

import UIKit
import Social
import MobileCoreServices

@available(iOSApplicationExtension 8.0, *)
class ShareViewController: SLComposeServiceViewController {

    let contentTypeList = kUTTypePropertyList as String
    let contentTypeTitle = "public.plain-text"
    let contentTypeUrl = "public.url"

    // We don't want to show the view actually
    // as we directly open our app!
    override func viewWillAppear(animated: Bool) {
        self.view.hidden = true
        self.cancel()
        self.doClipping()
    }

    // We directly forward all the values retrieved from Action.js to our app
    private func doClipping() {
        self.loadJsExtensionValues { dict in
            let url = "myAppScheme://mobileclipper?" + self.dictionaryToQueryString(dict)
            self.doOpenUrl(url)
        }
    }

    ///////////////////////////////////////////////////////////////////////////////////////////////
    ///////////////////////////////////////////////////////////////////////////////////////////////
    ///////////////////////////////////////////////////////////////////////////////////////////////

    private func dictionaryToQueryString(dict: Dictionary<String,String>) -> String {
        return dict.map({ entry in
            let value = entry.1
            let valueEncoded = value.stringByAddingPercentEncodingWithAllowedCharacters(.URLHostAllowedCharacterSet())
            return entry.0 + "=" + valueEncoded!
        }).joinWithSeparator("&")
    }

    // See https://github.com/extendedmind/extendedmind/blob/master/frontend/cordova/app/platforms/ios/extmd-share/ShareViewController.swift
    private func loadJsExtensionValues(f: Dictionary<String,String> -> Void) {
        let content = extensionContext!.inputItems[0] as! NSExtensionItem
        if (self.hasAttachmentOfType(content, contentType: contentTypeList)) {
            self.loadJsDictionnary(content) { dict in
                f(dict)
            }
        } else {
            self.loadUTIDictionnary(content) { dict in
                // 2 Items should be in dict to launch clipper opening : url and title.
                if (dict.count==2) { f(dict) }
            }
        }
    }

    private func hasAttachmentOfType(content: NSExtensionItem,contentType: String) -> Bool {
        for attachment in content.attachments as! [NSItemProvider] {
            if attachment.hasItemConformingToTypeIdentifier(contentType) {
                return true;
            }
        }
        return false;
    }

    private func loadJsDictionnary(content: NSExtensionItem,f: Dictionary<String,String> -> Void)  {
        for attachment in content.attachments as! [NSItemProvider] {
            if attachment.hasItemConformingToTypeIdentifier(contentTypeList) {
                attachment.loadItemForTypeIdentifier(contentTypeList, options: nil) { data, error in
                    if ( error == nil && data != nil ) {
                        let jsDict = data as! NSDictionary
                        if let jsPreprocessingResults = jsDict[NSExtensionJavaScriptPreprocessingResultsKey] {
                            let values = jsPreprocessingResults as! Dictionary<String,String>
                            f(values)
                        }
                    }
                }
            }
        }
    }


    private func loadUTIDictionnary(content: NSExtensionItem,f: Dictionary<String,String> -> Void) {
        var dict = Dictionary<String, String>()
        loadUTIString(content, utiKey: contentTypeUrl   , handler: { url_NSSecureCoding in
            let url_NSurl = url_NSSecureCoding as! NSURL
            let url_String = url_NSurl.absoluteString as String
            dict["url"] = url_String
            f(dict)
        })
        loadUTIString(content, utiKey: contentTypeTitle, handler: { title_NSSecureCoding in
            let title = title_NSSecureCoding as! String
            dict["title"] = title
            f(dict)
        })
    }


    private func loadUTIString(content: NSExtensionItem,utiKey: String,handler: NSSecureCoding -> Void) {
        for attachment in content.attachments as! [NSItemProvider] {
            if attachment.hasItemConformingToTypeIdentifier(utiKey) {
                attachment.loadItemForTypeIdentifier(utiKey, options: nil, completionHandler: { (data, error) -> Void in
                    if ( error == nil && data != nil ) {
                        handler(data!)
                    }
                })
            }
        }
    }


    // See https://whosebug.com/a/28037297/82609
    // Works fine for iOS 8.x and 9.0 but may not work anymore in the future :(
    private func doOpenUrl(url: String) {
        let urlNS = NSURL(string: url)!
        var responder = self as UIResponder?
        while (responder != nil){
            if responder!.respondsToSelector(Selector("openURL:")) == true{
                responder!.callSelector(Selector("openURL:"), object: urlNS, delay: 0)
            }
            responder = responder!.nextResponder()
        }
    }
}

// See https://whosebug.com/a/28037297/82609
extension NSObject {
    func callSelector(selector: Selector, object: AnyObject?, delay: NSTimeInterval) {
        let delay = delay * Double(NSEC_PER_SEC)
        let time = dispatch_time(DISPATCH_TIME_NOW, Int64(delay))
        dispatch_after(time, dispatch_get_main_queue(), {
            NSThread.detachNewThreadSelector(selector, toTarget:self, withObject: object)
        })
    }
}

请注意,有 2 种方法可以加载 Dictionary<String,String>。这是因为 Chrome 和 Safari 似乎以两种不同的方式提供 url 和页面标题。

自动化流程

您必须通过 XCode 界面创建共享扩展文件和 Action.js 文件。但是,一旦它们被创建(并在 XCode 中被引用),您就可以用您自己的文件替换它们。

因此我们决定将上述文件版本化在一个文件夹 (/cordova/ios-share-extension) 中,并用它们覆盖默认的共享扩展文件。

这并不理想,但我们使用的最低程序是:

  • 构建 Cordova iOS 平台(cordova prepare ios
  • 在 XCode
  • 中打开项目
  • 创建共享扩展(产品名称="MyClipper",语言="Swift",组织名称="MyCompany")
  • 在 "MyClipper" 上创建一个空文件 "Action.js"
  • /cordova/ios-share-extension的内容复制到cordova/platforms/ios/MyClipper

这样扩展就在 xproj 文件中正确注册了,但您仍然可以对扩展进行版本控制。

编辑 2017:使用 cordova-ios@5.0.0 可能会更容易设置所有内容,请参阅 https://issues.apache.org/jira/browse/CB-10218

上面的

doOpenUrl() 需要更新才能在 iOS 10 上运行。以下代码也适用于 iOS.

的旧版本
private func doOpenUrl(url: String) {

    let url = NSURL(string:url)
    let context = NSExtensionContext()
    context.open(url! as URL, completionHandler: nil)

    var responder = self as UIResponder?

    while (responder != nil){
        if responder?.responds(to: Selector("openURL:")) == true{
            responder?.perform(Selector("openURL:"), with: url)
        }
        responder = responder!.next
    }
}

跟进 Aaron Rosen 的 iOS 10 更新评论,以下是使其发挥作用的过程:

  1. 在 Sebastien Lorber 的原始答案的代码中,按照 Aaron 的建议更新 doOpenUrl 函数。为清楚起见,在此处重新发布:

    private func doOpenUrl(url: String) {
    let url = NSURL(string:url)
    let context = NSExtensionContext()
    context.open(url! as URL, completionHandler: nil)
    var responder = self as UIResponder?
    while (responder != nil){
        if responder?.responds(to: Selector("openURL:")) == true{
            responder?.perform(Selector("openURL:"), with: url)
        }
        responder = responder!.next
    }
    }
    
  2. 按照初始答案中概述的过程在 Xcode

  3. 中创建扩展
  4. Select ShareViewController.swift 在扩展文件夹中
  5. 转到编辑 > 转换 > 为当前 Swift 语法
  6. 在扩展构建设置中,将 "Require Only App-Extension-Safe API" 切换为否。

只有这样扩展才能工作。

使用此 cordova plugin,您应该能够以更少的手动工作实现您的目标。它也适用于 Android.

这是一个很好且仍然相关的问题。

我尝试使用 Jean-Christophe Hoelt 的 cordova-plugin-openwith,但遇到了几个问题。该插件旨在接收一种类型的共享项目(例如,URL、文本或图像),这是在安装期间配置的。此外,在当前的实现中,在 Cordova 应用程序中写一个共享和选择接收者的注释是不同(本机和 Cordova)上下文中的两个不同步骤,所以它对我来说并不是一个好的用户体验。

我对这个插件进行了这些和其他更正并将其作为一个单独的插件发布: https://github.com/EternallLight/cordova-plugin-openwith-ios

请注意,它仅适用于 iOS,不适用于 Android。