Implement Slack authentication on iOS

This commit is contained in:
Conrad Kramer 2024-03-30 16:47:59 -07:00
parent ec8cc533ab
commit e0fcc3ee09
10 changed files with 419 additions and 81 deletions

View file

@ -35,7 +35,6 @@ runs:
-authenticationKeyID ${{ inputs.app-store-key-id }} \ -authenticationKeyID ${{ inputs.app-store-key-id }} \
-authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \ -authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \
-authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \ -authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \
-onlyUsePackageVersionsFromResolvedFile \
-scheme '${{ inputs.scheme }}' \ -scheme '${{ inputs.scheme }}' \
-destination '${{ inputs.destination }}' \ -destination '${{ inputs.destination }}' \
-archivePath '${{ inputs.archive-path }}' \ -archivePath '${{ inputs.archive-path }}' \

View file

@ -15,10 +15,6 @@ inputs:
export-path: export-path:
description: The path to export the archive to description: The path to export the archive to
required: true required: true
outputs:
notarized-app:
description: The compressed and notarized app
value: ${{ steps.notarize.outputs.notarized-app }}
runs: runs:
using: composite using: composite
steps: steps:
@ -28,31 +24,28 @@ runs:
run: | run: |
echo "${{ inputs.app-store-key }}" > AuthKey_${{ inputs.app-store-key-id }}.p8 echo "${{ inputs.app-store-key }}" > AuthKey_${{ inputs.app-store-key-id }}.p8
echo '{"destination":"upload","method":"developer-id"}' \ echo '{"destination":"export","method":"developer-id"}' \
| plutil -convert xml1 -o ExportOptions.plist - | plutil -convert xml1 -o ExportOptions.plist -
xcodebuild \ xcodebuild -exportArchive \
-exportArchive \
-allowProvisioningUpdates \ -allowProvisioningUpdates \
-allowProvisioningDeviceRegistration \ -allowProvisioningDeviceRegistration \
-skipPackagePluginValidation \
-skipMacroValidation \
-onlyUsePackageVersionsFromResolvedFile \
-authenticationKeyID ${{ inputs.app-store-key-id }} \ -authenticationKeyID ${{ inputs.app-store-key-id }} \
-authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \ -authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \
-authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \ -authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \
-archivePath '${{ inputs.archive-path }}' \ -archivePath Wallet.xcarchive \
-exportPath Release \
-exportOptionsPlist ExportOptions.plist -exportOptionsPlist ExportOptions.plist
until xcodebuild \ ditto -c -k --keepParent Release/Wallet.app Upload.zip
-exportNotarizedApp \ SUBMISSION_ID=$(xcrun notarytool submit --issuer ${{ inputs.app-store-key-issuer-id }} --key-id ${{ inputs.app-store-key-id }} --key "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" Upload.zip | awk '/ id:/ { print $2; exit }')
-allowProvisioningUpdates \
-allowProvisioningDeviceRegistration \
-authenticationKeyID ${{ inputs.app-store-key-id }} \
-authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \
-authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \
-archivePath '${{ inputs.archive-path }}' \
-exportPath ${{ inputs.export-path }}
do
echo "Failed to export app, trying again in 10s..."
sleep 10
done
rm -rf AuthKey_${{ inputs.app-store-key-id }}.p8 ExportOptions.plist xcrun notarytool wait $SUBMISSION_ID --issuer ${{ inputs.app-store-key-issuer-id }} --key-id ${{ inputs.app-store-key-id }} --key "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8"
xcrun stapler staple Release/Wallet.app
aa archive -a lzma -b 8m -d Release -subdir Wallet.app -o Wallet.app.aar
rm -rf Upload.zip Release AuthKey_${{ inputs.app-store-key-id }}.p8 ExportOptions.plist

View file

@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:burrow.rs?mode=developer</string>
<string>webcredentials:burrow.rs?mode=developer</string>
</array>
<key>com.apple.developer.networking.networkextension</key> <key>com.apple.developer.networking.networkextension</key>
<array> <array>
<string>packet-tunnel-provider</string> <string>packet-tunnel-provider</string>

View file

@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:burrow.rs?mode=developer</string>
<string>webcredentials:burrow.rs?mode=developer</string>
</array>
<key>com.apple.developer.networking.networkextension</key> <key>com.apple.developer.networking.networkextension</key>
<array> <array>
<string>packet-tunnel-provider</string> <string>packet-tunnel-provider</string>

View file

