Merge profile to dashboard

This commit is contained in:
世界
2025-11-26 22:25:52 +08:00
parent 4f0d1098e6
commit 264c37218c
27 changed files with 1590 additions and 306 deletions
@@ -0,0 +1,60 @@
import SwiftUI
@MainActor
public struct NavigationButtonsView: View {
public let showGroupsButton: Bool
public let showConnectionsButton: Bool
public let groupsCount: Int
public let connectionsCount: Int
public let onGroupsTap: () -> Void
public let onConnectionsTap: () -> Void
public init(
showGroupsButton: Bool,
showConnectionsButton: Bool,
groupsCount: Int,
connectionsCount: Int,
onGroupsTap: @escaping () -> Void,
onConnectionsTap: @escaping () -> Void
) {
self.showGroupsButton = showGroupsButton
self.showConnectionsButton = showConnectionsButton
self.groupsCount = groupsCount
self.connectionsCount = connectionsCount
self.onGroupsTap = onGroupsTap
self.onConnectionsTap = onConnectionsTap
}
public var body: some View {
HStack(spacing: 12) {
if showGroupsButton {
Divider()
Text(verbatim: "\(groupsCount)")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize()
Button {
onGroupsTap()
} label: {
Label("Groups", systemImage: "rectangle.3.group.fill")
}
.labelStyle(.iconOnly)
.foregroundStyle(.primary)
}
if showConnectionsButton {
Divider()
Text(verbatim: "\(connectionsCount)")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize()
Button {
onConnectionsTap()
} label: {
Label("Connections", systemImage: "list.bullet.rectangle.portrait.fill")
}
.labelStyle(.iconOnly)
.foregroundStyle(.primary)
}
}
}
}
@@ -0,0 +1,48 @@
import Library
import SwiftUI
@MainActor
public struct SheetContent<Content: View>: View {
private let title: String
private let content: Content
public init(_ title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
public var body: some View {
#if os(iOS) || os(tvOS)
NavigationStackCompat {
content
.navigationTitle(title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
.presentationDetentsIfAvailable()
#endif
}
}
@MainActor
public struct GroupsSheetContent: View {
public init() {}
public var body: some View {
SheetContent("Groups") {
GroupListView()
}
}
}
@MainActor
public struct ConnectionsSheetContent: View {
public init() {}
public var body: some View {
SheetContent("Connections") {
ConnectionListView()
}
}
}
@@ -58,6 +58,7 @@ public struct ShareButtonCompat<Label>: View where Label: View {
public var body: some View {
Button(action: shareItem, label: label)
.buttonStyle(.plain)
#if os(macOS)
.background(SharingServicePicker($sharePresented, $alert, itemURL))
#endif
@@ -66,7 +67,7 @@ public struct ShareButtonCompat<Label>: View where Label: View {
private func shareItem() {
#if os(iOS)
Task {
await shareItem0()
await shareItemAsync()
}
#elseif os(macOS)
sharePresented = true
@@ -74,11 +75,11 @@ public struct ShareButtonCompat<Label>: View where Label: View {
}
#if os(iOS)
private nonisolated func shareItem0() async {
private nonisolated func shareItemAsync() async {
do {
let shareItem = try await itemURL()
await MainActor.run {
shareItem1(shareItem)
presentShareController(shareItem)
}
} catch {
await MainActor.run {
@@ -87,10 +88,20 @@ public struct ShareButtonCompat<Label>: View where Label: View {
}
}
private func shareItem1(_ item: URL) {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
windowScene.keyWindow?.rootViewController?.present(UIActivityViewController(activityItems: [item], applicationActivities: nil), animated: true, completion: nil)
private func presentShareController(_ item: URL) {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.keyWindow?.rootViewController
else {
return
}
var topViewController = rootViewController
while let presented = topViewController.presentedViewController {
topViewController = presented
}
topViewController.present(
UIActivityViewController(activityItems: [item], applicationActivities: nil),
animated: true
)
}
#endif
}
@@ -0,0 +1,39 @@
import SwiftUI
public extension View {
@ViewBuilder
func presentationDetentsIfAvailable() -> some View {
#if os(iOS) || os(tvOS)
if #available(iOS 16.0, tvOS 17.0, *) {
self.presentationDetents([.large])
.presentationDragIndicator(.visible)
} else {
self
}
#else
self
#endif
}
#if os(iOS) || os(tvOS)
@available(iOS 16.0, tvOS 17.0, *)
@ViewBuilder
func presentationDetentsIfAvailable(_ detents: PresentationDetent...) -> some View {
let detentSet: Set<PresentationDetent> = detents.isEmpty ? [.large] : Set(detents)
presentationDetents(detentSet)
.presentationDragIndicator(.visible)
}
#endif
@ViewBuilder
func actionButtonStyle() -> some View {
if #available(iOS 26.0, macOS 26.0, tvOS 26.0, *) {
self.frame(width: 36, height: 36)
.glassEffect(.regular.interactive(), in: .circle)
} else {
frame(width: 36, height: 36)
.background(Color.secondary.opacity(0.1))
.clipShape(Circle())
}
}
}
@@ -12,6 +12,9 @@ public struct ActiveDashboardView: View {
@State private var cardConfigurationVersion = 0
#if os(iOS) || os(tvOS)
@State private var showCardManagement = false
@State private var showGroups = false
@State private var showConnections = false
@State private var buttonState = ButtonVisibilityState()
#endif
private let externalCardConfigurationVersion: Int?
@@ -42,20 +45,15 @@ public struct ActiveDashboardView: View {
@ViewBuilder
private var content: some View {
VStack {
#if os(iOS) || os(tvOS)
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict {
pageSelector
pageContent
} else {
overviewPage
}
#else
overviewPage
#endif
}
overviewPage
#if os(iOS) || os(tvOS)
.toolbar { toolbar }
.sheet(isPresented: $showGroups) {
groupsSheetContent
}
.sheet(isPresented: $showConnections) {
connectionsSheetContent
}
#endif
.onAppear {
if ApplicationLibrary.inPreview {
@@ -83,45 +81,23 @@ public struct ActiveDashboardView: View {
}
}
}
#if os(iOS) || os(tvOS)
.onReceive(environments.commandClient.$groups) { _ in
updateButtonVisibility()
}
.onReceive(environments.commandClient.$connections) { _ in
updateButtonVisibility()
}
.onReceive(profile.$status) { _ in
updateButtonVisibility()
}
.onAppear {
updateButtonVisibility()
}
#endif
.alertBinding($coordinator.alert)
}
#if os(iOS) || os(tvOS)
@ViewBuilder
private var pageSelector: some View {
Picker("Page", selection: $coordinator.selection) {
ForEach(DashboardPage.enabledCases()) { page in
page.label
}
}
.pickerStyle(.segmented)
#if os(iOS)
.padding([.leading, .trailing])
.navigationBarTitleDisplayMode(.inline)
#endif
}
@ViewBuilder
private var pageContent: some View {
TabView(selection: $coordinator.selection) {
ForEach(DashboardPage.enabledCases()) { page in
page.contentView(
$coordinator.profileList,
$coordinator.selectedProfileID,
$coordinator.systemProxyAvailable,
$coordinator.systemProxyEnabled,
externalCardConfigurationVersion ?? cardConfigurationVersion
)
.tag(page)
}
}
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.tabViewStyle(.page(indexDisplayMode: .never))
}
#endif
@ViewBuilder
private var overviewPage: some View {
OverviewView(
@@ -134,26 +110,68 @@ public struct ActiveDashboardView: View {
}
#if os(iOS) || os(tvOS)
private func updateButtonVisibility() {
buttonState.update(profile: profile, commandClient: environments.commandClient)
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
if coordinator.selection == .overview {
if #available(iOS 16.0, tvOS 17.0, *) {
cardManagementButton
ToolbarItem(placement: .topBarLeading) {
#if os(iOS)
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
EmptyView()
} else {
navigationButtons
}
#else
navigationButtons
#endif
}
ToolbarItem(placement: .topBarTrailing) {
if #available(iOS 16.0, tvOS 17.0, *) {
cardManagementButton
}
}
ToolbarItem(placement: .topBarTrailing) {
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
EmptyView()
} else {
StartStopButton()
}
#if os(iOS)
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
EmptyView()
} else {
HStack(spacing: 12) {
Divider()
StartStopButton()
}
}
#else
HStack(spacing: 12) {
Divider()
StartStopButton()
}
#endif
}
}
private var navigationButtons: some View {
NavigationButtonsView(
showGroupsButton: buttonState.showGroupsButton,
showConnectionsButton: buttonState.showConnectionsButton,
groupsCount: buttonState.groupsCount,
connectionsCount: buttonState.connectionsCount,
onGroupsTap: { showGroups = true },
onConnectionsTap: { showConnections = true }
)
}
#endif
#if os(iOS) || os(tvOS)
private var groupsSheetContent: some View {
GroupsSheetContent()
}
private var connectionsSheetContent: some View {
ConnectionsSheetContent()
}
@available(iOS 16.0, tvOS 17.0, *)
@ViewBuilder
private var cardManagementButton: some View {
@@ -168,7 +186,8 @@ public struct ActiveDashboardView: View {
}
.sheet(isPresented: $showCardManagement) {
CardManagementSheet(configurationVersion: $cardConfigurationVersion)
.presentationDetents([.medium, .large])
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
#endif
@@ -0,0 +1,38 @@
import Foundation
import Library
@MainActor
public struct ButtonVisibilityState {
public var showGroupsButton = false
public var showConnectionsButton = false
public var groupsCount = 0
public var connectionsCount = 0
public init() {}
public mutating func update(
profile: ExtensionProfile?,
commandClient: CommandClient,
requireAnyConnection: Bool = false
) {
guard let profile else {
reset()
return
}
groupsCount = commandClient.groups?.count ?? 0
connectionsCount = commandClient.connections?.count ?? 0
let isConnected = ApplicationLibrary.inPreview || profile.status.isConnectedStrict
showConnectionsButton = isConnected && (!requireAnyConnection || commandClient.hasAnyConnection)
showGroupsButton = isConnected && (commandClient.groups?.isEmpty == false)
}
private mutating func reset() {
showGroupsButton = false
showConnectionsButton = false
groupsCount = 0
connectionsCount = 0
}
}
@@ -11,7 +11,7 @@ public struct ClashModeCard: View {
public var body: some View {
if shouldShowPicker {
DashboardCardView(title: "Mode", isHalfWidth: false) {
DashboardCardView(title: String(localized: "Mode"), isHalfWidth: false) {
Picker("", selection: Binding(get: {
clashMode
}, set: { newMode in
@@ -8,7 +8,7 @@ public struct ConnectionsCard: View {
public init() {}
public var body: some View {
DashboardCardView(title: "Connections", isHalfWidth: true) {
DashboardCardView(title: String(localized: "Connections"), isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) {
if ApplicationLibrary.inPreview {
DashboardCardLine(String(localized: "Inbound"), "34")
@@ -1,7 +1,14 @@
import Foundation
import Libbox
import Library
import QRCode
import SwiftUI
@MainActor
public struct ProfileCard: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@StateObject private var viewModel = ViewModel()
@Binding private var profileList: [ProfilePreview]
@Binding private var selectedProfileID: Int64
@@ -13,39 +20,431 @@ public struct ProfileCard: View {
_selectedProfileID = selectedProfileID
}
private var selectedProfile: ProfilePreview? {
profileList.first { $0.id == selectedProfileID }
}
public var body: some View {
DashboardCardView(title: "Profile", isHalfWidth: false) {
VStack(alignment: .leading, spacing: 12) {
#if os(iOS) || os(tvOS)
Picker("", selection: $selectedProfileID) {
ForEach(profileList, id: \.id) { profile in
Text(profile.name).tag(profile.id)
}
DashboardCardView(title: "") {
VStack(alignment: .leading, spacing: 16) {
headerView
profileSelectorView
}
}
.disabled(viewModel.isUpdating)
.sheet(isPresented: $viewModel.showNewProfile, onDismiss: {
environments.profileUpdate.send()
}) {
NewProfileNavigationView()
.environmentObject(environments)
}
.sheet(isPresented: $viewModel.showManageProfiles) {
manageProfilesSheet
}
.sheet(item: $viewModel.profileToEdit) { profile in
editProfileSheet(for: profile)
}
#if os(iOS) || os(tvOS)
.sheet(isPresented: $viewModel.showQRCode) {
if let profile = selectedProfile, let remoteURL = profile.remoteURL {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
#endif
.alertBinding($viewModel.alert)
}
private var headerView: some View {
HStack {
Text("Profile")
.font(.headline)
.foregroundColor(.primary)
Spacer()
Button {
viewModel.showNewProfile = true
} label: {
Image(systemName: "plus")
.font(.system(size: 16))
}
.buttonStyle(.plain)
.actionButtonStyle()
if !profileList.isEmpty {
Button {
viewModel.showManageProfiles = true
} label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 16))
}
.buttonStyle(.plain)
.actionButtonStyle()
}
}
}
private var profileSelectorView: some View {
VStack(alignment: .leading, spacing: 12) {
if profileList.isEmpty {
Text("Empty profiles")
.foregroundStyle(.secondary)
.padding(.vertical, 8)
} else {
ProfileSelectorButton(
items: profileList,
selectedItem: selectedProfile,
onSelect: { selectedProfileID = $0 }
)
if let profile = selectedProfile {
VStack(alignment: .leading, spacing: 12) {
profileInfo(for: profile)
actionButtonsRow(for: profile)
}
.pickerStyle(.menu)
.labelsHidden()
#elseif os(macOS)
VStack(alignment: .leading, spacing: 8) {
ForEach(profileList, id: \.id) { profile in
HStack {
Button {
selectedProfileID = profile.id
} label: {
HStack(spacing: 8) {
Image(systemName: selectedProfileID == profile.id ? "circle.fill" : "circle")
.font(.system(size: 12))
Text(profile.name)
.font(.subheadline)
Spacer()
}
}
.buttonStyle(.plain)
.foregroundColor(selectedProfileID == profile.id ? .accentColor : .primary)
}
}
}
#endif
}
}
}
}
@ViewBuilder
private func actionButtonsRow(for profile: ProfilePreview) -> some View {
HStack(spacing: 12) {
editButton(for: profile)
if profile.type == .remote {
updateButton(for: profile)
qrCodeButton(for: profile)
}
ProfileShareButton($viewModel.alert, profile.origin) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 16))
}
.buttonStyle(.plain)
.actionButtonStyle()
}
}
@ViewBuilder
private func editButton(for profile: ProfilePreview) -> some View {
Button {
viewModel.profileToEdit = profile.origin
} label: {
Image(systemName: "pencil")
.font(.system(size: 16))
}
.buttonStyle(.plain)
.actionButtonStyle()
}
@ViewBuilder
private func updateButton(for profile: ProfilePreview) -> some View {
Button {
viewModel.isUpdating = true
Task {
await viewModel.updateProfile(profile.origin, environments: environments)
}
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 16))
.rotationEffect(.degrees(viewModel.isUpdating ? 360 : 0))
.animation(
viewModel.isUpdating
? .linear(duration: 1).repeatForever(autoreverses: false)
: .default,
value: viewModel.isUpdating
)
}
.buttonStyle(.plain)
.actionButtonStyle()
.disabled(viewModel.isUpdating)
}
@ViewBuilder
private func qrCodeButton(for profile: ProfilePreview) -> some View {
#if os(iOS) || os(tvOS)
Button {
viewModel.showQRCode = true
} label: {
Image(systemName: "qrcode")
.font(.system(size: 16))
}
.buttonStyle(.plain)
.actionButtonStyle()
#elseif os(macOS)
Button {
viewModel.showQRCode = true
} label: {
Image(systemName: "qrcode")
.font(.system(size: 16))
}
.buttonStyle(.plain)
.actionButtonStyle()
.popover(isPresented: $viewModel.showQRCode, arrowEdge: .bottom) {
if let remoteURL = profile.remoteURL {
QRCodeContentView(profileName: profile.name, remoteURL: remoteURL)
}
}
#endif
}
@ViewBuilder
private func profileInfo(for profile: ProfilePreview) -> some View {
HStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: profile.type == .remote ? "cloud.fill" : "doc.fill")
.font(.system(size: 12))
.foregroundColor(.secondary)
Text(profile.type == .remote ? "Remote" : "Local")
.font(.caption)
.foregroundColor(.primary)
}
if profile.type == .remote, let lastUpdated = profile.lastUpdated {
HStack(spacing: 4) {
Image(systemName: "clock.fill")
.font(.system(size: 12))
.foregroundColor(.secondary)
Text(lastUpdated.myFormat)
.font(.caption)
.foregroundColor(.primary)
}
}
}
}
private var manageProfilesSheet: some View {
NavigationSheet(
title: "Profiles",
showDoneButton: true,
onDismiss: { viewModel.showManageProfiles = false }
) {
ManageProfilesView()
.environmentObject(environments)
}
}
@ViewBuilder
private func editProfileSheet(for profile: Profile) -> some View {
NavigationSheet(title: "Edit Profile") {
EditProfileView()
.environmentObject(profile)
.environmentObject(environments)
}
}
}
// MARK: - ViewModel
extension ProfileCard {
@MainActor
class ViewModel: ObservableObject {
@Published var showNewProfile = false
@Published var showManageProfiles = false
@Published var showQRCode = false
@Published var isUpdating = false
@Published var alert: Alert?
@Published var profileToEdit: Profile?
func updateProfile(_ profile: Profile, environments: ExtensionEnvironments) async {
defer { isUpdating = false }
do {
try await profile.updateRemoteProfile()
environments.profileUpdate.send()
} catch {
alert = Alert(
title: Text("Update Failed"),
message: Text(error.localizedDescription)
)
}
}
}
}
// MARK: - NewProfileNavigationView
extension ProfileCard {
@MainActor
struct NewProfileNavigationView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@State private var createdProfile: Profile?
var body: some View {
NavigationStackCompat {
if let profile = createdProfile {
EditProfileView()
.environmentObject(profile)
.environmentObject(environments)
} else {
NewProfileView { profile in
createdProfile = profile
}
.environmentObject(environments)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
#if os(iOS) || os(tvOS)
.presentationDetentsIfAvailable()
#endif
}
}
}
// MARK: - ManageProfilesView
extension ProfileCard {
@MainActor
struct ManageProfilesView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@StateObject private var viewModel = ProfileViewModel()
var body: some View {
VStack {
if viewModel.isLoading {
ProgressView().onAppear {
viewModel.setEnvironments(environments)
Task {
await viewModel.doReload()
}
}
} else {
FormView {
if viewModel.profileList.isEmpty {
Text("Empty profiles")
} else {
List {
ForEach(viewModel.profileList, id: \.id) { profile in
ManageProfileItem(viewModel, profile)
}
.onMove(perform: moveProfile)
#if os(macOS)
.onDelete(perform: deleteProfile)
#endif
}
#if os(iOS) || os(tvOS)
.environment(\.editMode, .constant(.active))
.deleteDisabled(true)
#endif
}
}
}
}
.disabled(viewModel.isUpdating)
.alertBinding($viewModel.alert, $viewModel.isLoading)
.onReceive(environments.profileUpdate) { _ in
Task {
await viewModel.doReload()
}
}
}
private func moveProfile(from source: IndexSet, to destination: Int) {
viewModel.moveProfile(from: source, to: destination)
}
private func deleteProfile(where profileIndex: IndexSet) {
viewModel.deleteProfile(where: profileIndex)
}
}
@MainActor
struct ManageProfileItem: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@ObservedObject private var viewModel: ProfileViewModel
@State private var profile: ProfilePreview
@State private var shareLinkPresented = false
init(_ viewModel: ProfileViewModel, _ profile: ProfilePreview) {
self.viewModel = viewModel
_profile = State(initialValue: profile)
}
var body: some View {
HStack {
Image(systemName: "line.3.horizontal")
.foregroundStyle(.secondary)
VStack(alignment: .leading) {
Text(profile.name)
#if os(macOS)
if profile.type == .remote {
Spacer(minLength: 4)
Text("Last Updated: \(profile.origin.lastUpdated!.myFormat)").font(.caption)
}
#endif
}
Spacer()
HStack(spacing: 8) {
if profile.type == .remote {
Button {
viewModel.isUpdating = true
Task {
await viewModel.updateProfile(profile.origin)
profile = ProfilePreview(profile.origin)
}
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 16))
.rotationEffect(.degrees(viewModel.isUpdating ? 360 : 0))
.animation(
viewModel.isUpdating
? .linear(duration: 1).repeatForever(autoreverses: false)
: .default,
value: viewModel.isUpdating
)
}
.buttonStyle(.plain)
.actionButtonStyle()
.disabled(viewModel.isUpdating)
Button {
shareLinkPresented = true
} label: {
Image(systemName: "qrcode")
.font(.system(size: 16))
}
.buttonStyle(.plain)
.actionButtonStyle()
#if os(macOS)
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
QRCodeContentView(profileName: profile.name, remoteURL: profile.remoteURL!)
}
#elseif os(iOS) || os(tvOS)
.sheet(isPresented: $shareLinkPresented) {
QRCodeSheet(profileName: profile.name, remoteURL: profile.remoteURL!)
}
#endif
}
ShareButtonCompat($viewModel.alert) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 16))
} itemURL: {
try profile.origin.toContent().generateShareFile()
}
.actionButtonStyle()
Button {
Task {
await viewModel.deleteProfile(profile.origin)
}
} label: {
Image(systemName: "trash")
.font(.system(size: 16))
}
.buttonStyle(.plain)
.actionButtonStyle()
}
}
#if os(macOS)
.padding(.vertical, 4)
#endif
}
}
}
@@ -0,0 +1,288 @@
import Library
import SwiftUI
#if os(iOS) || os(tvOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// A custom view that looks like a navigation link but opens a dropdown menu
struct ProfileSelectorButton: View {
let items: [ProfilePreview]
let selectedItem: ProfilePreview?
let onSelect: (Int64) -> Void
init(
items: [ProfilePreview],
selectedItem: ProfilePreview?,
onSelect: @escaping (Int64) -> Void
) {
self.items = items
self.selectedItem = selectedItem
self.onSelect = onSelect
}
var body: some View {
#if os(iOS) || os(tvOS)
ProfileSelectorUIButton(
items: items,
selectedItem: selectedItem,
onSelect: onSelect
)
.frame(height: 44)
.selectorBackground()
#elseif os(macOS)
ProfileSelectorNSButton(
items: items,
selectedItem: selectedItem,
onSelect: onSelect
)
.frame(height: 32)
.selectorBackground()
#endif
}
}
// MARK: - UIKit Implementation (iOS/tvOS)
#if os(iOS) || os(tvOS)
/// Custom UIButton subclass that allows customizing menu attachment point
private class MenuAttachmentButton: UIButton {
override func menuAttachmentPoint(for _: UIContextMenuConfiguration) -> CGPoint {
// Attach menu to bottom-leading corner of the button
CGPoint(x: 0, y: bounds.height)
}
}
/// UIViewRepresentable wrapper for UIButton with UIMenu
private struct ProfileSelectorUIButton: UIViewRepresentable {
let items: [ProfilePreview]
let selectedItem: ProfilePreview?
let onSelect: (Int64) -> Void
func makeUIView(context _: Context) -> MenuAttachmentButton {
let button = MenuAttachmentButton(type: .system)
button.showsMenuAsPrimaryAction = true
button.contentHorizontalAlignment = .fill
// Configure button appearance
var config = UIButton.Configuration.plain()
config.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)
button.configuration = config
updateButtonContent(button)
updateMenu(button)
return button
}
func updateUIView(_ button: MenuAttachmentButton, context _: Context) {
updateButtonContent(button)
updateMenu(button)
}
private func updateButtonContent(_ button: MenuAttachmentButton) {
// Remove existing subviews
for subview in button.subviews {
if subview is UIStackView {
subview.removeFromSuperview()
}
}
// Create content stack
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.alignment = .center
stackView.distribution = .fill
stackView.spacing = 8
stackView.isUserInteractionEnabled = false
stackView.translatesAutoresizingMaskIntoConstraints = false
// Title label
let titleLabel = UILabel()
titleLabel.text = selectedItem?.name ?? "Select Profile"
titleLabel.font = .systemFont(ofSize: 17, weight: .medium)
titleLabel.textColor = .label
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
// Chevron image
let chevronConfig = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
let chevronImage = UIImage(systemName: "chevron.up.chevron.down", withConfiguration: chevronConfig)
let chevronView = UIImageView(image: chevronImage)
chevronView.tintColor = .secondaryLabel
chevronView.setContentHuggingPriority(.required, for: .horizontal)
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(chevronView)
button.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
stackView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -14),
stackView.centerYAnchor.constraint(equalTo: button.centerYAnchor),
])
}
private func updateMenu(_ button: MenuAttachmentButton) {
let actions = items.map { item in
UIAction(
title: item.name,
image: UIImage(systemName: item.type.iconName)
) { [item] _ in
onSelect(item.id)
}
}
button.menu = UIMenu(children: actions)
}
}
#endif
// MARK: - AppKit Implementation (macOS)
#if os(macOS)
/// NSViewRepresentable wrapper for a clickable view with NSMenu
private struct ProfileSelectorNSButton: NSViewRepresentable {
let items: [ProfilePreview]
let selectedItem: ProfilePreview?
let onSelect: (Int64) -> Void
func makeNSView(context: Context) -> NSView {
let containerView = MenuContainerView()
containerView.coordinator = context.coordinator
updateContent(containerView)
context.coordinator.updateMenu(items: items, onSelect: onSelect)
return containerView
}
func updateNSView(_ nsView: NSView, context: Context) {
guard let containerView = nsView as? MenuContainerView else { return }
updateContent(containerView)
context.coordinator.updateMenu(items: items, onSelect: onSelect)
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
private func updateContent(_ containerView: MenuContainerView) {
containerView.titleText = selectedItem?.name ?? "Select Profile"
}
private class MenuContainerView: NSView {
weak var coordinator: Coordinator?
private let titleLabel = NSTextField(labelWithString: "")
private let chevronView = NSImageView()
var titleText: String = "" {
didSet {
titleLabel.stringValue = titleText
}
}
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setupViews()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupViews()
}
private func setupViews() {
titleLabel.font = .systemFont(ofSize: 13, weight: .medium)
titleLabel.textColor = .labelColor
titleLabel.alignment = .left
titleLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(titleLabel)
let chevronConfig = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
chevronView.image = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: nil)?
.withSymbolConfiguration(chevronConfig)
chevronView.contentTintColor = .secondaryLabelColor
chevronView.translatesAutoresizingMaskIntoConstraints = false
addSubview(chevronView)
NSLayoutConstraint.activate([
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
chevronView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
chevronView.centerYAnchor.constraint(equalTo: centerYAnchor),
titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevronView.leadingAnchor, constant: -8),
])
}
override func mouseDown(with _: NSEvent) {
coordinator?.showMenu(in: self)
}
override func resetCursorRects() {
addCursorRect(bounds, cursor: .pointingHand)
}
}
class Coordinator: NSObject {
private var menu = NSMenu()
private var onSelectCallback: ((Int64) -> Void)?
func updateMenu(items: [ProfilePreview], onSelect: @escaping (Int64) -> Void) {
menu.removeAllItems()
onSelectCallback = onSelect
for item in items {
let menuItem = NSMenuItem(
title: item.name,
action: #selector(menuItemSelected(_:)),
keyEquivalent: ""
)
menuItem.target = self
menuItem.tag = Int(item.id)
menuItem.image = NSImage(systemSymbolName: item.type.iconName, accessibilityDescription: nil)
menu.addItem(menuItem)
}
}
@objc private func menuItemSelected(_ sender: NSMenuItem) {
onSelectCallback?(Int64(sender.tag))
}
func showMenu(in view: NSView) {
let location = NSPoint(x: 0, y: view.bounds.height + 4)
menu.popUp(positioning: nil, at: location, in: view)
}
}
}
#endif
// MARK: - ProfileType Extension
private extension ProfileType {
var iconName: String {
switch self {
case .local:
"doc.fill"
case .icloud:
"icloud.fill"
case .remote:
"cloud.fill"
}
}
}
// MARK: - View Extension
private extension View {
@ViewBuilder
func selectorBackground() -> some View {
if #available(iOS 26.0, macOS 26.0, tvOS 26.0, *) {
self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 12))
} else {
background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.1))
)
}
}
}
@@ -8,7 +8,7 @@ public struct StatusCard: View {
public init() {}
public var body: some View {
DashboardCardView(title: "Status", isHalfWidth: true) {
DashboardCardView(title: String(localized: "Status"), isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) {
if ApplicationLibrary.inPreview {
DashboardCardLine(String(localized: "Memory"), "6.4 MB")
@@ -8,7 +8,7 @@ public struct TrafficCard: View {
public init() {}
public var body: some View {
DashboardCardView(title: "Traffic", isHalfWidth: true) {
DashboardCardView(title: String(localized: "Traffic"), isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) {
if ApplicationLibrary.inPreview {
DashboardCardLine(String(localized: "Uplink"), "38 B/s")
@@ -8,7 +8,7 @@ public struct TrafficTotalCard: View {
public init() {}
public var body: some View {
DashboardCardView(title: "Traffic Total", isHalfWidth: true) {
DashboardCardView(title: String(localized: "Traffic Total"), isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) {
if ApplicationLibrary.inPreview {
DashboardCardLine(String(localized: "Uplink"), "52 MB")
@@ -6,8 +6,11 @@ import SwiftUI
public struct DashboardView: View {
@Environment(\.openURL) private var openURL
@Environment(\.cardConfigurationVersion) private var cardConfigurationVersion
@Environment(\.importProfile) private var importProfile
@Environment(\.importRemoteProfile) private var importRemoteProfile
@EnvironmentObject private var environments: ExtensionEnvironments
@StateObject private var coordinator = DashboardCoordinator()
@State private var importRemoteProfileRequest: NewProfileView.ImportRequest?
#if os(macOS)
@Environment(\.controlActiveState) private var controlActiveState
@@ -22,6 +25,17 @@ public struct DashboardView: View {
#if os(macOS)
Task { await coordinator.reload() }
#endif
handleImportProfile()
handleImportRemoteProfile()
}
.onChangeCompat(of: importProfile.wrappedValue) { _ in
handleImportProfile()
}
.onChangeCompat(of: importRemoteProfile.wrappedValue) { _ in
handleImportRemoteProfile()
}
.sheet(item: $importRemoteProfileRequest) { request in
importRemoteProfileSheet(for: request)
}
#if os(macOS)
.onChangeCompat(of: controlActiveState) { state in
@@ -31,6 +45,52 @@ public struct DashboardView: View {
#endif
}
private func handleImportProfile() {
if let profile = importProfile.wrappedValue {
importProfile.wrappedValue = nil
coordinator.alert = Alert(
title: Text("Import Profile"),
message: Text("Are you sure to import profile \(profile.name)?"),
primaryButton: .default(Text("Import")) {
Task {
do {
try await profile.importProfile()
} catch {
coordinator.alert = Alert(error)
return
}
environments.profileUpdate.send()
}
},
secondaryButton: .cancel()
)
}
}
private func handleImportRemoteProfile() {
if let remoteProfile = importRemoteProfile.wrappedValue {
importRemoteProfile.wrappedValue = nil
coordinator.alert = Alert(
title: Text("Import Remote Profile"),
message: Text("Are you sure to import remote profile \(remoteProfile.name)? You will connect to \(remoteProfile.host) to download the configuration."),
primaryButton: .default(Text("Import")) {
importRemoteProfileRequest = .init(name: remoteProfile.name, url: remoteProfile.url)
},
secondaryButton: .cancel()
)
}
}
@ViewBuilder
private func importRemoteProfileSheet(for request: NewProfileView.ImportRequest) -> some View {
NavigationSheet(title: "Import Profile", onDismiss: {
environments.profileUpdate.send()
}) {
NewProfileView(request)
.environmentObject(environments)
}
}
@ViewBuilder
private var content: some View {
#if os(macOS)
@@ -34,13 +34,6 @@ public struct OverviewView: View {
Group {
if configuration.isLoading {
ProgressView()
} else if profileList.isEmpty {
VStack {
Spacer()
Text("Empty profiles")
.foregroundStyle(.secondary)
Spacer()
}
} else {
ScrollView {
cardGrid
@@ -13,14 +13,13 @@ public enum NavigationPage: Int, CaseIterable, Identifiable {
case connections
#endif
case logs
case profiles
case settings
}
public extension NavigationPage {
#if os(macOS)
static var macosDefaultPages: [NavigationPage] {
[.logs, .profiles, .settings]
[.logs, .settings]
}
#endif
@@ -37,12 +36,10 @@ public extension NavigationPage {
case .groups:
return String(localized: "Groups")
case .connections:
return NSLocalizedString("Connections", comment: "")
return String(localized: "Connections")
#endif
case .logs:
return String(localized: "Logs")
case .profiles:
return String(localized: "Profiles")
case .settings:
return String(localized: "Settings")
}
@@ -60,8 +57,6 @@ public extension NavigationPage {
#endif
case .logs:
return "doc.text.fill"
case .profiles:
return "list.bullet.rectangle.fill"
case .settings:
return "gear.circle.fill"
}
@@ -81,8 +76,6 @@ public extension NavigationPage {
#endif
case .logs:
LogView()
case .profiles:
ProfileView()
case .settings:
SettingView()
}
@@ -55,46 +55,16 @@ public struct EditProfileView: View {
FormTextItem("Last Updated", profile.lastUpdated!.myFormat)
}
}
Section("Action") {
if profile.type != .remote {
#if os(iOS) || os(macOS)
FormNavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
} label: {
Label("Edit Content", systemImage: "pencil")
.foregroundColor(.accentColor)
}
#endif
} else {
#if os(iOS) || os(macOS)
FormNavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
} label: {
Label("View Content", systemImage: "doc.fill")
.foregroundColor(.accentColor)
}
#endif
FormButton {
viewModel.isLoading = true
Task {
await viewModel.updateProfile(profile, environments: environments)
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
.foregroundColor(.accentColor)
.disabled(viewModel.isLoading)
}
FormButton(role: .destructive) {
Task {
await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss)
}
} label: {
Label("Delete", systemImage: "trash.fill")
}
.foregroundColor(.red)
}
#if os(iOS) || os(tvOS)
ProfileActionToolbar(profile: profile, viewModel: viewModel)
#endif
}
#if os(macOS)
.safeAreaInset(edge: .bottom) {
ProfileActionToolbar(profile: profile, viewModel: viewModel)
}
#endif
.onChangeCompat(of: profile.name) {
viewModel.markAsChanged()
}
@@ -8,14 +8,20 @@ public struct NewProfileView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: NewProfileViewModel
private var onSuccess: ((Profile) async -> Void)?
public struct ImportRequest: Codable, Hashable {
public struct ImportRequest: Codable, Hashable, Identifiable {
public var id: String { url }
public let name: String
public let url: String
}
public init(_ importRequest: ImportRequest? = nil) {
public init(
_ importRequest: ImportRequest? = nil,
onSuccess: ((Profile) async -> Void)? = nil
) {
_viewModel = StateObject(wrappedValue: NewProfileViewModel(importRequest: importRequest))
self.onSuccess = onSuccess
}
public var body: some View {
@@ -86,23 +92,55 @@ public struct NewProfileView: View {
#endif
}
}
Section {
if !viewModel.isSaving {
FormButton {
viewModel.isSaving = true
Task {
await viewModel.createProfile(environments: environments, dismiss: dismiss)
#if os(iOS) || os(tvOS)
Section {
if !viewModel.isSaving {
FormButton {
viewModel.isSaving = true
Task {
await viewModel.createProfile(
environments: environments,
dismiss: onSuccess == nil ? dismiss : nil,
onSuccess: onSuccess
)
}
} label: {
Label("Create", systemImage: "doc.fill.badge.plus")
}
} label: {
Label("Create", systemImage: "doc.fill.badge.plus")
} else {
ProgressView()
}
} else {
ProgressView()
}
}
#endif
}
.navigationTitle("New Profile")
.alertBinding($viewModel.alert)
#if os(macOS)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
if viewModel.isSaving {
ProgressView()
} else {
Button("Create") {
viewModel.isSaving = true
Task {
await viewModel.createProfile(
environments: environments,
dismiss: onSuccess == nil ? dismiss : nil,
onSuccess: onSuccess
)
}
}
}
}
}
#endif
.disabled(viewModel.isSaving)
.alertBinding($viewModel.alert)
#if os(iOS) || os(macOS)
.fileImporter(
isPresented: $viewModel.pickerPresented,
@@ -36,37 +36,52 @@ public final class NewProfileViewModel: ObservableObject {
remotePath = ""
}
public func createProfile(environments: ExtensionEnvironments, dismiss: DismissAction) async {
defer {
isSaving = false
}
if profileName.isEmpty {
public func createProfile(
environments: ExtensionEnvironments,
dismiss: DismissAction? = nil,
onSuccess: ((Profile) async -> Void)? = nil,
sendUpdateNotification: Bool = true
) async {
defer { isSaving = false }
guard !profileName.isEmpty else {
alert = Alert(errorMessage: String(localized: "Missing profile name"))
return
}
if remotePath.isEmpty {
if profileType == .icloud {
alert = Alert(errorMessage: String(localized: "Missing path"))
return
} else if profileType == .remote {
alert = Alert(errorMessage: String(localized: "Missing URL"))
return
}
if profileType == .icloud, remotePath.isEmpty {
alert = Alert(errorMessage: String(localized: "Missing path"))
return
}
if profileType == .remote, remotePath.isEmpty {
alert = Alert(errorMessage: String(localized: "Missing URL"))
return
}
let createdProfile: Profile
do {
try await createProfileBackground()
createdProfile = try await createProfileBackground()
} catch {
alert = Alert(error)
return
}
environments.profileUpdate.send()
dismiss()
if let onSuccess {
await onSuccess(createdProfile)
} else {
if sendUpdateNotification {
environments.profileUpdate.send()
}
dismiss?()
}
#if os(macOS)
resetFields()
#endif
}
private nonisolated func createProfileBackground() async throws {
private nonisolated func createProfileBackground() async throws -> Profile {
let nextProfileID = try await ProfileManager.nextID()
var savePath = ""
@@ -130,7 +145,9 @@ public final class NewProfileViewModel: ObservableObject {
remoteURL = remotePath
lastUpdated = .now
}
try await ProfileManager.create(Profile(
// Create Profile object - GRDB will set its ID after insertion
let profile = Profile(
name: profileName,
type: profileType,
path: savePath,
@@ -138,7 +155,9 @@ public final class NewProfileViewModel: ObservableObject {
autoUpdate: autoUpdate,
autoUpdateInterval: autoUpdateInterval,
lastUpdated: lastUpdated
))
)
try await ProfileManager.create(profile)
if profileType == .remote {
#if os(iOS) || os(tvOS)
try UIProfileUpdateTask.configure()
@@ -146,5 +165,8 @@ public final class NewProfileViewModel: ObservableObject {
try await ProfileUpdateTask.configure()
#endif
}
// Return the profile object which now has its ID set by GRDB
return profile
}
}
@@ -0,0 +1,109 @@
import Libbox
import Library
import SwiftUI
@MainActor
public struct ProfileActionToolbar: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.dismiss) private var dismiss
@ObservedObject private var profile: Profile
@ObservedObject private var viewModel: EditProfileViewModel
public init(profile: Profile, viewModel: EditProfileViewModel) {
self.profile = profile
self.viewModel = viewModel
}
public var body: some View {
#if os(iOS) || os(tvOS)
iosBody
#elseif os(macOS)
macOSBody
#endif
}
#if os(iOS) || os(tvOS)
private var iosBody: some View {
Section("Action") {
if profile.type != .remote {
FormNavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
} label: {
Label("Edit Content", systemImage: "pencil")
.foregroundColor(.accentColor)
}
} else {
FormNavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
} label: {
Label("View Content", systemImage: "doc.fill")
.foregroundColor(.accentColor)
}
FormButton {
viewModel.isLoading = true
Task {
await viewModel.updateProfile(profile, environments: environments)
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
.foregroundColor(.accentColor)
.disabled(viewModel.isLoading)
}
FormButton(role: .destructive) {
Task {
await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss)
}
} label: {
Label("Delete", systemImage: "trash.fill")
}
.foregroundColor(.red)
}
}
#endif
#if os(macOS)
private var macOSBody: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 12) {
if profile.type != .remote {
NavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
} label: {
Text("Edit Content")
}
} else {
NavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
} label: {
Text("View Content")
}
Button {
viewModel.isLoading = true
Task {
await viewModel.updateProfile(profile, environments: environments)
}
} label: {
Text("Update")
}
.disabled(viewModel.isLoading)
}
Spacer()
Button("Delete", role: .destructive) {
Task {
await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss)
}
}
.foregroundColor(.red)
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
}
}
#endif
}
@@ -0,0 +1,78 @@
import Library
import SwiftUI
public enum SheetSize {
case large
case medium
#if os(iOS) || os(tvOS)
@available(iOS 16.0, tvOS 17.0, *)
var presentationDetent: SwiftUI.PresentationDetent {
switch self {
case .large: return .large
case .medium: return .medium
}
}
#endif
}
@MainActor
public struct NavigationSheet<Content: View>: View {
private let title: String
private let size: SheetSize
private let showDoneButton: Bool
private let onDismiss: (() -> Void)?
private let content: () -> Content
public init(
title: String,
size: SheetSize = .large,
showDoneButton: Bool = false,
onDismiss: (() -> Void)? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.title = title
self.size = size
self.showDoneButton = showDoneButton
self.onDismiss = onDismiss
self.content = content
}
public var body: some View {
NavigationStackCompat {
content()
.navigationTitle(title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if os(macOS)
.toolbar {
if showDoneButton {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
onDismiss?()
}
}
}
}
#endif
}
#if os(iOS) || os(tvOS)
.sheetDetent(size)
#endif
}
}
#if os(iOS) || os(tvOS)
private extension View {
@ViewBuilder
func sheetDetent(_ size: SheetSize) -> some View {
if #available(iOS 16.0, tvOS 17.0, *) {
self.presentationDetents([size.presentationDetent])
.presentationDragIndicator(.visible)
} else {
self
}
}
}
#endif
@@ -2,7 +2,6 @@ import Foundation
import Libbox
import Library
import Network
import QRCode
import SwiftUI
@MainActor
@@ -172,17 +171,17 @@ public struct ProfileView: View {
public var body: some View {
#if os(iOS) || os(macOS)
if #available(iOS 16.0, macOS 13.0,*) {
body0.draggable(profile.origin)
if #available(iOS 16.0, macOS 13.0, *) {
draggableBody.draggable(profile.origin)
} else {
body0
draggableBody
}
#else
body0
draggableBody
#endif
}
private var body0: some View {
private var draggableBody: some View {
viewBuilder {
#if !os(macOS)
FormNavigationLink {
@@ -191,7 +190,7 @@ public struct ProfileView: View {
Text(profile.name)
}
.sheet(isPresented: $shareLinkPresented) {
shareLinkView.padding()
QRCodeSheet(profileName: profile.name, remoteURL: profile.remoteURL!)
}
.contextMenu {
ProfileShareButton($viewModel.alert, profile.origin) {
@@ -254,7 +253,7 @@ public struct ProfileView: View {
}
.padding(.leading, 4)
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
shareLinkView
QRCodeContentView(profileName: profile.name, remoteURL: profile.remoteURL!)
}
}
ProfileShareButton($viewModel.alert, profile.origin) {
@@ -279,41 +278,5 @@ public struct ProfileView: View {
#endif
}
}
private var shareLinkView: some View {
#if os(iOS)
viewBuilder {
if #available(iOS 16.0, *) {
shareLinkView0
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
} else {
shareLinkView0
}
}
#elseif os(macOS)
shareLinkView0
.frame(minWidth: 300, minHeight: 300)
#else
shareLinkView0
#endif
}
private var foregroundColor: CGColor {
#if canImport(UIKit)
return UIColor.label.cgColor
#elseif canImport(AppKit)
return NSColor.labelColor.cgColor
#endif
}
private var shareLinkView0: some View {
QRCodeViewUI(
content: LibboxGenerateRemoteProfileImportLink(profile.name, profile.remoteURL!),
errorCorrection: .low,
foregroundColor: foregroundColor,
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
)
}
}
}
@@ -0,0 +1,78 @@
import Foundation
import Libbox
import Library
import QRCode
import SwiftUI
private extension CGColor {
static var labelColor: CGColor {
#if canImport(UIKit)
UIColor.label.cgColor
#elseif canImport(AppKit)
NSColor.labelColor.cgColor
#endif
}
}
@MainActor
public struct QRCodeContentView: View {
private let profileName: String
private let remoteURL: String
public init(profileName: String, remoteURL: String) {
self.profileName = profileName
self.remoteURL = remoteURL
}
public var body: some View {
VStack {
Spacer()
QRCodeViewUI(
content: LibboxGenerateRemoteProfileImportLink(profileName, remoteURL),
errorCorrection: .low,
foregroundColor: .labelColor,
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
)
#if os(macOS)
.frame(minWidth: 300, minHeight: 300)
#endif
Spacer()
}
.padding()
}
}
@MainActor
public struct QRCodeSheet: View {
private let profileName: String
private let remoteURL: String
public init(profileName: String, remoteURL: String) {
self.profileName = profileName
self.remoteURL = remoteURL
}
public var body: some View {
#if os(iOS) || os(tvOS)
if #available(iOS 16.0, tvOS 17.0, *) {
NavigationStackCompat {
QRCodeContentView(profileName: profileName, remoteURL: remoteURL)
.navigationTitle("Share QR Code")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
} else {
NavigationStackCompat {
QRCodeContentView(profileName: profileName, remoteURL: remoteURL)
.navigationTitle("Share QR Code")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
#endif
}
}
+12 -7
View File
@@ -64,6 +64,7 @@ public class CommandClient: ObservableObject {
@Published public var connectionStateFilter = ConnectionStateFilter.active
@Published public var connectionSort = ConnectionSort.byDate
@Published public var connections: [LibboxConnection]?
@Published public var hasAnyConnection: Bool = false
public var rawConnections: LibboxConnections?
// Batch processing for logs
@@ -92,7 +93,7 @@ public class CommandClient: ObservableObject {
connectTask.cancel()
}
connectTask = Task {
await connect0()
await performConnection()
}
}
@@ -125,10 +126,13 @@ public class CommandClient: ObservableObject {
guard let message = rawConnections else {
return
}
connections = filterConnections(message)
let result = filterConnections(message)
connections = result.connections
hasAnyConnection = result.hasAny
}
private func filterConnections(_ message: LibboxConnections) -> [LibboxConnection] {
private func filterConnections(_ message: LibboxConnections) -> (connections: [LibboxConnection], hasAny: Bool) {
let hasAny = message.iterator()?.hasNext() ?? false
message.filterState(Int32(connectionStateFilter.rawValue))
switch connectionSort {
case .byDate:
@@ -143,7 +147,7 @@ public class CommandClient: ObservableObject {
while connectionIterator.hasNext() {
connections.append(connectionIterator.next()!)
}
return connections
return (connections: connections, hasAny: hasAny)
}
private func initializeConnectionFilterState() async {
@@ -155,7 +159,7 @@ public class CommandClient: ObservableObject {
}
}
private nonisolated func connect0() async {
private nonisolated func performConnection() async {
if connectionTypes.contains(.connections) {
await initializeConnectionFilterState()
}
@@ -299,10 +303,11 @@ public class CommandClient: ObservableObject {
guard let message else {
return
}
let connections = commandClient.filterConnections(message)
let result = commandClient.filterConnections(message)
DispatchQueue.main.async { [self] in
commandClient.rawConnections = message
commandClient.connections = connections
commandClient.connections = result.connections
commandClient.hasAnyConnection = result.hasAny
}
}
}
+8 -12
View File
@@ -529,7 +529,14 @@
}
},
"Done" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "完成"
}
}
}
},
"Downlink" : {
"localizations" : {
@@ -611,17 +618,6 @@
}
}
},
"Enabled" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "启用"
}
}
}
},
"enforceRoutes" : {
"shouldTranslate" : false
},
+4 -4
View File
@@ -45,8 +45,8 @@ public class MainViewModel: ObservableObject {
if error != nil {
return
}
if selection != .profiles {
selection = .profiles
if selection != .dashboard {
selection = .dashboard
}
} else if url.pathExtension == "bpf" {
Task {
@@ -66,8 +66,8 @@ public class MainViewModel: ObservableObject {
alert = Alert(error)
return
}
if selection != .profiles {
selection = .profiles
if selection != .dashboard {
selection = .dashboard
}
}
+129 -52
View File
@@ -12,57 +12,126 @@ struct MainView: View {
@State private var importProfile: LibboxProfileContent?
@State private var importRemoteProfile: LibboxImportRemoteProfile?
@State private var alert: Alert?
@State private var showGroups = false
@State private var showConnections = false
@State private var buttonState = ButtonVisibilityState()
var body: some View {
if ApplicationLibrary.inPreview {
body1.preferredColorScheme(.dark)
private var shouldShowBottomAccessory: Bool {
guard !environments.extensionProfileLoading else {
return false
}
guard !environments.emptyProfiles else {
return false
}
guard environments.extensionProfile != nil else {
return false
}
return true
}
@available(iOS 26.0, *)
@ViewBuilder
private var tabViewContent: some View {
if shouldShowBottomAccessory {
TabView(selection: $selection) {
ForEach(NavigationPage.allCases, id: \.self) { page in
NavigationStackCompat {
page.contentView
.navigationTitle(page.title)
}
.tag(page)
.tabItem { page.label }
}
}
.tabViewBottomAccessory {
HStack(spacing: 12) {
if let profile = environments.extensionProfile {
StatusText(profile: profile)
}
Spacer()
NavigationButtonsView(
showGroupsButton: buttonState.showGroupsButton,
showConnectionsButton: buttonState.showConnectionsButton,
groupsCount: buttonState.groupsCount,
connectionsCount: buttonState.connectionsCount,
onGroupsTap: { showGroups = true },
onConnectionsTap: { showConnections = true }
)
Divider()
StartStopButton()
}
.padding(.horizontal)
}
} else {
body1
TabView(selection: $selection) {
ForEach(NavigationPage.allCases, id: \.self) { page in
NavigationStackCompat {
page.contentView
.navigationTitle(page.title)
}
.tag(page)
.tabItem { page.label }
}
}
}
}
var body1: some View {
var body: some View {
if ApplicationLibrary.inPreview {
mainBody.preferredColorScheme(.dark)
} else {
mainBody
}
}
private var mainBody: some View {
viewBuilder {
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
TabView(selection: $selection) {
ForEach(NavigationPage.allCases, id: \.self) { page in
NavigationStackCompat {
page.contentView
.navigationTitle(page.title)
}
.tag(page)
.tabItem { page.label }
}
}
.tabViewBottomAccessory {
HStack(spacing: 12) {
if let profile = environments.extensionProfile {
StatusText(profile: profile)
}
Spacer()
StartStopButton()
}
.padding(.horizontal)
}
.onAppear {
environments.postReload()
}
.alertBinding($alert)
.onChangeCompat(of: scenePhase) { newValue in
if newValue == .active {
tabViewContent
.onAppear {
environments.postReload()
updateButtonVisibility()
}
}
.onChangeCompat(of: selection) { newValue in
if newValue == .logs {
environments.connect()
.alertBinding($alert)
.onChangeCompat(of: scenePhase) { newValue in
if newValue == .active {
environments.postReload()
}
}
.onChangeCompat(of: selection) { newValue in
if newValue == .logs {
environments.connect()
}
}
.onReceive(environments.commandClient.$groups) { _ in
updateButtonVisibility()
}
.onReceive(environments.commandClient.$connections) { _ in
updateButtonVisibility()
}
.onReceive(environments.commandClient.$hasAnyConnection) { _ in
updateButtonVisibility()
}
.onReceive(NotificationCenter.default.publisher(for: .NEVPNStatusDidChange)) { _ in
updateButtonVisibility()
}
.onReceive(environments.$extensionProfile) { _ in
updateButtonVisibility()
}
.onReceive(environments.$emptyProfiles) { _ in
updateButtonVisibility()
}
.environment(\.selection, $selection)
.environment(\.importProfile, $importProfile)
.environment(\.importRemoteProfile, $importRemoteProfile)
.handlesExternalEvents(preferring: [], allowing: ["*"])
.onOpenURL(perform: openURL)
.sheet(isPresented: $showGroups) {
GroupsSheetContent()
}
.sheet(isPresented: $showConnections) {
ConnectionsSheetContent()
}
}
.environment(\.selection, $selection)
.environment(\.importProfile, $importProfile)
.environment(\.importRemoteProfile, $importRemoteProfile)
.handlesExternalEvents(preferring: [], allowing: ["*"])
.onOpenURL(perform: openURL)
} else {
TabView(selection: $selection) {
ForEach(NavigationPage.allCases, id: \.self) { page in
@@ -97,6 +166,14 @@ struct MainView: View {
}
}
private func updateButtonVisibility() {
buttonState.update(
profile: environments.extensionProfile,
commandClient: environments.commandClient,
requireAnyConnection: true
)
}
private struct StatusText: View {
@ObservedObject var profile: ExtensionProfile
@@ -109,19 +186,19 @@ struct MainView: View {
private var statusText: String {
switch profile.status {
case .invalid:
return "Invalid"
return String(localized: "Invalid")
case .disconnected:
return "Stopped"
return String(localized: "Stopped")
case .connecting:
return "Starting"
return String(localized: "Starting")
case .connected:
return "Started"
return String(localized: "Started")
case .reasserting:
return "Reasserting"
return String(localized: "Reasserting")
case .disconnecting:
return "Stopping"
return String(localized: "Stopping")
@unknown default:
return "Unknown"
return String(localized: "Unknown")
}
}
}
@@ -134,8 +211,8 @@ struct MainView: View {
alert = Alert(error)
return
}
if selection != .profiles {
selection = .profiles
if selection != .dashboard {
selection = .dashboard
}
} else if url.pathExtension == "bpf" {
do {
@@ -146,8 +223,8 @@ struct MainView: View {
alert = Alert(error)
return
}
if selection != .profiles {
selection = .profiles
if selection != .dashboard {
selection = .dashboard
}
} else {
alert = Alert(errorMessage: String(localized: "Handled unknown URL \(url.absoluteString)"))