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