@ -1,9 +1,29 @@
import AuthenticationServices
import SwiftUI import SwiftUI
#if !os(macOS)
struct BurrowView: View { struct BurrowView: View {
@Environment(\.webAuthenticationSession)
private var webAuthenticationSession
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack { VStack {
HStack {
Text("Networks")
.font(.largeTitle)
.fontWeight(.bold)
Spacer()
Menu {
Button("Hack Club", action: addHackClubNetwork)
Button("WireGuard", action: addWireGuardNetwork)
} label: {
Image(systemName: "plus.circle.fill")
.font(.title)
.accessibilityLabel("Add")
}
}
.padding(.top)
NetworkCarouselView() NetworkCarouselView()
Spacer() Spacer()
TunnelStatusView() TunnelStatusView()
@ -11,9 +31,35 @@ struct BurrowView: View {
.padding(.bottom) .padding(.bottom)
} }
.padding() .padding()
.navigationTitle("Networks") .handleOAuth2Callback()
} }
} }
private func addHackClubNetwork() {
Task {
try await authenticateWithSlack()
}
}
private func addWireGuardNetwork() {
}
private func authenticateWithSlack() async throws {
guard
let authorizationEndpoint = URL(string: "https://slack.com/openid/connect/authorize"),
let tokenEndpoint = URL(string: "https://slack.com/api/openid.connect.token"),
let redirectURI = URL(string: "https://burrow.rs/callback/oauth2") else { return }
let session = OAuth2.Session(
authorizationEndpoint: authorizationEndpoint,
tokenEndpoint: tokenEndpoint,
redirectURI: redirectURI,
scopes: ["openid", "profile"],
clientID: "2210535565.6884042183125",
clientSecret: "2793c8a5255cae38830934c664eeb62d"
)
let response = try await session.authorize(webAuthenticationSession)
}
} }
#if DEBUG #if DEBUG
@ -24,3 +70,4 @@ struct NetworkView_Previews: PreviewProvider {
} }
} }
#endif #endif
#endif

View file

@ -0,0 +1,39 @@
import SwiftUI
struct NetworkCarouselView: View {
var networks: [any Network] = [
HackClub(id: "1"),
HackClub(id: "2"),
WireGuard(id: "4"),
HackClub(id: "5"),
]
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(networks, id: \.id) { network in
NetworkView(network: network)
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
content
.scaleEffect(1.0 - abs(phase.value) * 0.1)
}
}
}
}
.scrollTargetLayout()
.scrollClipDisabled()
.scrollIndicators(.hidden)
.defaultScrollAnchor(.center)
.scrollTargetBehavior(.viewAligned)
.containerRelativeFrame(.horizontal)
}
}
#if DEBUG
struct NetworkCarouselView_Previews: PreviewProvider {
static var previews: some View {
NetworkCarouselView()
}
}
#endif

View file

@ -30,59 +30,9 @@ struct NetworkView<Content: View>: View {
} }
} }
struct AddNetworkView: View {
var body: some View {
Text("Add Network")
.frame(maxWidth: .infinity, minHeight: 175, maxHeight: 175)
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(style: .init(lineWidth: 2, dash: [6]))
)
}
}
extension NetworkView where Content == AnyView { extension NetworkView where Content == AnyView {
init(network: any Network) { init(network: any Network) {
color = network.backgroundColor color = network.backgroundColor
content = { AnyView(network.label) } content = { AnyView(network.label) }
} }
} }
struct NetworkCarouselView: View {
var networks: [any Network] = [
HackClub(id: "1"),
HackClub(id: "2"),
WireGuard(id: "4"),
HackClub(id: "5"),
]
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(networks, id: \.id) { network in
NetworkView(network: network)
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
content
.scaleEffect(1.0 - abs(phase.value) * 0.1)
}
}
AddNetworkView()
}
.scrollTargetLayout()
}
.scrollClipDisabled()
.scrollIndicators(.hidden)
.defaultScrollAnchor(.center)
.scrollTargetBehavior(.viewAligned)
.containerRelativeFrame(.horizontal)
}
}
#if DEBUG
struct NetworkCarouselView_Previews: PreviewProvider {
static var previews: some View {
NetworkCarouselView()
}
}
#endif

280
Apple/App/OAuth2.swift Normal file
View file

