...
 
Commits (9)
This diff is collapsed.
......@@ -11,30 +11,44 @@ class AppViewController: UIViewController {
override func loadView() {
super.loadView()
view.backgroundColor = .white
embed(initialViewController)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let finder = Finder()
finder.login()
private func performLogin(withAppleID appleID: String, password: String) {
let activityViewController = LoginActivityViewController(appleID: appleID, password: password)
activityViewController.onLogin = { [weak self] success, error in
guard let appViewController = self else { return }
switch success {
case true:
fatalError("success")
case false:
DispatchQueue.main.async {
let loginViewController = appViewController.newLoginFormViewController()
appViewController.transition(to: loginViewController)
}
}
}
transition(to: activityViewController)
}
finder.fetchDevices { devices, error in
os_log("got devices:")
devices?.forEach { os_log("%@: %@", $0.name, $0.identifier) }
// MARK: Boilerplate
if let alertDeviceName = ProcessInfo.processInfo.environment["FINDER_ALERT_NAME"], let alertDevice = devices?.first(where: { $0.name == alertDeviceName }) {
os_log("alerting %@: %@", alertDevice.name, alertDevice.identifier)
finder.alert(alertDevice)
}
lazy var initialViewController: UIViewController = {
return newLoginFormViewController()
}()
return
private func newLoginFormViewController() -> LoginFormViewController {
let loginViewController = LoginFormViewController()
loginViewController.submitAction = { [weak self] appleID, password in
self?.performLogin(withAppleID: appleID, password: password)
}
return loginViewController
}
@available(*, unavailable)
required init(coder: NSCoder) {
UIViewController.notImplementedInit()
type(of: self).notImplementedInit()
}
}
// Created by Geoff Pado on 4/8/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import Foundation
import Security
enum CredentialStorage {
static func store(appleID: String, password: String) {
guard let passwordData = password.data(using: .utf8) else { fatalError("Error generating password data") }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecAttrService as String: "com.cocoatype.Finder",
kSecAttrAccount as String: appleID,
kSecValueData as String: passwordData
]
SecItemAdd(query as CFDictionary, nil)
}
static var storedCredentials: (appleID: String, password: String)? {
var item: CFTypeRef?
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.cocoatype.Finder",
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true
]
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard
status == errSecSuccess,
let existingItem = item as? [String: Any],
let passwordData = existingItem[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: .utf8),
let appleID = existingItem[kSecAttrAccount as String] as? String
else { return nil }
return (appleID, password)
}
}
// Created by Geoff Pado on 4/7/18.
// Copyright (c) 2018 Cocoatype, LLC. All rights reserved.
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import Foundation
......
// Created by Geoff Pado on 4/7/18.
// Copyright (c) 2018 Cocoatype, LLC. All rights reserved.
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import Foundation
......
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import UIKit
extension UIViewController {
static func notImplementedInit() -> Never {
fatalError("\(String(describing: type(of: self))) does not implement init(coder:)")
}
func embed(_ newChild: UIViewController) {
if let existingChild = childViewControllers.first {
existingChild.willMove(toParentViewController: nil)
existingChild.view.removeFromSuperview()
existingChild.removeFromParentViewController()
}
guard let newChildView = newChild.view else { return }
newChildView.translatesAutoresizingMaskIntoConstraints = false
addChildViewController(newChild)
view.addSubview(newChildView)
newChild.didMove(toParentViewController: self)
NSLayoutConstraint.activate([
newChildView.widthAnchor.constraint(equalTo: view.widthAnchor),
newChildView.heightAnchor.constraint(equalTo: view.heightAnchor),
newChildView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
newChildView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
func transition(to newChild: UIViewController) {
UIView.transition(with: view, duration: 0.3, options: .transitionCrossDissolve, animations: { [unowned self] in self.embed(newChild) }, completion: nil)
}
}
......@@ -3,7 +3,7 @@
import UIKit
extension UIViewController {
extension UIView {
static func notImplementedInit() -> Never {
fatalError("\(String(describing: type(of: self))) does not implement init(coder:)")
}
......
// Created by Geoff Pado on 4/8/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import Foundation
extension UserDefaults {
static var suite: UserDefaults {
guard let suiteDefaults = UserDefaults(suiteName: "group.com.cocoatype.Finder") else { fatalError("Couldn't create defaults for suite") }
return suiteDefaults
}
var baseURL: URL? {
get { return url(forKey: DefaultsKeys.baseURL) }
set(newURL) { set(newURL, forKey: DefaultsKeys.baseURL) }
}
}
enum DefaultsKeys {
static let baseURL = "DefaultsKeys.baseURL"
}
\ No newline at end of file
......@@ -4,5 +4,13 @@
<dict>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.cocoatype.Finder</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.cocoatype.Finder</string>
</array>
</dict>
</plist>
/**
* LoginView.swift
*/
// Text for the label describing the Apple ID login text field
"LoginView.appleIDLabelText" = "Apple ID";
// Text for the label describing the password login text field
"LoginView.passwordLabelText" = "Password";
// Title for the button to log in
"LoginView.loginButtonTitle" = "Log In";
\ No newline at end of file
// Created by Geoff Pado on 4/8/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import UIKit
class LoginActivityViewController: UIViewController {
var onLogin: ((Bool, Error?) -> Void)?
init(appleID: String, password: String) {
self.appleID = appleID
self.password = password
super.init(nibName: nil, bundle: nil)
}
override func loadView() {
super.loadView()
view.backgroundColor = .white
let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(activityIndicator)
activityIndicator.startAnimating()
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let loginOperation = LoginOperation(appleID: appleID, password: password)
loginOperation.completionBlock = { [weak self, weak loginOperation] in
guard let loginOperation = loginOperation else { return }
guard let serviceURL = loginOperation.serviceURL else {
self?.onLogin?(false, loginOperation.error)
return
}
CredentialStorage.store(appleID: loginOperation.appleID, password: loginOperation.password)
UserDefaults.suite.baseURL = serviceURL
self?.onLogin?(true, loginOperation.error)
}
operationQueue.addOperation(loginOperation)
}
// MARK: Boilerplate
private let appleID: String
private let password: String
private let operationQueue: OperationQueue = {
let operationQueue = OperationQueue()
operationQueue.qualityOfService = .userInitiated
return operationQueue
}()
@available(*, unavailable)
required init(coder: NSCoder) {
type(of: self).notImplementedInit()
}
}
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import UIKit
class LoginButton: UIButton {
init(_ title: String) {
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
setTitleColor(tintColor, for: .normal)
setTitle(title, for: .normal)
}
// MARK: Boilerplate
override var intrinsicContentSize: CGSize { return CGSize(width: UIViewNoIntrinsicMetric, height: 44.0) }
@available(*, unavailable)
required init(coder: NSCoder) {
type(of: self).notImplementedInit()
}
}
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import UIKit
class LoginFormView: UIView {
var submitAction: ((String, String) -> Void)?
init() {
super.init(frame: .zero)
backgroundColor = .white
translatesAutoresizingMaskIntoConstraints = false
let appleIDLabel = LoginTextFieldLabel(LoginFormView.appleIDLabelText)
let passwordLabel = LoginTextFieldLabel(LoginFormView.passwordLabelText)
let appleIDTextField = LoginTextField()
appleIDTextField.keyboardType = .emailAddress
appleIDTextField.autocapitalizationType = .none
appleIDTextField.autocorrectionType = .no
let passwordTextField = LoginTextField()
passwordTextField.isSecureTextEntry = true
passwordTextField.autocapitalizationType = .none
passwordTextField.autocorrectionType = .no
let loginButton = LoginButton(LoginFormView.loginButtonTitle)
loginButton.addTarget(self, action: #selector(LoginFormView.submitForm), for: .touchUpInside)
let stackView = UIStackView(arrangedSubviews: [appleIDLabel, appleIDTextField, passwordLabel, passwordTextField, loginButton])
stackView.axis = .vertical
stackView.distribution = .equalSpacing
stackView.spacing = 8
stackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.widthAnchor.constraint(equalToConstant: 200),
stackView.centerXAnchor.constraint(equalTo: centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: centerYAnchor)
])
self.appleIDTextField = appleIDTextField
self.passwordTextField = passwordTextField
self.loginButton = loginButton
}
// MARK: Submission
@objc private func submitForm() {
guard
let appleID = appleIDTextField.text,
let password = passwordTextField.text
else { return }
submitAction?(appleID, password)
}
// MARK: Boilerplate
private weak var appleIDTextField: UITextField!
private weak var passwordTextField: UITextField!
private weak var loginButton: UIButton!
private static let appleIDLabelText = NSLocalizedString("LoginView.appleIDLabelText", comment: "Text for the label describing the Apple ID login text field")
private static let passwordLabelText = NSLocalizedString("LoginView.passwordLabelText", comment: "Text for the label describing the password login text field")
private static let loginButtonTitle = NSLocalizedString("LoginView.loginButtonTitle", comment: "Title for the button to log in")
@available(*, unavailable)
required init(coder: NSCoder) {
type(of: self).notImplementedInit()
}
}
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import UIKit
class LoginFormViewController: UIViewController {
var submitAction: ((String, String) -> Void)? {
get { return loginView.submitAction }
set(newAction) { loginView.submitAction = newAction }
}
override func loadView() {
super.loadView()
view = LoginFormView()
}
// MARK: Boilerplate
private weak var loginView: LoginFormView! { return view as? LoginFormView }
}
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import UIKit
class LoginTextField: UITextField {
init() {
super.init(frame: .zero)
borderStyle = .line
translatesAutoresizingMaskIntoConstraints = false
}
@available(*, unavailable)
required init(coder: NSCoder) {
type(of: self).notImplementedInit()
}
}
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import UIKit
class LoginTextFieldLabel: UILabel {
init(_ defaultText: String) {
super.init(frame: .zero)
font = UIFont.preferredFont(forTextStyle: .footnote)
translatesAutoresizingMaskIntoConstraints = false
text = defaultText
}
@available(*, unavailable)
required init(coder: NSCoder) {
type(of: self).notImplementedInit()
}
}
// Created by Geoff Pado on 4/7/18.
// Copyright (c) 2018 Cocoatype, LLC. All rights reserved.
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import Foundation
......@@ -9,7 +9,7 @@ class AlertOperation: Operation, URLSessionDataDelegate {
super.init()
}
override func start() {
guard let loginOperation = dependencies.first as? LoginOperation, let baseURL = loginOperation.serviceURL else { isFinished = true; return }
guard let baseURL = UserDefaults.suite.baseURL else { isFinished = true; return }
guard isCancelled == false else { isFinished = true; return }
self.baseURL = baseURL
......
// Created by Geoff Pado on 4/7/18.
// Copyright (c) 2018 Cocoatype, LLC. All rights reserved.
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import Foundation
......@@ -8,7 +8,7 @@ class FetchDevicesOperation: Operation, URLSessionDataDelegate {
private(set) var devices: [Device]?
override func start() {
guard let loginOperation = dependencies.first as? LoginOperation, let baseURL = loginOperation.serviceURL else { isFinished = true; return }
guard let baseURL = UserDefaults.suite.baseURL else { isFinished = true; return }
guard isCancelled == false else { isFinished = true; return }
self.baseURL = baseURL
......@@ -48,8 +48,7 @@ class FetchDevicesOperation: Operation, URLSessionDataDelegate {
// MARK: URLSessionDataDelegate
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
if isCancelled {
isFinished = true
localSessionTask?.cancel()
......
// Created by Geoff Pado on 4/7/18.
// Copyright (c) 2018 Cocoatype, LLC. All rights reserved.
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import os.log
import Foundation
......
// Created by Geoff Pado on 4/7/18.
// Copyright (c) 2018 Cocoatype, LLC. All rights reserved.
// Created by Geoff Pado on 4/7/18.
// Copyright © 2018 Cocoatype, LLC. All rights reserved.
import Foundation
class LoginOperation: Operation, URLSessionDataDelegate {
// return value
// return values
private(set) var error: Error?
private(set) var serviceURL: URL?
convenience override init() {
guard let (appleID, password) = CredentialStorage.storedCredentials else { fatalError("Could not locate stored credentials") }
self.init(appleID: appleID, password: password)
}
init(appleID: String, password: String) {
self.appleID = appleID
self.password = password
super.init()
}
override func start() {
if isCancelled {
isFinished = true
......@@ -17,18 +29,6 @@ class LoginOperation: Operation, URLSessionDataDelegate {
localSessionTask?.resume()
}
// MARK: Login Values
var appleID: String {
guard let appleID = ProcessInfo.processInfo.environment["FINDER_APPLE_ID"] else { fatalError("Please provide your Apple ID with the environment variable 'FINDER_APPLE_ID'.") }
return appleID
}
var password: String {
guard let password = ProcessInfo.processInfo.environment["FINDER_PASSWORD"] else { fatalError("Please provide your Apple ID with the environment variable 'FINDER_PASSWORD'.") }
return password
}
// MARK: URL Request
private static var url: URL = {
......@@ -55,8 +55,7 @@ class LoginOperation: Operation, URLSessionDataDelegate {
// MARK: URLSessionDataDelegate
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
if isCancelled {
isFinished = true
localSessionTask?.cancel()
......@@ -102,6 +101,9 @@ class LoginOperation: Operation, URLSessionDataDelegate {
// MARK: Boilerplate
let appleID: String
let password: String
private var _finished = false
override var isFinished: Bool {
get { return _finished }
......
<?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>com.apple.security.application-groups</key>
<array>
<string>group.com.cocoatype.Finder</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.cocoatype.Finder</string>
</array>
</dict>
</plist>
......@@ -3,23 +3,25 @@
import os.log
import Intents
import UIKit
class IntentHandler: INExtension, INSendMessageIntentHandling {
func resolveRecipients(for intent: INSendMessageIntent, with completion: @escaping ([INPersonResolutionResult]) -> Void) {
if let recipients = intent.recipients {
// If no recipients were provided we'll need to prompt for a value.
if recipients.count == 0 {
completion([INPersonResolutionResult.needsValue()])
return
}
let resolutionResults = recipients.map { INPersonResolutionResult.success(with: $0) }
completion(resolutionResults)
guard let recipients = intent.recipients else { return }
switch recipients.count {
case 0:
let currentDevice = INPerson(personHandle: INPersonHandle(value: UIDevice.current.name, type: .unknown), nameComponents: nil, displayName: UIDevice.current.name, image: nil, contactIdentifier: nil, customIdentifier: nil)
completion([INPersonResolutionResult.success(with: currentDevice)])
case 1:
completion(recipients.map { INPersonResolutionResult.success(with: $0) })
default:
completion([INPersonResolutionResult.disambiguation(with: recipients)])
}
}
func resolveContent(for intent: INSendMessageIntent, with completion: @escaping (INStringResolutionResult) -> Void) {
completion(INStringResolutionResult.success(with: "Find My iPhone Alert"))
completion(INStringResolutionResult.success(with: "Find My iPhone"))
}
func confirm(intent: INSendMessageIntent, completion: @escaping (INSendMessageIntentResponse) -> Void) {
......@@ -29,20 +31,24 @@ class IntentHandler: INExtension, INSendMessageIntentHandling {
}
func handle(intent: INSendMessageIntent, completion: @escaping (INSendMessageIntentResponse) -> Void) {
guard let alertDeviceName = intent.recipients?.first?.personHandle?.value else { return }
let finder = Finder()
let userActivity = NSUserActivity(activityType: NSStringFromClass(INSendMessageIntent.self))
finder.login()
finder.fetchDevices { devices, error in
os_log("got devices:")
devices?.forEach { os_log("%@: %@", $0.name, $0.identifier) }
if let alertDeviceName = ProcessInfo.processInfo.environment["FINDER_ALERT_NAME"], let alertDevice = devices?.first(where: { $0.name == alertDeviceName }) {
if let alertDevice = devices?.first(where: { $0.name == alertDeviceName }) {
os_log("alerting %@: %@", alertDevice.name, alertDevice.identifier)
finder.alert(alertDevice) {
let userActivity = NSUserActivity(activityType: NSStringFromClass(INSendMessageIntent.self))
let response = INSendMessageIntentResponse(code: .success, userActivity: userActivity)
completion(response)
}
} else {
completion(INSendMessageIntentResponse(code: .failure, userActivity: userActivity))
}
return
......
......@@ -2,18 +2,28 @@
An implementation of Find My iPhone for HomePod. For personal use only; I highly doubt this would be allowed in the App Store.
## Getting Started
Currently, Finder only works when run from Xcode. It uses environment variables to get your Apple ID and password in order to log into iCloud and hit the Find my iPhone API. In order to set this up:
1. Go to "Edit Scheme…" for the Finder or Intent scheme.
2. Select the "Run" step.
3. Select the "Arguments" tab.
4. Add the following environment variables:
- `FINDER_APPLE_ID`: Your iCloud Apple ID.
- `FINDER_PASSWORD`: The password to the above Apple ID.
- `FINDER_ALERT_NAME`: The name of the device you want to send an alert to.
Once you've added those environment variables, running either scheme will trigger the "play sound" effect for Find my iPhone for the device you've named in `FINDER_ALERT_NAME`.
## Using Finder
The user interface for Finder is currently a bit of a hack job. Here are the steps to set up the app for use with HomePod:
1. Launch Finder.
2. Enter your Apple ID and password for iCloud.
3. Tap the "Log In" button.
3. Dismiss any two-factor authentication prompts that appear.
4. If logging in was unsuccessful, the app will return to the login form.
5. If logging in was successful, the app will crash. Yes, that's a success. Hush.
At this point, you can now use Siri from either the device or HomePod to trigger Find my iPhone. The following command works best:
> Hey Siri, send a message using Finder.
This will trigger a "play sound" alert on the device that is set up to handle Personal Requests from HomePod, or the active device from any other iOS device.
The following command is also available, but currently doesn't work very well:
> Hey Siri, send a message to `<device name>` using Finder.
This allows you to send an alert to any device associated with your iCloud account. However, the detection of device names isn't very robust, and so getting the correct device (or any device) is tricky.
## License
......