diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d6a45..2449acf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Mac] Added a menu item for toggling the toolbar. - [Mac] In a post with unpublished changes (i.e., with "local" or "edited" status), the post is autosaved after a one-second pause in typing. - [iOS/Mac] Added a context-menu item to delete local posts from the post list. +- [iOS/Mac] Added methods to fetch device logs. +- [iOS] Added a settings option to generate a log file as a new local draft (iOS 15+). ### Changed diff --git a/Shared/Logging/Logging.swift b/Shared/Logging/Logging.swift index 64d7f9c..9d8343c 100644 --- a/Shared/Logging/Logging.swift +++ b/Shared/Logging/Logging.swift @@ -1,9 +1,4 @@ -// -// Logging.swift -// WriteFreely-MultiPlatform -// -// Created by Angelo Stavrow on 2022-06-25. -// +// Credit for much of this class: https://steipete.com/posts/logging-in-swift/ import Foundation import os @@ -14,6 +9,11 @@ protocol LogWriter { func logCrashAndSetFlag(error: Error) } +@available(iOS 15, *) +protocol LogReader { + func fetchLogs() -> [String] +} + final class Logging { private let logger: Logger @@ -48,3 +48,57 @@ extension Logging: LogWriter { } } + +extension Logging: LogReader { + + @available(iOS 15, *) + func fetchLogs() -> [String] { + var logs: [String] = [] + + do { + let osLog = try getLogEntries() + for logEntry in osLog { + let formattedEntry = formatEntry(logEntry) + logs.append(formattedEntry) + } + } catch { + logs.append("Could not fetch logs") + } + + return logs + } + + @available(iOS 15, *) + private func getLogEntries() throws -> [OSLogEntryLog] { + let logStore = try OSLogStore(scope: .currentProcessIdentifier) + let oneHourAgo = logStore.position(date: Date().addingTimeInterval(-3600)) + let allEntries = try Array(logStore.__entriesEnumerator(position: oneHourAgo, predicate: nil)) + return allEntries + .compactMap { $0 as? OSLogEntryLog } + .filter { $0.subsystem == subsystem } + } + + @available(iOS 15, *) + private func formatEntry(_ logEntry: OSLogEntryLog) -> String { + /// The desired format is: + /// `date [process/category] LEVEL: composedMessage (threadIdentifier)` + var level: String = "" + switch logEntry.level { + case .debug: + level = "DEBUG" + case .info: + level = "INFO" + case .notice: + level = "NOTICE" + case .error: + level = "ERROR" + case .fault: + level = "FAULT" + default: + level = "UNDEFINED" + } + // swiftlint:disable:next line_length + return "\(logEntry.date) [\(logEntry.process)/\(logEntry.category)] \(level): \(logEntry.composedMessage) (\(logEntry.threadIdentifier))" + } + +} diff --git a/Shared/WriteFreely_MultiPlatformApp.swift b/Shared/WriteFreely_MultiPlatformApp.swift index 53b2ae0..5b0123d 100644 --- a/Shared/WriteFreely_MultiPlatformApp.swift +++ b/Shared/WriteFreely_MultiPlatformApp.swift @@ -24,6 +24,8 @@ struct CheckForDebugModifier { struct WriteFreely_MultiPlatformApp: App { @StateObject private var model = WriteFreelyModel.shared + private let logger = Logging(for: String(describing: WriteFreely_MultiPlatformApp.self)) + #if os(macOS) @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject var updaterViewModel = MacUpdatesViewModel() @@ -68,6 +70,11 @@ struct WriteFreely_MultiPlatformApp: App { ) ) } + .onAppear { + if #available(iOS 15, *) { + if didCrash { generateCrashLogPost() } + } + } .withErrorHandling() .environmentObject(model) .environment(\.managedObjectContext, LocalStorageManager.standard.container.viewContext) @@ -167,6 +174,33 @@ struct WriteFreely_MultiPlatformApp: App { } } + @available(iOS 15, *) + private func generateCrashLogPost() { + logger.log("Generating local log post...") + + DispatchQueue.main.asyncAfter(deadline: .now()) { + // Unset selected post and collection and navigate to local drafts. + self.model.selectedPost = nil + self.model.selectedCollection = nil + self.model.showAllPosts = false + + // Create the new log post. + let newLogPost = model.editor.generateNewLocalPost(withFont: 2) + newLogPost.title = "Logs For Support" + var postBody: [String] = [ + "WriteFreely-Multiplatform v\(Bundle.main.appMarketingVersion) (\(Bundle.main.appBuildVersion))", + "Generated \(Date())", + "" + ] + postBody.append(contentsOf: logger.fetchLogs()) + newLogPost.body = postBody.joined(separator: "\n") + + self.model.selectedPost = newLogPost + } + + logger.log("Generated local log post.") + } + private func resetCrashFlags() { UserDefaults.shared.set(false, forKey: WFDefaults.didHaveFatalError) UserDefaults.shared.removeObject(forKey: WFDefaults.fatalErrorDescription) diff --git a/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj b/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj index 3a4a250..f8f628e 100644 --- a/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj +++ b/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj @@ -1050,7 +1050,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = "ActionExtension-iOS/ActionExtension-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 690; + CURRENT_PROJECT_VERSION = 691; DEVELOPMENT_TEAM = TPPAB4YBA6; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "ActionExtension-iOS/Info.plist"; @@ -1081,7 +1081,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = "ActionExtension-iOS/ActionExtension-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 690; + CURRENT_PROJECT_VERSION = 691; DEVELOPMENT_TEAM = TPPAB4YBA6; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "ActionExtension-iOS/Info.plist"; @@ -1224,7 +1224,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "WriteFreely-MultiPlatform (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 690; + CURRENT_PROJECT_VERSION = 691; DEVELOPMENT_TEAM = TPPAB4YBA6; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iOS/Info.plist; @@ -1250,7 +1250,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "WriteFreely-MultiPlatform (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 690; + CURRENT_PROJECT_VERSION = 691; DEVELOPMENT_TEAM = TPPAB4YBA6; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iOS/Info.plist; diff --git a/iOS/Settings/SettingsView.swift b/iOS/Settings/SettingsView.swift index 28c55a6..cfd0211 100644 --- a/iOS/Settings/SettingsView.swift +++ b/iOS/Settings/SettingsView.swift @@ -1,7 +1,11 @@ import SwiftUI struct SettingsView: View { + @EnvironmentObject var model: WriteFreelyModel + @State private var isShowingAlert = false + + private let logger = Logging(for: String(describing: SettingsView.self)) var body: some View { VStack { @@ -14,21 +18,22 @@ struct SettingsView: View { Section(header: Text("Appearance")) { PreferencesView(preferences: model.preferences) } - Section(header: Text("External Links")) { - HStack { - Spacer() - Link("View the Guide", destination: model.howToURL) - Spacer() - } - HStack { - Spacer() - Link("Visit the Help Forum", destination: model.helpURL) - Spacer() - } - HStack { - Spacer() - Link("Write a Review on the App Store", destination: model.reviewURL) - Spacer() + Section(header: Text("Help and Support")) { + Link("View the Guide", destination: model.howToURL) + Link("Visit the Help Forum", destination: model.helpURL) + Link("Write a Review on the App Store", destination: model.reviewURL) + if #available(iOS 15.0, *) { + VStack(alignment: .leading, spacing: 8) { + Button( + action: didTapGenerateLogPostButton, + label: { + Text("Create Log Post") + } + ) + Text("Generates a local post using recent logs. You can share this for troubleshooting.") + .font(.footnote) + .foregroundColor(.secondary) + } } } Section(header: Text("Acknowledgements")) { @@ -55,8 +60,41 @@ struct SettingsView: View { } } } + .alert(isPresented: $isShowingAlert) { + Alert( + title: Text("Log Post Created"), + message: Text("Check your local drafts for app logs from the past 24 hours.") + ) + } // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } + + @available(iOS 15, *) + private func didTapGenerateLogPostButton() { + logger.log("Generating local log post...") + + DispatchQueue.main.asyncAfter(deadline: .now()) { + // Unset selected post and collection and navigate to local drafts. + self.model.selectedPost = nil + self.model.selectedCollection = nil + self.model.showAllPosts = false + + // Create the new log post. + let newLogPost = model.editor.generateNewLocalPost(withFont: 2) + newLogPost.title = "Logs For Support" + var postBody: [String] = [ + "WriteFreely-Multiplatform v\(Bundle.main.appMarketingVersion) (\(Bundle.main.appBuildVersion))", + "Generated \(Date())", + "" + ] + postBody.append(contentsOf: logger.fetchLogs()) + newLogPost.body = postBody.joined(separator: "\n") + + self.isShowingAlert = true + } + + logger.log("Generated local log post.") + } } struct SettingsView_Previews: PreviewProvider {