@ -0,0 +1,280 @@
import AuthenticationServices
import SwiftUI
import Foundation
enum OAuth2 {
enum Error: Swift.Error {
case unknown
case invalidAuthorizationURL
case invalidCallbackURL
case invalidRedirectURI
}
struct Credential {
var accessToken: String
var refreshToken: String?
var expirationDate: Date?
}
struct Session {
var authorizationEndpoint: URL
var tokenEndpoint: URL
var redirectURI: URL
var responseType = OAuth2.ResponseType.code
var scopes: Set<String>
var clientID: String
var clientSecret: String
fileprivate static var queue: [Int: CheckedContinuation<URL, Swift.Error>] = [:]
fileprivate static func handle(url: URL) {
let continuations = queue
queue.removeAll()
for (_, continuation) in continuations {
continuation.resume(returning: url)
}
}
public init(
authorizationEndpoint: URL,
tokenEndpoint: URL,
redirectURI: URL,
scopes: Set<String>,
clientID: String,
clientSecret: String
) {
self.authorizationEndpoint = authorizationEndpoint
self.tokenEndpoint = tokenEndpoint
self.redirectURI = redirectURI
self.scopes = scopes
self.clientID = clientID
self.clientSecret = clientSecret
}
private var authorizationURL: URL {
get throws {
var queryItems: [URLQueryItem] = [
.init(name: "client_id", value: clientID),
.init(name: "response_type", value: responseType.rawValue),
.init(name: "redirect_uri", value: redirectURI.absoluteString),
]
if !scopes.isEmpty {
queryItems.append(.init(name: "scope", value: scopes.joined(separator: ",")))
}
guard var components = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false) else {
throw OAuth2.Error.invalidAuthorizationURL
}
components.queryItems = queryItems
guard let authorizationURL = components.url else { throw OAuth2.Error.invalidAuthorizationURL }
return authorizationURL
}
}
private func handle(callbackURL: URL) async throws -> OAuth2.AccessTokenResponse {
switch responseType {
case .code:
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else {
throw OAuth2.Error.invalidCallbackURL
}
return try await handle(response: try components.decode(OAuth2.CodeResponse.self))
default:
throw OAuth2.Error.invalidCallbackURL
}
}
private func handle(response: OAuth2.CodeResponse) async throws -> OAuth2.AccessTokenResponse {
var components = URLComponents()
components.queryItems = [
.init(name: "client_id", value: clientID),
.init(name: "client_secret", value: clientSecret),
.init(name: "grant_type", value: GrantType.authorizationCode.rawValue),
.init(name: "code", value: response.code),
.init(name: "redirect_uri", value: redirectURI.absoluteString)
]
let httpBody = Data(components.percentEncodedQuery!.utf8)
var request = URLRequest(url: tokenEndpoint)
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
request.httpBody = httpBody
let session = URLSession(configuration: .ephemeral)
let (data, _) = try await session.data(for: request)
return try OAuth2.decoder.decode(OAuth2.AccessTokenResponse.self, from: data)
}
func authorize(_ session: WebAuthenticationSession) async throws -> Credential {
let authorizationURL = try authorizationURL
let callbackURL = try await session.start(
url: authorizationURL,
redirectURI: redirectURI
)
return try await handle(callbackURL: callbackURL).credential
}
}
private struct CodeResponse: Codable {
var code: String
var state: String?
}
private struct AccessTokenResponse: Codable {
var accessToken: String
var tokenType: TokenType
var expiresIn: Double?
var refreshToken: String?
var credential: Credential {
.init(accessToken: accessToken, refreshToken: refreshToken, expirationDate: expiresIn.map { Date.init(timeIntervalSinceNow: $0) })
}
}
enum TokenType: Codable, RawRepresentable {
case bearer
case unknown(String)
init(rawValue: String) {
self = switch rawValue.lowercased() {
case "bearer": .bearer
default: .unknown(rawValue)
}
}
var rawValue: String {
switch self {
case .bearer: "bearer"
case .unknown(let type): type
}
}
}
enum GrantType: Codable, RawRepresentable {
case authorizationCode
case unknown(String)
init(rawValue: String) {
self = switch rawValue.lowercased() {
case "authorization_code": .authorizationCode
default: .unknown(rawValue)
}
}
var rawValue: String {
switch self {
case .authorizationCode: "authorization_code"
case .unknown(let type): type
}
}
}
enum ResponseType: Codable, RawRepresentable {
case code
case idToken
case unknown(String)
init(rawValue: String) {
self = switch rawValue.lowercased() {
case "code": .code
case "id_token": .idToken
default: .unknown(rawValue)
}
}
var rawValue: String {
switch self {
case .code: "code"
case .idToken: "id_token"
case .unknown(let type): type
}
}
}
fileprivate static var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}
fileprivate static var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}
}
extension WebAuthenticationSession {
func start(url: URL, redirectURI: URL) async throws -> URL {
#if canImport(BrowserEngineKit)
if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) {
return try await authenticate(
using: url,
callback: try Self.callback(for: redirectURI),
additionalHeaderFields: [:]
)
}
#endif
return try await withThrowingTaskGroup(of: URL.self) { group in
group.addTask {
return try await authenticate(using: url, callbackURLScheme: redirectURI.scheme ?? "")
}
let id = Int.random(in: 0..<Int.max)
group.addTask {
return try await withCheckedThrowingContinuation { continuation in
OAuth2.Session.queue[id] = continuation
}
}
guard let url = try await group.next() else { throw OAuth2.Error.invalidCallbackURL }
group.cancelAll()
OAuth2.Session.queue[id] = nil
return url
}
}
#if canImport(BrowserEngineKit)
@available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *)
fileprivate static func callback(for redirectURI: URL) throws -> ASWebAuthenticationSession.Callback {
switch redirectURI.scheme {
case "https":
guard let host = redirectURI.host else { throw OAuth2.Error.invalidRedirectURI }
return .https(host: host, path: redirectURI.path)
case "http":
throw OAuth2.Error.invalidRedirectURI
case .some(let scheme):
return .customScheme(scheme)
case .none:
throw OAuth2.Error.invalidRedirectURI
}
}
#endif
}
extension View {
func handleOAuth2Callback() -> some View {
onOpenURL { url in OAuth2.Session.handle(url: url) }
}
}
extension URLComponents {
fileprivate func decode<T: Decodable>(_ type: T.Type) throws -> T {
guard let queryItems else {
throw DecodingError.valueNotFound(
T.self,
.init(codingPath: [], debugDescription: "Missing query items")
)
}
let data = try OAuth2.encoder.encode(try queryItems.values)
return try OAuth2.decoder.decode(T.self, from: data)
}
}
extension Sequence where Element == URLQueryItem {
fileprivate var values: [String: String?] {
get throws {
try Dictionary(map { ($0.name, $0.value) }) { _, _ in
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Duplicate query items"))
}
}
}
}

