@@ -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] 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. | - [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 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 | ### Changed | ||||
@@ -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 Foundation | ||||
import os | import os | ||||
@@ -14,6 +9,11 @@ protocol LogWriter { | |||||
func logCrashAndSetFlag(error: Error) | func logCrashAndSetFlag(error: Error) | ||||
} | } | ||||
@available(iOS 15, *) | |||||
protocol LogReader { | |||||
func fetchLogs() -> [String] | |||||
} | |||||
final class Logging { | final class Logging { | ||||
private let logger: Logger | 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))" | |||||
} | |||||
} |
@@ -24,6 +24,8 @@ struct CheckForDebugModifier { | |||||
struct WriteFreely_MultiPlatformApp: App { | struct WriteFreely_MultiPlatformApp: App { | ||||
@StateObject private var model = WriteFreelyModel.shared | @StateObject private var model = WriteFreelyModel.shared | ||||
private let logger = Logging(for: String(describing: WriteFreely_MultiPlatformApp.self)) | |||||
#if os(macOS) | #if os(macOS) | ||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate | ||||
@StateObject var updaterViewModel = MacUpdatesViewModel() | @StateObject var updaterViewModel = MacUpdatesViewModel() | ||||
@@ -68,6 +70,11 @@ struct WriteFreely_MultiPlatformApp: App { | |||||
) | ) | ||||
) | ) | ||||
} | } | ||||
.onAppear { | |||||
if #available(iOS 15, *) { | |||||
if didCrash { generateCrashLogPost() } | |||||
} | |||||
} | |||||
.withErrorHandling() | .withErrorHandling() | ||||
.environmentObject(model) | .environmentObject(model) | ||||
.environment(\.managedObjectContext, LocalStorageManager.standard.container.viewContext) | .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() { | private func resetCrashFlags() { | ||||
UserDefaults.shared.set(false, forKey: WFDefaults.didHaveFatalError) | UserDefaults.shared.set(false, forKey: WFDefaults.didHaveFatalError) | ||||
UserDefaults.shared.removeObject(forKey: WFDefaults.fatalErrorDescription) | UserDefaults.shared.removeObject(forKey: WFDefaults.fatalErrorDescription) | ||||
@@ -1050,7 +1050,7 @@ | |||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; | ||||
CODE_SIGN_ENTITLEMENTS = "ActionExtension-iOS/ActionExtension-iOS.entitlements"; | CODE_SIGN_ENTITLEMENTS = "ActionExtension-iOS/ActionExtension-iOS.entitlements"; | ||||
CODE_SIGN_STYLE = Automatic; | CODE_SIGN_STYLE = Automatic; | ||||
CURRENT_PROJECT_VERSION = 690; | |||||
CURRENT_PROJECT_VERSION = 691; | |||||
DEVELOPMENT_TEAM = TPPAB4YBA6; | DEVELOPMENT_TEAM = TPPAB4YBA6; | ||||
GENERATE_INFOPLIST_FILE = YES; | GENERATE_INFOPLIST_FILE = YES; | ||||
INFOPLIST_FILE = "ActionExtension-iOS/Info.plist"; | INFOPLIST_FILE = "ActionExtension-iOS/Info.plist"; | ||||
@@ -1081,7 +1081,7 @@ | |||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; | ||||
CODE_SIGN_ENTITLEMENTS = "ActionExtension-iOS/ActionExtension-iOS.entitlements"; | CODE_SIGN_ENTITLEMENTS = "ActionExtension-iOS/ActionExtension-iOS.entitlements"; | ||||
CODE_SIGN_STYLE = Automatic; | CODE_SIGN_STYLE = Automatic; | ||||
CURRENT_PROJECT_VERSION = 690; | |||||
CURRENT_PROJECT_VERSION = 691; | |||||
DEVELOPMENT_TEAM = TPPAB4YBA6; | DEVELOPMENT_TEAM = TPPAB4YBA6; | ||||
GENERATE_INFOPLIST_FILE = YES; | GENERATE_INFOPLIST_FILE = YES; | ||||
INFOPLIST_FILE = "ActionExtension-iOS/Info.plist"; | INFOPLIST_FILE = "ActionExtension-iOS/Info.plist"; | ||||
@@ -1224,7 +1224,7 @@ | |||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | ||||
CODE_SIGN_ENTITLEMENTS = "WriteFreely-MultiPlatform (iOS).entitlements"; | CODE_SIGN_ENTITLEMENTS = "WriteFreely-MultiPlatform (iOS).entitlements"; | ||||
CODE_SIGN_STYLE = Automatic; | CODE_SIGN_STYLE = Automatic; | ||||
CURRENT_PROJECT_VERSION = 690; | |||||
CURRENT_PROJECT_VERSION = 691; | |||||
DEVELOPMENT_TEAM = TPPAB4YBA6; | DEVELOPMENT_TEAM = TPPAB4YBA6; | ||||
ENABLE_PREVIEWS = YES; | ENABLE_PREVIEWS = YES; | ||||
INFOPLIST_FILE = iOS/Info.plist; | INFOPLIST_FILE = iOS/Info.plist; | ||||
@@ -1250,7 +1250,7 @@ | |||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | ||||
CODE_SIGN_ENTITLEMENTS = "WriteFreely-MultiPlatform (iOS).entitlements"; | CODE_SIGN_ENTITLEMENTS = "WriteFreely-MultiPlatform (iOS).entitlements"; | ||||
CODE_SIGN_STYLE = Automatic; | CODE_SIGN_STYLE = Automatic; | ||||
CURRENT_PROJECT_VERSION = 690; | |||||
CURRENT_PROJECT_VERSION = 691; | |||||
DEVELOPMENT_TEAM = TPPAB4YBA6; | DEVELOPMENT_TEAM = TPPAB4YBA6; | ||||
ENABLE_PREVIEWS = YES; | ENABLE_PREVIEWS = YES; | ||||
INFOPLIST_FILE = iOS/Info.plist; | INFOPLIST_FILE = iOS/Info.plist; | ||||
@@ -1,7 +1,11 @@ | |||||
import SwiftUI | import SwiftUI | ||||
struct SettingsView: View { | struct SettingsView: View { | ||||
@EnvironmentObject var model: WriteFreelyModel | @EnvironmentObject var model: WriteFreelyModel | ||||
@State private var isShowingAlert = false | |||||
private let logger = Logging(for: String(describing: SettingsView.self)) | |||||
var body: some View { | var body: some View { | ||||
VStack { | VStack { | ||||
@@ -14,21 +18,22 @@ struct SettingsView: View { | |||||
Section(header: Text("Appearance")) { | Section(header: Text("Appearance")) { | ||||
PreferencesView(preferences: model.preferences) | 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")) { | 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. | // .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 { | struct SettingsView_Previews: PreviewProvider { | ||||