diff options
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Application-macOS.swift | 5 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 74 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineProtocol.swift | 116 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 10 | ||||
| -rw-r--r-- | Hotline/Utility/FoundationExtensions.swift | 18 | ||||
| -rw-r--r-- | Hotline/macOS/AccountManagerView.swift | 344 | ||||
| -rw-r--r-- | Hotline/macOS/HotlinePanelView.swift | 14 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 16 |
8 files changed, 596 insertions, 1 deletions
diff --git a/Hotline/Application-macOS.swift b/Hotline/Application-macOS.swift index 9aec187..63b127a 100644 --- a/Hotline/Application-macOS.swift +++ b/Hotline/Application-macOS.swift @@ -226,6 +226,11 @@ struct Application: App { } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("4"), modifiers: .command) + Button("Show Accounts") { + activeServerState?.selection = .accounts + } + .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) == false ) + .keyboardShortcut(.init("5"), modifiers: .command) } } diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 74a27a1..9bc1e56 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -581,6 +581,28 @@ class HotlineClient: NetSocketDelegate { } } + @MainActor func sendGetAccounts(callback: (([HotlineAccount]) -> Void)? = nil) { + let t = HotlineTransaction(type: .getAccounts) + + self.sendPacket(t) { reply, err in + guard err == nil else { + callback?([]) + return + } + + let accountFields = reply.getFieldList(type: .data) + + var accounts: [HotlineAccount] = [] + for data in accountFields { + accounts.append(data.getAcccount()) + } + + accounts.sort { $0.login < $1.login } + + callback?(accounts) + } + } + @MainActor func sendGetNewsArticles(path: [String] = [], callback: (([HotlineNewsArticle]) -> Void)? = nil) { var t = HotlineTransaction(type: .getNewsArticleNameList) if !path.isEmpty { @@ -667,6 +689,58 @@ class HotlineClient: NetSocketDelegate { } } + + @MainActor func sendCreateUser(name: String, login: String, password: String?, access: uint64) { + var t = HotlineTransaction(type: .newUser) + + t.setFieldString(type: .userName, val: name) + t.setFieldEncodedString(type: .userLogin, val: login) + t.setFieldUInt64(type: .userAccess, val: access) + + if let password { + t.setFieldEncodedString(type: .userPassword, val: password) + } + + self.sendPacket(t) + // TODO: handle errors + } + + @MainActor func sendSetUser(name: String, login: String, newLogin: String?, password: String?, access: uint64) { + var t = HotlineTransaction(type: .setUser) + t.setFieldString(type: .userName, val: name) + t.setFieldUInt64(type: .userAccess, val: access) + + if let newLogin { + t.setFieldEncodedString(type: .data, val: login) + t.setFieldEncodedString(type: .userLogin, val: newLogin) + } else { + t.setFieldEncodedString(type: .userLogin, val: login) + } + + // In the setUser transaction, there are 3 possibilities for the password field: + // 1. If the password was not modified, the password field is sent with a zero byte. + if password == nil { + t.setFieldUInt8(type: .userPassword, val: 0) + } + + // 2. If the transaction should update the password, the password field is sent with the new password. + if let password, password != "" { + t.setFieldEncodedString(type: .userPassword, val: password) + } + + // 3) If the transaction should remove the password, the password field is omitted from the transaction. + self.sendPacket(t) + // TODO: handle errors + } + + @MainActor func sendDeleteUser(login: String) { + var t = HotlineTransaction(type: .deleteUser) + t.setFieldEncodedString(type: .userLogin, val: login) + + self.sendPacket(t) + // TODO: handle errors + } + @MainActor func sendSetFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { var t = HotlineTransaction(type: .setFileInfo) t.setFieldString(type: .fileName, val: fileName, encoding: encoding) diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 55ac0a3..314133d 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -21,7 +21,22 @@ struct HotlineUserAccessOptions: OptionSet { static func accessIndexToBit(_ index: Int) -> Int { return 63 - index } - + + // Hotline 1.9.2 default user permissions + static let defaultAccess = Self([ + .canDownloadFiles, + .canDownloadFolders, + .canUploadFiles, + .canUploadFolders, + .canSendMessages, + .canReadMessageBoard, + .canPostMessageBoard, + .canCreateChat, + .canReadChat, + .canSendChat, + .canUseAnyName + ]) + static func printAccessOptions(_ val: HotlineUserAccessOptions) { func formatBinaryString(_ binaryString: String) -> String { var formattedString = "" @@ -181,6 +196,87 @@ struct HotlineNewsArticle: Identifiable { } } +struct HotlineAccount: Identifiable { + let id: UUID = UUID() + var name: String = "" + var login: String = "" + var password: String? = nil + var persisted: Bool = true + var access: HotlineUserAccessOptions = HotlineUserAccessOptions() + var fields: [HotlineTransactionField] = [] + + init(from data: [UInt8]) { + self.decodeFields(from: data) + } + + init(_ name: String, _ login: String, _ access: HotlineUserAccessOptions) { + self.name = name + self.login = login + self.access = access + self.persisted = false + } + + + mutating func decodeFields(from data: [UInt8]) { + var fieldData = data + + guard fieldData.count > 0, + let fieldCount = fieldData.consumeUInt16(), + fieldCount > 0 else { + return + } + + for _ in 0..<fieldCount { + if + let fieldID = fieldData.consumeUInt16(), + let fieldSize = fieldData.consumeUInt16(), + let fieldRemainingData: [UInt8] = fieldData.consumeBytes(Int(fieldSize)) { + + if let fieldType = HotlineTransactionFieldType(rawValue: fieldID) { + + let field = HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldRemainingData) + + if fieldType == .userName { + self.name = field.getString()! + } + + if fieldType == .userLogin { + self.login = field.getObfuscatedString()! + } + + if fieldType == .userPassword { + self.password = field.getObfuscatedString()! + } + + if fieldType == .userAccess, let opts = field.getUInt64(){ + self.access = HotlineUserAccessOptions(rawValue: opts) + + HotlineUserAccessOptions.printAccessOptions(self.access) + } + + + self.fields.append(HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldRemainingData)) + } + } + } + } + + static func == (lhs: HotlineAccount, rhs: HotlineAccount) -> Bool { + return lhs.id == rhs.id + } + + // Generate an initial random 21 character alphanumeric password, in the spirit of the original client + static func randomPassword() -> String { + return String((0..<20).map{_ in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()!}) + } +} + +extension HotlineAccount: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } +} + struct HotlineNewsList: Identifiable { let id: UInt32 let name: String @@ -572,6 +668,14 @@ struct HotlineTransactionField { return self.data.readString(at: 0, length: self.data.count) } + func getObfuscatedString() -> String? { + return String(bytes: self.data.map { 255 - $0 }, encoding: .ascii) + } + + func getDecodedString() -> String? { + return self.data.readString(at: 0, length: self.data.count) + } + func getUser() -> HotlineUser { return HotlineUser(from: self.data) } @@ -587,6 +691,10 @@ struct HotlineTransactionField { func getNewsList() -> HotlineNewsList { return HotlineNewsList(from: self.data) } + + func getAcccount() -> HotlineAccount { + return HotlineAccount(from: self.data) + } } struct HotlineTransaction { @@ -676,6 +784,10 @@ struct HotlineTransaction { self.fields.append(HotlineTransactionField(type: type, val: val)) } + mutating func setFieldUInt64(type: HotlineTransactionFieldType, val: UInt64) { + self.fields.append(HotlineTransactionField(type: type, val: val)) + } + mutating func setFieldEncodedString(type: HotlineTransactionFieldType, val: String) { self.fields.append(HotlineTransactionField(type: type, string: val, encrypt: true)) } @@ -838,6 +950,8 @@ enum HotlineTransactionType: UInt16 { case notifyOfUserDelete = 302 // Server case getClientInfoText = 303 case setClientUserInfo = 304 + case getAccounts = 348 + case updateUser = 349 case newUser = 350 case deleteUser = 351 case getUser = 352 diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 93f1762..70073c2 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -125,6 +125,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { var access: HotlineUserAccessOptions? var agreed: Bool = false var users: [User] = [] + var accounts: [HotlineAccount] = [] var chat: [ChatMessage] = [] var messageBoard: [String] = [] var messageBoardLoaded: Bool = false @@ -133,6 +134,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { var news: [NewsInfo] = [] private var newsLookup: [String:NewsInfo] = [:] var newsLoaded: Bool = false + var accountsLoaded: Bool = false var instantMessages: [UInt16:[InstantMessage]] = [:] var transfers: [TransferInfo] = [] var downloads: [HotlineFileClient] = [] @@ -308,6 +310,14 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { } + @MainActor func getAccounts() async -> [HotlineAccount] { + return await withCheckedContinuation { [weak self] continuation in + self?.client.sendGetAccounts() { articles in + continuation.resume(returning: articles) + } + } + } + @MainActor func getNewsList(at path: [String] = []) async { return await withCheckedContinuation { [weak self] continuation in let parentNewsGroup = self?.findNews(in: self?.news ?? [], at: path) diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift index 9cf2992..b5a2c34 100644 --- a/Hotline/Utility/FoundationExtensions.swift +++ b/Hotline/Utility/FoundationExtensions.swift @@ -594,3 +594,21 @@ extension FourCharCode { return String(bytes: bytes, encoding: .ascii) ?? "" } } + +extension Binding where Value: OptionSet, Value == Value.Element { + func bindedValue(_ options: Value) -> Bool { + return wrappedValue.contains(options) + } + + func bind(_ options: Value) -> Binding<Bool> { + return .init { () -> Bool in + self.wrappedValue.contains(options) + } set: { newValue in + if newValue { + self.wrappedValue.insert(options) + } else { + self.wrappedValue.remove(options) + } + } + } +} diff --git a/Hotline/macOS/AccountManagerView.swift b/Hotline/macOS/AccountManagerView.swift new file mode 100644 index 0000000..1ada9f3 --- /dev/null +++ b/Hotline/macOS/AccountManagerView.swift @@ -0,0 +1,344 @@ +import SwiftUI + +struct AccountManagerView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var accounts: [HotlineAccount] = [] + @State private var selection: HotlineAccount? + @State private var loading: Bool = true + + @State var pendingName: String = "" + @State var pendingLogin: String = "" + @State var pendingPassword: String = "" + @State var pendingAccess = HotlineUserAccessOptions.defaultAccess + + @State private var toDelete: HotlineAccount? + + let placeholderPassword = "xxxxxxxxxxxxxxxxxx" + + var body: some View { + NavigationSplitView(){ + if loading == false { + List(accounts, id: \.self, selection: $selection) { account in + HStack { + if account.access.contains(.canDisconnectUsers) { + Image(nsImage: Hotline.getClassicIcon(192)!) + .frame(width: 16, height: 16) + .padding(.leading, 4) + Text(account.login) + } + else if account.access.rawValue == 0 { + Image(nsImage: Hotline.getClassicIcon(190)!) + .frame(width: 16, height: 16) + .padding(.leading, 4) + Text(account.login) + } + else if account.persisted == false { + HStack { + Image(nsImage: Hotline.getClassicIcon(191)!) + .frame(width: 16, height: 16) + .padding(.leading, 4) + Text(account.login) + .italic() + } + } else { + Image(nsImage: Hotline.getClassicIcon(193)!) + .frame(width: 16, height: 16) + .padding(.leading, 4) + Text(account.login) + } + } + } + .sheet(item: $toDelete ) { item in + Form { + HStack{ + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 30)) + Text("Delete account \"\(item.name)\" and all associated files?") + .lineSpacing(4) + } + } + .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + toDelete = nil + } + } + + ToolbarItem(placement: .primaryAction) { + Button("Delete"){ + guard toDelete != nil else { + return + } + model.client.sendDeleteUser(login: toDelete!.login) + accounts = accounts.filter { $0.login != toDelete!.login } + self.toDelete = nil + self.selection = nil + } + } + } + } + } + } detail: { + if let selection { + Form { + Group { + TextField("Name", text: $pendingName) + TextField("Login", text: $pendingLogin) + .disabled(selection.persisted == true) + if selection.persisted == true { + SecureField("Password", text: $pendingPassword) + } else { + TextField("Password", text: $pendingPassword) + } + } + .textFieldStyle(.roundedBorder) + .controlSize(.large) + + Section("File System Maintenance") { + Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles)) + .disabled(model.access?.contains(.canDownloadFiles) == false) + Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders)) + .disabled(model.access?.contains(.canDownloadFolders) == false) + Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles)) + .disabled(model.access?.contains(.canUploadFiles) == false) + Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders)) + .disabled(model.access?.contains(.canUploadFolders) == false) + Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere)) + .disabled(model.access?.contains(.canUploadAnywhere) == false) + Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles)) + .disabled(model.access?.contains(.canDeleteFiles) == false) + Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles)) + .disabled(model.access?.contains(.canRenameFiles) == false) + Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles)) + .disabled(model.access?.contains(.canMoveFiles) == false) + Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment)) + .disabled(model.access?.contains(.canSetFileComment) == false) + Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders)) + .disabled(model.access?.contains(.canCreateFolders) == false) + Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders)) + .disabled(model.access?.contains(.canDeleteFolders) == false) + Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders)) + .disabled(model.access?.contains(.canRenameFolders) == false) + Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders)) + .disabled(model.access?.contains(.canMoveFolders) == false) + Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment)) + .disabled(model.access?.contains(.canSetFolderComment) == false) + Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes)) + .disabled(model.access?.contains(.canViewDropBoxes) == false) + Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases)) + .disabled(model.access?.contains(.canMakeAliases) == false) + } + + Section("User Maintenance") { + Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers)) + .disabled(model.access?.contains(.canCreateUsers) == false) + Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers)) + .disabled(model.access?.contains(.canDeleteUsers) == false) + Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers)) + .disabled(model.access?.contains(.canOpenUsers) == false) + Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers)) + .disabled(model.access?.contains(.canModifyUsers) == false) + Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo)) + .disabled(model.access?.contains(.canGetClientInfo) == false) + + Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers)) + .disabled(model.access?.contains(.canDisconnectUsers) == false) + Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected)) + .disabled(model.access?.contains(.cantBeDisconnected) == false) + } + + Section("Messaging") { + Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages)) + .disabled(model.access?.contains(.canSendMessages) == false) + Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast)) + .disabled(model.access?.contains(.canBroadcast) == false) + } + + Section("News") { + Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard)) + .disabled(model.access?.contains(.canReadMessageBoard) == false) + Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard)) + .disabled(model.access?.contains(.canPostMessageBoard) == false) + Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles)) + .disabled(model.access?.contains(.canDeleteNewsArticles) == false) + Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories)) + .disabled(model.access?.contains(.canCreateNewsCategories) == false) + Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories)) + .disabled(model.access?.contains(.canDeleteNewsCategories) == false) + Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders)) + .disabled(model.access?.contains(.canCreateNewsFolders) == false) + Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders)) + .disabled(model.access?.contains(.canDeleteNewsFolders) == false) + } + + Section("Chat") { + Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat)) + .disabled(model.access?.contains(.canCreateChat) == false) + Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat)) + .disabled(model.access?.contains(.canReadChat) == false) + Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat)) + .disabled(model.access?.contains(.canSendChat) == false) + } + + Section("Miscellaneous") { + Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName)) + .disabled(model.access?.contains(.canUseAnyName) == false) + Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement)) + .disabled(model.access?.contains(.canSkipAgreement) == false) + } + } + .disabled(model.access?.contains(.canModifyUsers) == false) + .formStyle(.grouped) + .onChange(of: selection) { + pendingName = selection.name + pendingLogin = selection.login + pendingAccess = selection.access + + if selection.persisted { + if selection.password == nil { + pendingPassword = "" + } else { + pendingPassword = placeholderPassword + } + } + } + .onAppear() { + pendingName = selection.name + pendingLogin = selection.login + pendingAccess = selection.access + + if selection.persisted { + if selection.password == nil { + pendingPassword = "" + } else { + pendingPassword = placeholderPassword + } + } else { + pendingPassword = HotlineAccount.randomPassword() + } + } + } + } + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .task { + if loading { + accounts = await model.getAccounts() + loading = false + } + } + .toolbar { + if model.access?.contains(.canCreateUsers) == true { + ToolbarItem(placement: .primaryAction) { + Button { + let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess) + + pendingPassword = HotlineAccount.randomPassword() + accounts.append(newAccount) + selection = newAccount + } label: { + Label("New Account", systemImage: "plus") + } + .help("Create a new account") + } + } + + if model.access?.contains(.canDeleteUsers) == true { + ToolbarItem(placement: .primaryAction) { + Button { + toDelete = selection + } label: { + Label("Delete Account", systemImage: "trash") + } + .help("Delete account") + .disabled(selection == nil) + } + } + } + + Divider() + + VStack(alignment: .trailing) { + HStack(){ + Button("Revert") { + if let selection { + pendingAccess = selection.access + pendingName = selection.name + pendingLogin = selection.login + + if selection.password != nil { + pendingPassword = selection.password! + } + } + } + .disabled(!self.isSaveable()) + + Button("Save"){ + guard let selection else { + return + } + + // Update existing account + if selection.persisted == true { + + if pendingPassword == placeholderPassword { + model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) + } else { + model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) + } + + } else { + // Create new existing account + model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) + self.selection?.password = pendingPassword + pendingPassword = placeholderPassword + } + + var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) + account.persisted = true + account.password = placeholderPassword + + accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } + + // Add new account to list + accounts.append(account) + + // Re-sort accounts + accounts.sort { $0.login < $1.login } + self.selection = account + } + .keyboardShortcut(.defaultAction) + .disabled(!self.isSaveable()) + } + .frame(height: 40) + } + } + + + private func isSaveable() -> Bool { + guard let selection else { + return false + } + + // Disable save if login field is cleared + if pendingLogin == "" { + return false + } + + // If the account initial has a password and it was updated + if selection.password != nil && pendingPassword != placeholderPassword { + return true + } + + // If the account initial has no password, but was updated to have one + if selection.password == nil && pendingPassword != "" { + return true + } + + // If the access bits or user name have been changed + return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName + } +} diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 374e379..2d4c253 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -88,6 +88,20 @@ struct HotlinePanelView: View { .disabled(ApplicationState.shared.activeServerState == nil) .help("Files") + Button { + ApplicationState.shared.activeServerState?.selection = .accounts + } + label: { + Image(systemName: "person.2.fill") + .resizable() + .scaledToFit() + } + .buttonStyle(.plain) + .frame(width: 18, height: 18) + .opacity(ApplicationState.shared.activeServerState == nil ? 0.5 : 1.0) + .disabled(ApplicationState.shared.activeServerState == nil || ApplicationState.shared.activeHotline?.access?.contains(.canOpenUsers) == false) + .help("Accounts") + Spacer() SettingsLink(label: { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 89f353b..7349d95 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -114,6 +114,8 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { return "Board" case .files: return "Files" + case .accounts: + return "Accounts" case .user(let userID): return String(userID) } @@ -123,6 +125,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case news case board case files + case accounts case user(userID: UInt16) } @@ -149,6 +152,7 @@ struct ServerView: View { ServerMenuItem(type: .board, name: "Board", image: "pin"), ServerMenuItem(type: .news, name: "News", image: "newspaper"), ServerMenuItem(type: .files, name: "Files", image: "folder"), + ServerMenuItem(type: .accounts, name: "Accounts", image: "person.2"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -362,6 +366,11 @@ struct ServerView: View { if menuItem.type == .chat { ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat).tag(menuItem.type) } + else if menuItem.type == .accounts { + if model.access?.contains(.canOpenUsers) == true { + ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) + } + } else { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) } @@ -456,6 +465,11 @@ struct ServerView: View { .navigationTitle(model.serverTitle) .navigationSubtitle("Shared Files") .navigationSplitViewColumnWidth(min: 250, ideal: 500) + case .accounts: + AccountManagerView() + .navigationTitle(model.serverTitle) + .navigationSubtitle("Accounts") + .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): let user = model.users.first(where: { $0.id == userID }) MessageView(userID: userID) @@ -467,6 +481,8 @@ struct ServerView: View { } } } + .toolbar(removing: .sidebarToggle) + } |