View file

@ -4,17 +4,20 @@ struct TunnelButton: View {
@Environment(\.tunnel) @Environment(\.tunnel)
var tunnel: any Tunnel var tunnel: any Tunnel
private var action: Action? { tunnel.action }
var body: some View { var body: some View {
if let action = tunnel.action {
Button { Button {
if let action {
tunnel.perform(action) tunnel.perform(action)
}
} label: { } label: {
Text(action.description) Text(action.description)
} }
.disabled(action.isDisabled)
.padding(.horizontal) .padding(.horizontal)
.buttonStyle(.floating) .buttonStyle(.floating)
} }
}
} }
extension Tunnel { extension Tunnel {
@ -40,12 +43,21 @@ extension TunnelButton {
} }
} }
extension TunnelButton.Action { extension TunnelButton.Action? {
var description: LocalizedStringKey { var description: LocalizedStringKey {
switch self { switch self {
case .enable: "Enable" case .enable: "Enable"
case .start: "Start" case .start: "Start"
case .stop: "Stop" case .stop: "Stop"
case .none: "Start"
}
}
var isDisabled: Bool {
if case .none = self {
true
} else {
false
} }
} }
} }

View file

@ -10,6 +10,8 @@
0B28F1562ABF463A000D44B0 /* DataTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B28F1552ABF463A000D44B0 /* DataTypes.swift */; }; 0B28F1562ABF463A000D44B0 /* DataTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B28F1552ABF463A000D44B0 /* DataTypes.swift */; };
0B46E8E02AC918CA00BA2A3C /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B46E8DF2AC918CA00BA2A3C /* Client.swift */; }; 0B46E8E02AC918CA00BA2A3C /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B46E8DF2AC918CA00BA2A3C /* Client.swift */; };
43AA26D82A10004900F14CE6 /* MenuItemToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */; }; 43AA26D82A10004900F14CE6 /* MenuItemToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */; };
D000363D2BB8928E00E582EC /* NetworkCarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D000363C2BB8928E00E582EC /* NetworkCarouselView.swift */; };
D000363F2BB895FB00E582EC /* OAuth2.swift in Sources */ = {isa = PBXBuildFile; fileRef = D000363E2BB895FB00E582EC /* OAuth2.swift */; };
D00117312B2FFFC900D87C25 /* NWConnection+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */; }; D00117312B2FFFC900D87C25 /* NWConnection+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */; };
D00117332B3001A400D87C25 /* NewlineProtocolFramer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */; }; D00117332B3001A400D87C25 /* NewlineProtocolFramer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */; };
D001173B2B30341C00D87C25 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D001173A2B30341C00D87C25 /* Logging.swift */; }; D001173B2B30341C00D87C25 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D001173A2B30341C00D87C25 /* Logging.swift */; };
@ -78,6 +80,8 @@
0B28F1552ABF463A000D44B0 /* DataTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataTypes.swift; sourceTree = "<group>"; }; 0B28F1552ABF463A000D44B0 /* DataTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataTypes.swift; sourceTree = "<group>"; };
0B46E8DF2AC918CA00BA2A3C /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = "<group>"; }; 0B46E8DF2AC918CA00BA2A3C /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = "<group>"; };
43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemToggleView.swift; sourceTree = "<group>"; }; 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemToggleView.swift; sourceTree = "<group>"; };
D000363C2BB8928E00E582EC /* NetworkCarouselView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkCarouselView.swift; sourceTree = "<group>"; };
D000363E2BB895FB00E582EC /* OAuth2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth2.swift; sourceTree = "<group>"; };
D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NWConnection+Async.swift"; sourceTree = "<group>"; }; D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NWConnection+Async.swift"; sourceTree = "<group>"; };
D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewlineProtocolFramer.swift; sourceTree = "<group>"; }; D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewlineProtocolFramer.swift; sourceTree = "<group>"; };
D00117382B30341C00D87C25 /* libBurrowShared.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libBurrowShared.a; sourceTree = BUILT_PRODUCTS_DIR; }; D00117382B30341C00D87C25 /* libBurrowShared.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libBurrowShared.a; sourceTree = BUILT_PRODUCTS_DIR; };
@ -247,7 +251,9 @@
D00AA8962A4669BC005C8102 /* AppDelegate.swift */, D00AA8962A4669BC005C8102 /* AppDelegate.swift */,
43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */, 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */,
D05B9F7729E39EEC008CB1F9 /* BurrowView.swift */, D05B9F7729E39EEC008CB1F9 /* BurrowView.swift */,
D000363C2BB8928E00E582EC /* NetworkCarouselView.swift */,
D01A79302B81630D0024EC91 /* NetworkView.swift */, D01A79302B81630D0024EC91 /* NetworkView.swift */,
D000363E2BB895FB00E582EC /* OAuth2.swift */,
D032E64D2B8A69C90006B8AD /* Networks */, D032E64D2B8A69C90006B8AD /* Networks */,
D0FAB5972B818B8200F6A84B /* TunnelStatusView.swift */, D0FAB5972B818B8200F6A84B /* TunnelStatusView.swift */,
D0FAB5952B818B2900F6A84B /* TunnelButton.swift */, D0FAB5952B818B2900F6A84B /* TunnelButton.swift */,
@ -476,6 +482,7 @@
43AA26D82A10004900F14CE6 /* MenuItemToggleView.swift in Sources */, 43AA26D82A10004900F14CE6 /* MenuItemToggleView.swift in Sources */,
D05B9F7829E39EEC008CB1F9 /* BurrowView.swift in Sources */, D05B9F7829E39EEC008CB1F9 /* BurrowView.swift in Sources */,
D0FAB5922B818A5900F6A84B /* NetworkExtensionTunnel.swift in Sources */, D0FAB5922B818A5900F6A84B /* NetworkExtensionTunnel.swift in Sources */,
D000363F2BB895FB00E582EC /* OAuth2.swift in Sources */,
D0FAB5962B818B2900F6A84B /* TunnelButton.swift in Sources */, D0FAB5962B818B2900F6A84B /* TunnelButton.swift in Sources */,
D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */, D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */,
D05EF8C82B81818D0017AB4F /* FloatingButtonStyle.swift in Sources */, D05EF8C82B81818D0017AB4F /* FloatingButtonStyle.swift in Sources */,
@ -484,6 +491,7 @@
D01A79312B81630D0024EC91 /* NetworkView.swift in Sources */, D01A79312B81630D0024EC91 /* NetworkView.swift in Sources */,
D032E6542B8A79DA0006B8AD /* WireGuard.swift in Sources */, D032E6542B8A79DA0006B8AD /* WireGuard.swift in Sources */,
D0BCC5FD2A086D4700AD070D /* NetworkExtension+Async.swift in Sources */, D0BCC5FD2A086D4700AD070D /* NetworkExtension+Async.swift in Sources */,
D000363D2BB8928E00E582EC /* NetworkCarouselView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };