diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/Contents.json b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/Contents.json index f345d5639..e38e57fa7 100644 --- a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/Contents.json +++ b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "logo-text-any.svg", + "filename" : "welcomeview-light.svg", "idiom" : "universal", "scale" : "1x" }, @@ -12,7 +12,7 @@ "value" : "light" } ], - "filename" : "logo-text-light.svg", + "filename" : "welcomeview-light 3.svg", "idiom" : "universal", "scale" : "1x" }, @@ -23,11 +23,12 @@ "value" : "dark" } ], - "filename" : "logo-text-dark.svg", + "filename" : "welcomeview-dark.svg", "idiom" : "universal", "scale" : "1x" }, { + "filename" : "welcomeview-light 1.svg", "idiom" : "universal", "scale" : "2x" }, @@ -38,6 +39,7 @@ "value" : "light" } ], + "filename" : "welcomeview-light 4.svg", "idiom" : "universal", "scale" : "2x" }, @@ -48,10 +50,12 @@ "value" : "dark" } ], + "filename" : "welcomeview-dark 1.svg", "idiom" : "universal", "scale" : "2x" }, { + "filename" : "welcomeview-light 2.svg", "idiom" : "universal", "scale" : "3x" }, @@ -62,6 +66,7 @@ "value" : "light" } ], + "filename" : "welcomeview-light 5.svg", "idiom" : "universal", "scale" : "3x" }, @@ -72,6 +77,7 @@ "value" : "dark" } ], + "filename" : "welcomeview-dark 2.svg", "idiom" : "universal", "scale" : "3x" } diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/logo-text-any.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/logo-text-any.svg deleted file mode 100644 index d4712a051..000000000 --- a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/logo-text-any.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/logo-text-dark.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/logo-text-dark.svg deleted file mode 100644 index 1fb26214b..000000000 --- a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/logo-text-dark.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/logo-text-light.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/logo-text-light.svg deleted file mode 100644 index d4712a051..000000000 --- a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/logo-text-light.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-dark 1.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-dark 1.svg new file mode 100644 index 000000000..fe7d71cf8 --- /dev/null +++ b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-dark 1.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-dark 2.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-dark 2.svg new file mode 100644 index 000000000..fe7d71cf8 --- /dev/null +++ b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-dark 2.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-dark.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-dark.svg new file mode 100644 index 000000000..fe7d71cf8 --- /dev/null +++ b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 1.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 1.svg new file mode 100644 index 000000000..a788bedd0 --- /dev/null +++ b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 1.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 2.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 2.svg new file mode 100644 index 000000000..a788bedd0 --- /dev/null +++ b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 2.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 3.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 3.svg new file mode 100644 index 000000000..a788bedd0 --- /dev/null +++ b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 3.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 4.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 4.svg new file mode 100644 index 000000000..a788bedd0 --- /dev/null +++ b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 4.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 5.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 5.svg new file mode 100644 index 000000000..a788bedd0 --- /dev/null +++ b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light 5.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light.svg b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light.svg new file mode 100644 index 000000000..a788bedd0 --- /dev/null +++ b/swift/apple/Firezone/Assets.xcassets/LogoText.imageset/welcomeview-light.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift index a7c0df8ab..b103f4f0c 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift @@ -38,13 +38,13 @@ public class AppViewModel: ObservableObject { self.status = status - #if os(macOS) - if status == .invalid || DeviceMetadata.firstTime() { - AppViewModel.WindowDefinition.main.openWindow() - } else { - AppViewModel.WindowDefinition.main.window()?.close() - } - #endif +#if os(macOS) + if status == .invalid || DeviceMetadata.firstTime() { + AppViewModel.WindowDefinition.main.openWindow() + } else { + AppViewModel.WindowDefinition.main.window()?.close() + } +#endif }) .store(in: &cancellables) @@ -63,86 +63,71 @@ public class AppViewModel: ObservableObject { public struct AppView: View { @ObservedObject var model: AppViewModel - @State private var isSettingsPresented = false - public init(model: AppViewModel) { self.model = model } - private var SettingsButton: some View { - Button(action: { - isSettingsPresented = true - }) { - Label("Settings", systemImage: "gear") - } - .disabled(model.status == .invalid) - } - @ViewBuilder public var body: some View { - #if os(iOS) - NavigationView { - switch (model.status, model.decision) { - case (nil, _), (_, nil): - ProgressView() - case (.invalid, _): - GrantVPNView(model: GrantVPNViewModel(store: model.store)) - case (.disconnected, .notDetermined): - GrantNotificationsView(model: GrantNotificationsViewModel(store: model.store)) - case (.disconnected, _): - WelcomeView(model: WelcomeViewModel(store: model.store)) - .navigationBarItems(trailing: SettingsButton) - case (_, _): - SessionView(model: SessionViewModel(store: model.store)) - .navigationBarItems(trailing: SettingsButton) - } +#if os(iOS) + switch (model.status, model.decision) { + case (nil, _), (_, nil): + ProgressView() + case (.invalid, _): + GrantVPNView(model: GrantVPNViewModel(store: model.store)) + case (.disconnected, .notDetermined): + GrantNotificationsView(model: GrantNotificationsViewModel(store: model.store)) + case (.disconnected, _): + iOSNavigationView(model: model) { + WelcomeView(model: WelcomeViewModel(store: model.store)) } - .sheet(isPresented: $isSettingsPresented) { - SettingsView(model: SettingsViewModel(store: model.store)) + case (_, _): + iOSNavigationView(model: model) { + SessionView(model: SessionViewModel(store: model.store)) } - .navigationViewStyle(StackNavigationViewStyle()) - #elseif os(macOS) - switch model.store.status { - case .invalid: - GrantVPNView(model: GrantVPNViewModel(store: model.store)) - default: - FirstTimeView() - } - #endif + } +#elseif os(macOS) + switch model.store.status { + case .invalid: + GrantVPNView(model: GrantVPNViewModel(store: model.store)) + default: + FirstTimeView() + } +#endif } } #if os(macOS) - public extension AppViewModel { - enum WindowDefinition: String, CaseIterable { - case main - case settings +public extension AppViewModel { + enum WindowDefinition: String, CaseIterable { + case main + case settings - public var identifier: String { "firezone-\(rawValue)" } - public var externalEventMatchString: String { rawValue } - public var externalEventOpenURL: URL { URL(string: "firezone://\(rawValue)")! } + public var identifier: String { "firezone-\(rawValue)" } + public var externalEventMatchString: String { rawValue } + public var externalEventOpenURL: URL { URL(string: "firezone://\(rawValue)")! } - public func openWindow() { - if let window = NSApp.windows.first(where: { - $0.identifier?.rawValue.hasPrefix(identifier) ?? false - }) { - // Order existing window front - NSApp.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(self) - } else { - // Open new window - NSWorkspace.shared.open(externalEventOpenURL) - } + public func openWindow() { + if let window = NSApp.windows.first(where: { + $0.identifier?.rawValue.hasPrefix(identifier) ?? false + }) { + // Order existing window front + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(self) + } else { + // Open new window + NSWorkspace.shared.open(externalEventOpenURL) } + } - public func window() -> NSWindow? { - NSApp.windows.first { window in - if let windowId = window.identifier?.rawValue { - return windowId.hasPrefix(self.identifier) - } - return false + public func window() -> NSWindow? { + NSApp.windows.first { window in + if let windowId = window.identifier?.rawValue { + return windowId.hasPrefix(self.identifier) } + return false } } } +} #endif diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift new file mode 100644 index 000000000..ee236a1a3 --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift @@ -0,0 +1,142 @@ +// +// ResourceView.swift +// +// +// Created by Jamil Bou Kheir on 5/25/24. +// + +import SwiftUI + +#if os(iOS) +struct ResourceView: View { + var resource: Resource + @Environment(\.openURL) var openURL + + var body: some View { + List { + Section(header: Text("Resource")) { + HStack { + Text("NAME") + .bold() + .font(.system(size: 14)) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .leading) + Text(resource.name) + } + .contextMenu { + Button(action: { + copyToClipboard(resource.name) + }) { + Text("Copy name") + Image(systemName: "doc.on.doc") + } + } + + HStack { + Text("ADDRESS") + .bold() + .font(.system(size: 14)) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .leading) + if let url = URL(string: resource.addressDescription ?? resource.address), + let _ = url.host { + Button(action: { + openURL(url) + }) { + Text(resource.addressDescription ?? resource.address) + .foregroundColor(.blue) + .underline() + .font(.system(size: 16)) + .contextMenu { + Button(action: { + copyToClipboard(resource.addressDescription ?? resource.address) + }) { + Text("Copy address") + Image(systemName: "doc.on.doc") + } + } + } + } else { + Text(resource.addressDescription ?? resource.address) + .contextMenu { + Button(action: { + copyToClipboard(resource.addressDescription ?? resource.address) + }) { + Text("Copy address") + Image(systemName: "doc.on.doc") + } + } + } + } + } + + if let site = resource.sites.first { + Section(header: Text("Site")) { + HStack { + Text("NAME") + .bold() + .font(.system(size: 14)) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .leading) + Text(site.name) + } + .contextMenu { + Button(action: { + copyToClipboard(site.name) + }) { + Text("Copy name") + Image(systemName: "doc.on.doc") + } + } + + HStack { + Text("STATUS") + .bold() + .font(.system(size: 14)) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .leading) + statusIndicator(for: resource.status) + Text(resource.status.toSiteStatus()) + .padding(.leading, 5) + } + .contextMenu { + Button(action: { + copyToClipboard(resource.status.toSiteStatus()) + }) { + Text("Copy status") + Image(systemName: "doc.on.doc") + } + } + } + } + } + .listStyle(GroupedListStyle()) + .navigationBarTitle("Details", displayMode: .inline) + } + + @ViewBuilder + private func statusIndicator(for status: ResourceStatus) -> some View { + HStack { + Circle() + .fill(color(for: status)) + .frame(width: 10, height: 10) + } + } + + private func color(for status: ResourceStatus) -> Color { + switch status { + case .online: + return .green + case .offline: + return .red + case .unknown: + return .gray + } + } + + private func copyToClipboard(_ value: String) { + let pasteboard = UIPasteboard.general + pasteboard.string = value + } +} +#endif diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift index 11f6acaa4..f40601baa 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift @@ -32,7 +32,7 @@ public final class SessionViewModel: ObservableObject { .store(in: &cancellables) // MenuBar has its own observer - #if os(iOS) +#if os(iOS) store.$status .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] status in @@ -41,7 +41,9 @@ public final class SessionViewModel: ObservableObject { if status == .connected { store.beginUpdatingResources() { data in - self.resources = try? JSONDecoder().decode([Resource].self, from: data) + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + self.resources = try? decoder.decode([Resource].self, from: data) } } else { store.endUpdatingResources() @@ -49,14 +51,9 @@ public final class SessionViewModel: ObservableObject { }) .store(in: &cancellables) - #endif +#endif } - func signOutButtonTapped() { - Task { - try await store.signOut() - } - } } #if os(iOS) @@ -65,68 +62,34 @@ struct SessionView: View { @ObservedObject var model: SessionViewModel var body: some View { - List { - Section(header: Text("Authentication")) { - Group { - if case .connected = model.status { - HStack { - Text("Signed in as") - Spacer() - Text(model.actorName ?? "Unknown user").foregroundColor(.secondary) - } - HStack { - Spacer() - Button("Sign Out") { - model.signOutButtonTapped() - } - Spacer() - } - } else { - Text(model.status?.description ?? "") - } - } - } - if case .connected = model.status { - Section(header: Text("Resources")) { - if let resources = model.resources { - if resources.isEmpty { - Text("No Resources") - } else { - ForEach(resources) { resource in - Menu( - content: { - Button { - copyResourceTapped(resource) - } label: { - Label("Copy Address", systemImage: "doc.on.doc") - } - }, - label: { - HStack { - Text(resource.name) - .foregroundColor(.primary) - Spacer() - Text(resource.address) - .foregroundColor(.secondary) - } - }) - } - } - } else { - Text("Loading Resources...") + switch model.status { + case .connected: + if let resources = model.resources { + if resources.isEmpty { + Text("No Resources. Contact your admin to be granted access.") + } else { + List(resources) { resource in + NavigationLink(resource.name, destination: ResourceView(resource: resource)) + .navigationTitle("All Resources") } + .listStyle(GroupedListStyle()) } + } else { + Text("Loading Resources...") } + case .connecting: + Text("Connecting...") + case .disconnecting: + Text("Disconnecting...") + case .reasserting: + Text("No internet connection. Resources will be displayed when your internet connection resumes.") + case .invalid, .none: + Text("VPN permission doesn't seem to be granted.") + case .disconnected: + Text("Signed out. Please sign in again to connect to Resources.") + @unknown default: + Text("Unknown status. Please report this and attach your logs.") } - .listStyle(GroupedListStyle()) - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Firezone") - } - - private func copyResourceTapped(_ resource: Resource) { - let pasteboard = UIPasteboard.general - pasteboard.string = resource.address } } - #endif diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SignedOutView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SignedOutView.swift deleted file mode 100644 index c55171d64..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SignedOutView.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// SignedOutView.swift -// (c) 2024 Firezone, Inc. -// LICENSE: Apache-2.0 -// - -import AuthenticationServices -import Combine -import SwiftUI - -@MainActor -final class WelcomeViewModel: ObservableObject { - let store: Store - - init(store: Store) { - self.store = store - } - - func signInButtonTapped() { - Task { await WebAuthSession.signIn(store: store) } - } -} - -struct WelcomeView: View { - @ObservedObject var model: WelcomeViewModel - - // Debounce button taps - @State private var tapped = false - - var body: some View { - VStack( - alignment: .center, - content: { - // TODO: Same screen as Windows app - Spacer() - Image("LogoText") - .resizable() - .scaledToFit() - .frame(maxWidth: 600) - .padding(.horizontal, 10) - Spacer() - Button("Sign in") { - if !tapped { - tapped = true - - DispatchQueue.main.async { - model.signInButtonTapped() - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - tapped = false - } - } - } - .disabled(tapped) - .buttonStyle(.borderedProminent) - .controlSize(.large) - Spacer() - }) - - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/WelcomeView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/WelcomeView.swift new file mode 100644 index 000000000..d5288de2b --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/WelcomeView.swift @@ -0,0 +1,65 @@ +// +// WelcomeView.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// + +import AuthenticationServices +import Combine +import SwiftUI + +@MainActor +final class WelcomeViewModel: ObservableObject { + let store: Store + + init(store: Store) { + self.store = store + } + + func signInButtonTapped() { + Task { await WebAuthSession.signIn(store: store) } + } +} + +struct WelcomeView: View { + @ObservedObject var model: WelcomeViewModel + + // Debounce button taps + @State private var tapped = false + + var body: some View { + VStack( + alignment: .center, + content: { + Spacer() + Image("LogoText") + .resizable() + .scaledToFit() + .frame(maxWidth: 300) + .padding(.horizontal, 10) + .padding(.vertical, 10) + Text(""" + Welcome to Firezone. + Sign in to access Resources. + """).multilineTextAlignment(.center) + .padding(.bottom, 10) + Button("Sign in") { + if !tapped { + tapped = true + + DispatchQueue.main.async { + model.signInButtonTapped() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + tapped = false + } + } + } + .disabled(tapped) + .buttonStyle(.borderedProminent) + .controlSize(.large) + Spacer() + }) + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift new file mode 100644 index 000000000..090c55295 --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift @@ -0,0 +1,84 @@ +// +// iOSNavigationView.swift +// +// +// Created by Jamil Bou Kheir on 5/25/24. +// +// A View that contains common elements, intended to be inherited from. + +import SwiftUI + +#if os(iOS) +struct iOSNavigationView: View { + @State private var isSettingsPresented = false + @ObservedObject var model: AppViewModel + @Environment(\.openURL) var openURL + + let content: Content + + init(model: AppViewModel, @ViewBuilder content: () -> Content) { + self.model = model + self.content = content() + } + + var body: some View { + NavigationView { + content + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: AuthMenu) + .navigationBarItems(trailing: SettingsButton) + } + .sheet(isPresented: $isSettingsPresented) { + SettingsView(model: SettingsViewModel(store: model.store)) + } + .navigationViewStyle(StackNavigationViewStyle()) + } + + private var SettingsButton: some View { + Button(action: { + isSettingsPresented = true + }) { + Label("Settings", systemImage: "gear") + } + .disabled(model.status == .invalid) + } + + private var AuthMenu: some View { + Menu { + if model.status == .connected { + Text("Signed in as \(model.store.actorName ?? "Unknown user")") + Button(action: { + signOutButtonTapped() + }) { + Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") + } + } else { + Button(action: { + Task { await WebAuthSession.signIn(store: model.store) } + }) { + Label("Sign in", systemImage: "person.crop.circle.fill.badge.plus") + } + } + Divider() + Button(action: { + openURL(URL(string: "https://www.firezone.dev/support?utm_source=ios-client")!) + }) { + Label("Support...", systemImage: "safari") + } + Button(action: { + openURL(URL(string: "https://www.firezone.dev/kb?utm_source=ios=client")!) + }) { + Label("Documentation...", systemImage: "safari") + } + } label: { + Image(systemName: "person.circle") + } + } + + private func signOutButtonTapped() { + Task { + try await model.store.signOut() + } + } +} +#endif