diff --git a/ContentView.swift b/ContentView.swift index 9ce33aa..37a0784 100644 --- a/ContentView.swift +++ b/ContentView.swift @@ -1,128 +1,41 @@ import SwiftUI -struct ContentView: View { - @State private var visibility: NavigationSplitViewVisibility = .all +enum NavigationCategory: View { + case rules, shared, change, appended var body: some View { - NavigationSplitView(columnVisibility: $visibility) { - List { - NavigationLink { - PasswordRules() - } label: { - Label { - #if os(iOS) - LabeledContent { - EmptyView() - } label: { - Text("Password Rules") - Text("Rules to generate compatible passwords with websites' particular requirements.") - } - #else - Text("Password Rules") - #endif - } icon: { - Image(systemName: "lock.rectangle") - } - #if os(iOS) - .padding(.vertical) - #endif - } + switch self { + case .rules: + PasswordRules() + case .shared: + SharedCredentials() + case .change: + ChangePasswordURLs() + case .appended: + Appended2FA() + } + } +} - NavigationLink { - SharedCredentials() - } label: { - Label { - #if os(iOS) - LabeledContent { - EmptyView() - } label: { - Text("Shared Credentials") - Text("Groups of websites known to use the same credential backend, which can be used to enhance suggested credentials to sign in to websites.") - } - #else - Text("Shared Credentials") - #endif - } icon: { - Image(systemName: "rectangle.on.rectangle.angled") - } - #if os(iOS) - .padding(.vertical) - #endif - } +struct ContentView: View { + @State private var visibility: NavigationSplitViewVisibility = .all - NavigationLink { - ChangePasswordURLs() - } label: { - Label { - #if os(iOS) - LabeledContent { - EmptyView() - } label: { - Text("Change Password URLs") - Text("To drive the adoption of strong passwords, it's useful to be able to take users directly to websites' change password pages.") - } - #else - Text("Change Password URLs") - #endif - } icon: { - Image(systemName: "rectangle.and.pencil.and.ellipsis") - } - #if os(iOS) - .padding(.vertical) - #endif - } + @State private var navigationCategory: NavigationCategory? - NavigationLink { - Appended2FA() - } label: { - Label { - #if os(iOS) - LabeledContent { - EmptyView() - } label: { - Text("Websites Where 2FA Code is Appended to Password") - Text("Some websites use a two-factor authentication scheme where the user must append a generated code to their password when signing in.") - } - #else - Text("Appended 2FA") - #endif - } icon: { - Image(systemName: "123.rectangle") - } - #if os(iOS) - .padding(.vertical) - #endif - } - } - .scrollContentBackground(.hidden) - .background { - HomeBackground() - } - .listStyle(.sidebar) - .navigationTitle(Text("Passwords Inspector")) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #else - .navigationSplitViewColumnWidth(min: 180, ideal: 180) - #endif - .toolbar { - #if os(iOS) - ToolbarItemGroup(placement: .principal) { - Label { - Text("Passwords Inspector") - } icon: { - Image(systemName: "key.fill") - .rotationEffect(.radians(-0.3)) - } - .labelStyle(.titleAndIcon) - .foregroundStyle(.tint, .tertiary) - } - #endif - } + var body: some View { + NavigationSplitView(columnVisibility: $visibility) { + HomeSidebar(selection: $navigationCategory) + .listStyle(.sidebar) } content: { - EmptyView() + if let navigationCategory { + navigationCategory + .navigationSplitViewColumnWidth(min: 320, ideal: 640) + } } detail: { - EmptyView() + Text("") + .accessibilityHidden(true) + .navigationSplitViewColumnWidth(ideal: navigationCategory == .rules ? 320 : 0, + max: navigationCategory == .rules ? 480 : 0) } } } diff --git a/Domains/Appended2FA.swift b/Domains/Appended2FA.swift index bf4d4ef..c014e35 100644 --- a/Domains/Appended2FA.swift +++ b/Domains/Appended2FA.swift @@ -44,14 +44,7 @@ struct Appended2FA: View { Text("Domains which use a two-factor authentication scheme where you must append a generated code to your password when signing in.") } } - .searchable(text: $searchText, prompt: Text("Search Domains")) - .refreshable { - await reload() - } - .task { - guard response.isEmpty else { return } - await reload(cache: .returnCacheDataElseLoad) - } + #if os(iOS) .toolbar { Button { showHelp.toggle() @@ -78,6 +71,7 @@ struct Appended2FA: View { .presentationCompactAdaptation(horizontal: .sheet, vertical: .popover) } } + #endif } .searchable(text: $searchText, prompt: Text("Search Domains")) .refreshable { diff --git a/Domains/ChangePasswordURLs.swift b/Domains/ChangePasswordURLs.swift index b62670b..363f780 100644 --- a/Domains/ChangePasswordURLs.swift +++ b/Domains/ChangePasswordURLs.swift @@ -8,6 +8,8 @@ struct ChangePasswordURLs: View { static let getURL = URL(string: "https://raw.githubusercontent.com/apple/password-manager-resources/main/quirks/change-password-URLs.json")! + @AppStorage("showFavicon") private var showFavicon = true + func reload(cache: NSURLRequest.CachePolicy = .reloadIgnoringLocalCacheData) async { switch await Self.reload(cache: cache) { case let .success(data): @@ -59,12 +61,23 @@ struct ChangePasswordURLs: View { .foregroundStyle(.secondary) } } icon: { - Favicon(domain: response.key) + if showFavicon { + Favicon(domain: response.key) + } } } .foregroundStyle(.foreground) } } + .toolbar { + ToolbarItemGroup(placement: .automatic) { + Picker("Favicon", selection: $showFavicon) { + Label("Show", systemImage: "checklist.unchecked").tag(true) + Label("Hide", systemImage: "list.bullet").tag(false) + } + .pickerStyle(.segmented) + } + } .searchable(text: $searchText, prompt: Text("Search Domains")) .searchSuggestions { if !searchText.contains(".") { diff --git a/HomeBackground.swift b/Home/HomeBackground.swift similarity index 100% rename from HomeBackground.swift rename to Home/HomeBackground.swift diff --git a/Home/HomeSidebar.swift b/Home/HomeSidebar.swift new file mode 100644 index 0000000..ff9c080 --- /dev/null +++ b/Home/HomeSidebar.swift @@ -0,0 +1,118 @@ +import SwiftUI + +struct HomeSidebar: View { + @Binding var selection: NavigationCategory? + + var body: some View { + List(selection: $selection) { + NavigationLink(value: NavigationCategory.rules) { + Label { + #if os(iOS) + LabeledContent { + EmptyView() + } label: { + Text("Password Rules") + Text("Rules to generate compatible passwords with websites' particular requirements.") + } + #else + Text("Password Rules") + #endif + } icon: { + Image(systemName: "lock.rectangle") + } + #if os(iOS) + .padding(.vertical) + #endif + } + + NavigationLink(value: NavigationCategory.shared) { + Label { + #if os(iOS) + LabeledContent { + EmptyView() + } label: { + Text("Shared Credentials") + Text("Groups of websites known to use the same credential backend, which can be used to enhance suggested credentials to sign in to websites.") + } + #else + Text("Shared Credentials") + #endif + } icon: { + Image(systemName: "rectangle.on.rectangle.angled") + } + #if os(iOS) + .padding(.vertical) + #endif + } + + NavigationLink(value: NavigationCategory.change) { + Label { + #if os(iOS) + LabeledContent { + EmptyView() + } label: { + Text("Change Password URLs") + Text("To drive the adoption of strong passwords, it's useful to be able to take users directly to websites' change password pages.") + } + #else + Text("Change Password URLs") + #endif + } icon: { + Image(systemName: "rectangle.and.pencil.and.ellipsis") + } + #if os(iOS) + .padding(.vertical) + #endif + } + + NavigationLink(value: NavigationCategory.appended) { + Label { + #if os(iOS) + LabeledContent { + EmptyView() + } label: { + Text("Websites Where 2FA Code is Appended to Password") + Text("Some websites use a two-factor authentication scheme where the user must append a generated code to their password when signing in.") + } + #else + Text("Appended 2FA") + #endif + } icon: { + Image(systemName: "123.rectangle") + } + #if os(iOS) + .padding(.vertical) + #endif + } + } + .scrollContentBackground(.hidden) + .background { + HomeBackground() + } + .listStyle(.sidebar) + .navigationTitle(Text("Passwords Inspector")) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #else + .navigationSplitViewColumnWidth(min: 180, ideal: 180) + #endif + .toolbar { + #if os(iOS) + ToolbarItemGroup(placement: .principal) { + Label { + Text("Passwords Inspector") + } icon: { + Image(systemName: "key.fill") + .rotationEffect(.radians(-0.3)) + } + .labelStyle(.titleAndIcon) + .foregroundStyle(.tint, .tertiary) + } + #endif + } + } +} + +#Preview { + HomeSidebar(selection: .constant(nil)) +} diff --git a/Passwords Inspector.xcodeproj/project.pbxproj b/Passwords Inspector.xcodeproj/project.pbxproj index 9841a84..1c26e2b 100644 --- a/Passwords Inspector.xcodeproj/project.pbxproj +++ b/Passwords Inspector.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 174820E22AD33DD8006AF2AC /* HomeBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174820E12AD33DD8006AF2AC /* HomeBackground.swift */; }; 174840992AD49A3E00EB9592 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 174840982AD49A3E00EB9592 /* Settings.bundle */; }; 17618B7C2AC200C600B31C28 /* PasswordRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17618B7B2AC200C600B31C28 /* PasswordRules.swift */; }; + 1776885F2AD9D65400F68E88 /* HomeSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1776885E2AD9D65400F68E88 /* HomeSidebar.swift */; }; 17882AC52AC84AAD0037E3A6 /* PasswordRuleDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17882AC42AC84AAD0037E3A6 /* PasswordRuleDetail.swift */; }; 17BFF7002ABCDDC2004047AD /* SharedCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BFF6FF2ABCDDC2004047AD /* SharedCredentials.swift */; }; 17BFF7022ABF217D004047AD /* Favicon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BFF7012ABF217D004047AD /* Favicon.swift */; }; @@ -35,6 +36,7 @@ 174840982AD49A3E00EB9592 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; 1748409A2AD4A73C00EB9592 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 17618B7B2AC200C600B31C28 /* PasswordRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordRules.swift; sourceTree = ""; }; + 1776885E2AD9D65400F68E88 /* HomeSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSidebar.swift; sourceTree = ""; }; 17882AC42AC84AAD0037E3A6 /* PasswordRuleDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordRuleDetail.swift; sourceTree = ""; }; 17BFF6FF2ABCDDC2004047AD /* SharedCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedCredentials.swift; sourceTree = ""; }; 17BFF7012ABF217D004047AD /* Favicon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Favicon.swift; sourceTree = ""; }; @@ -57,7 +59,7 @@ children = ( 1717C49D2ABA44EC00AD991A /* PIApp.swift */, 1717C49F2ABA44EC00AD991A /* ContentView.swift */, - 174820E12AD33DD8006AF2AC /* HomeBackground.swift */, + 177688602AD9D65A00F68E88 /* Home */, 17882AC22AC848380037E3A6 /* Rule */, 17882AC32AC8483E0037E3A6 /* Domains */, 17BFF7012ABF217D004047AD /* Favicon.swift */, @@ -77,6 +79,15 @@ name = Products; sourceTree = ""; }; + 177688602AD9D65A00F68E88 /* Home */ = { + isa = PBXGroup; + children = ( + 1776885E2AD9D65400F68E88 /* HomeSidebar.swift */, + 174820E12AD33DD8006AF2AC /* HomeBackground.swift */, + ); + path = Home; + sourceTree = ""; + }; 17882AC22AC848380037E3A6 /* Rule */ = { isa = PBXGroup; children = ( @@ -175,6 +186,7 @@ 17BFF7002ABCDDC2004047AD /* SharedCredentials.swift in Sources */, 17882AC52AC84AAD0037E3A6 /* PasswordRuleDetail.swift in Sources */, 173245232AC4CE99005C3AC2 /* Rule.swift in Sources */, + 1776885F2AD9D65400F68E88 /* HomeSidebar.swift in Sources */, 17BFF7042ABF7D50004047AD /* Appended2FA.swift in Sources */, 17BFF7022ABF217D004047AD /* Favicon.swift in Sources */, 173245252AC4D29D005C3AC2 /* PasswordRuleChips.swift in Sources */, diff --git a/Rule/PasswordRuleDetail.swift b/Rule/PasswordRuleDetail.swift index 809836b..93827ad 100644 --- a/Rule/PasswordRuleDetail.swift +++ b/Rule/PasswordRuleDetail.swift @@ -37,6 +37,9 @@ struct PasswordRuleDetail: View { } header: { Text("Length") } + #if os(macOS) + .listRowSeparator(.hidden) + #endif Section { Grid { diff --git a/Rule/PasswordRules.swift b/Rule/PasswordRules.swift index a3261a1..50d0ca0 100644 --- a/Rule/PasswordRules.swift +++ b/Rule/PasswordRules.swift @@ -49,10 +49,9 @@ struct PasswordRules: View { private let isCompact = false #endif - @ScaledMetric(wrappedValue: 24, relativeTo: .body) private var faviconHeight - @AppStorage("showFavicon") private var showFavicon = true - + @ScaledMetric(wrappedValue: 24, relativeTo: .body) private var faviconHeight + @State private var sortOrder = [KeyPathComparator(\Rule.id)] var body: some View { @@ -69,74 +68,9 @@ struct PasswordRules: View { Group { if isCompact { - List { - ForEach(responses) { rule in - NavigationLink { - PasswordRuleDetail(rule: rule) - } label: { - Label { - LabeledContent {} label: { - Text(rule.id) - PasswordRuleChips(rule: rule) - } - } icon: { - Favicon(domain: rule.id) - } - } - } - } - .listStyle(.plain) + RulesList(rules: responses, showFavicon: showFavicon) } else { - Table(of: Rule.self, sortOrder: $sortOrder) { - TableColumn("Domain", value: \.id) { domain in - HStack { - if showFavicon { - Favicon(domain: domain.id) - .frame(maxHeight: faviconHeight) - } - Text(domain.id) - } - } - - TableColumn("Length", value: \.sumLength) { domain in - if showFavicon { - PasswordRuleChips.Length(min: domain.minLength, max: domain.maxLength) - .symbolVariant(.fill) - .symbolRenderingMode(.hierarchical) - .font(.title) - } else { - Text("\(domain.minLength?.description ?? "?") – \(domain.maxLength?.description ?? "?")") - } - } - .width(min: 48, ideal: 48, max: 64) - - TableColumn("Required") { domain in - HStack { - ForEach(domain.required.sorted()) { set in - if let symbol = set.symbol { - Image(systemName: symbol) - } else { - Text(set.description) - } - } - } - } - TableColumn("Allowed") { domain in - HStack { - ForEach(domain.allowed.sorted()) { set in - if let symbol = set.symbol { - Image(systemName: symbol) - } else { - Text(set.description) - } - } - } - } - } rows: { - ForEach(responses) { rule in - TableRow(rule) - } - } + RulesTable(rules: responses, showFavicon: showFavicon, faviconHeight: faviconHeight, sortOrder: $sortOrder) } } .toolbar { @@ -192,6 +126,101 @@ struct RefreshButton: View { } } +struct RulesList: View { + let rules: [Rule] + let showFavicon: Bool + + var body: some View { + List { + ForEach(rules) { rule in + NavigationLink { + PasswordRuleDetail(rule: rule) + } label: { + Label { + LabeledContent {} label: { + Text(rule.id) + PasswordRuleChips(rule: rule) + } + } icon: { + if showFavicon { + Favicon(domain: rule.id) + } + } + } + } + } + .listStyle(.plain) + } +} + +struct RulesTable: View { + let rules: [Rule] + let showFavicon: Bool + let faviconHeight: Double + @Binding var sortOrder: [KeyPathComparator] + + @State private var selection: Rule.ID? + + var body: some View { + Table(of: Rule.self, selection: $selection, sortOrder: $sortOrder) { + TableColumn("Domain", value: \.id) { domain in + HStack { + if showFavicon { + Favicon(domain: domain.id) + .frame(maxHeight: faviconHeight) + } + Text(domain.id) + } + } + + TableColumn("Length", value: \.sumLength) { domain in + if showFavicon { + PasswordRuleChips.Length(min: domain.minLength, max: domain.maxLength) + .symbolVariant(.fill) + .symbolRenderingMode(.hierarchical) + .font(.title) + } else { + Text("\(domain.minLength?.description ?? "?") – \(domain.maxLength?.description ?? "?")") + } + } + .width(min: 48, ideal: 48, max: 64) + + TableColumn("Required") { domain in + HStack { + ForEach(domain.required.sorted()) { set in + if let symbol = set.symbol { + Image(systemName: symbol) + } else { + Text(set.description) + } + } + } + } + TableColumn("Allowed") { domain in + HStack { + ForEach(domain.allowed.sorted()) { set in + if let symbol = set.symbol { + Image(systemName: symbol) + } else { + Text(set.description) + } + } + } + } + } rows: { + ForEach(rules) { rule in + TableRow(rule) + } + } + .navigationDestination(isPresented: Binding(get: { selection != nil }, set: { if !$0 { selection = nil } })) { + if let rule = rules.first(where: { $0.id == selection }) { + PasswordRuleDetail(rule: rule) + .navigationSplitViewColumnWidth(min: 320, ideal: 640) + } + } + } +} + #Preview("Local") { NavigationStack { PasswordRules(response: [.init(id: "example.com", originalRule: "maxlength: 5;")]) @@ -205,5 +234,17 @@ struct RefreshButton: View { } #Preview("Table", traits: .fixedLayout(width: 960, height: 640)) { - PasswordRules() + NavigationSplitView { + List { + NavigationLink { + PasswordRules() + } label: { + Text("PasswordRules") + } + } + } content: { + PasswordRules() + } detail: { + EmptyView() + } } diff --git a/Rule/Rule.swift b/Rule/Rule.swift index 9b272d7..0418239 100644 --- a/Rule/Rule.swift +++ b/Rule/Rule.swift @@ -1,7 +1,7 @@ import Foundation import RegexBuilder -struct Rule: Identifiable { +struct Rule: Identifiable, Hashable { let id: String let originalRule: String