diff --git a/src/Notepads/Core/SessionManager.cs b/src/Notepads/Core/SessionManager.cs index 111a039be..f63a99c85 100644 --- a/src/Notepads/Core/SessionManager.cs +++ b/src/Notepads/Core/SessionManager.cs @@ -4,6 +4,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; + using System.IO; using System.Linq; using System.Text; using System.Threading; @@ -15,6 +16,7 @@ using Notepads.Models; using Notepads.Services; using Notepads.Utilities; + using Windows.ApplicationModel.Resources; using Windows.Storage; using Windows.Storage.AccessCache; @@ -61,46 +63,27 @@ public async Task LoadLastSessionAsync() return 0; // Already loaded } - var data = await SessionUtility.GetSerializedSessionMetaDataAsync(_sessionMetaDataFileName); - - if (data == null) - { - return 0; // No session data found - } - - NotepadsSessionDataV1 sessionData; - - try - { - var json = JsonDocument.Parse(data); - var version = json.RootElement.GetProperty("Version").GetInt32(); + IList recoveredEditor = new List(); + var sessionData = await SessionUtility.GetSessionMetaDataAsync(_sessionMetaDataFileName); + var backupFiles = (await SessionUtility.GetAllBackupFilesAsync(_backupFolderName)).ToList(); + var orphanedEditorCount = 0; - if (version == 1) - { - sessionData = JsonSerializer.Deserialize(data); - } - else - { - throw new Exception($"Invalid version found in session metadata: {version}"); - } - } - catch (Exception ex) + if (sessionData == null && backupFiles.Count > 0) { - LoggingService.LogError($"[{nameof(SessionManager)}] Failed to load last session metadata: {ex.Message}"); - Analytics.TrackEvent("SessionManager_FailedToLoadLastSession", new Dictionary() { { "Exception", ex.Message } }); + recoveredEditor = await RecoverOrphanedTextEditors(backupFiles); + orphanedEditorCount = recoveredEditor.Count; + _notepadsCore.OpenTextEditors(recoveredEditor.ToArray()); await ClearSessionDataAsync(); - return 0; + return orphanedEditorCount; // No session meta-data found, only recover from orphaned backup files } - IList recoveredEditor = new List(); - foreach (var textEditorData in sessionData.TextEditors) { - ITextEditor textEditor; + Tuple> recoveredData; try { - textEditor = await RecoverTextEditorAsync(textEditorData); + recoveredData = await RecoverTextEditorAsync(textEditorData, backupFiles); } catch (Exception ex) { @@ -109,13 +92,28 @@ public async Task LoadLastSessionAsync() continue; } - if (textEditor != null) + if (recoveredData != null) { - recoveredEditor.Add(textEditor); - _sessionDataCache.TryAdd(textEditor.Id, textEditorData); + if (recoveredData.Item1 != null) + { + recoveredEditor.Add(recoveredData.Item1); + _sessionDataCache.TryAdd(recoveredData.Item1.Id, textEditorData); + } + + if (recoveredData.Item2?.Count > 0) + { + recoveredData.Item2.All(file => backupFiles.Remove(file)); + } } } + if (backupFiles?.Count > 0) + { + var orphanedEditors = await RecoverOrphanedTextEditors(backupFiles); + orphanedEditorCount = orphanedEditors.Count; + recoveredEditor.Concat(orphanedEditors); + } + _notepadsCore.OpenTextEditors(recoveredEditor.ToArray(), sessionData.SelectedTextEditor); _notepadsCore.SetTabScrollViewerHorizontalOffset(sessionData.TabScrollViewerHorizontalOffset); @@ -123,7 +121,7 @@ public async Task LoadLastSessionAsync() LoggingService.LogInfo($"[{nameof(SessionManager)}] {_sessionDataCache.Count} tab(s) restored from last session."); - return _sessionDataCache.Count; + return _sessionDataCache.Count + orphanedEditorCount; } public async Task SaveSessionAsync(Action actionAfterSaving = null) @@ -386,25 +384,28 @@ private void UnbindEditorContentStateChangeEvent(object sender, ITextEditor text textEditor.FileReloaded -= RemoveTextEditorSessionData; } - private async Task RecoverTextEditorAsync(TextEditorSessionDataV1 editorSessionData) + private async Task>> RecoverTextEditorAsync( + TextEditorSessionDataV1 editorSessionData, + IList backupFiles) { StorageFile editingFile = null; + IList recoveredFiles = new List(); if (editorSessionData.EditingFileFutureAccessToken != null) { editingFile = await FutureAccessListUtility.GetFileFromFutureAccessList(editorSessionData.EditingFileFutureAccessToken); } - string lastSavedFile = editorSessionData.LastSavedBackupFilePath; - string pendingFile = editorSessionData.PendingBackupFilePath; + string lastSavedFilePath = editorSessionData.LastSavedBackupFilePath; + string pendingFilePath = editorSessionData.PendingBackupFilePath; ITextEditor textEditor; - if (editingFile == null && lastSavedFile == null && pendingFile == null) + if (editingFile == null && lastSavedFilePath == null && pendingFilePath == null) { textEditor = null; } - else if (editingFile != null && lastSavedFile == null && pendingFile == null) // File without pending changes + else if (editingFile != null && lastSavedFilePath == null && pendingFilePath == null) // File without pending changes { var encoding = EncodingUtility.GetEncodingByName(editorSessionData.StateMetaData.LastSavedEncoding); textEditor = await _notepadsCore.CreateTextEditor(editorSessionData.Id, editingFile, encoding: encoding, ignoreFileSizeLimit: true); @@ -415,11 +416,20 @@ private async Task RecoverTextEditorAsync(TextEditorSessionDataV1 e string lastSavedText = string.Empty; string pendingText = null; - if (lastSavedFile != null) + if (lastSavedFilePath != null) { - TextFile lastSavedTextFile = await FileSystemUtility.ReadFile(lastSavedFile, ignoreFileSizeLimit: true, - EncodingUtility.GetEncodingByName(editorSessionData.StateMetaData.LastSavedEncoding)); - lastSavedText = lastSavedTextFile.Content; + var lastSavedFile = backupFiles.First(file => lastSavedFilePath.Equals(file.Path, StringComparison.OrdinalIgnoreCase)); + if (lastSavedFile == null) + { + lastSavedText = (await FileSystemUtility.ReadFile(lastSavedFilePath, ignoreFileSizeLimit: true, + EncodingUtility.GetEncodingByName(editorSessionData.StateMetaData.LastSavedEncoding))).Content; + } + else + { + recoveredFiles.Add(lastSavedFile); + lastSavedText = (await FileSystemUtility.ReadFile(lastSavedFile, ignoreFileSizeLimit: true, + EncodingUtility.GetEncodingByName(editorSessionData.StateMetaData.LastSavedEncoding))).Content; + } } var textFile = new TextFile(lastSavedText, @@ -434,25 +444,121 @@ private async Task RecoverTextEditorAsync(TextEditorSessionDataV1 e editorSessionData.StateMetaData.FileNamePlaceholder, editorSessionData.StateMetaData.IsModified); - if (pendingFile != null) + if (pendingFilePath != null) { - TextFile pendingTextFile = await FileSystemUtility.ReadFile(pendingFile, - ignoreFileSizeLimit: true, - EncodingUtility.GetEncodingByName(editorSessionData.StateMetaData.LastSavedEncoding)); - pendingText = pendingTextFile.Content; + var pendingFile = backupFiles.First(file => pendingFilePath.Equals(file.Path, StringComparison.OrdinalIgnoreCase)); + if (pendingFile == null) + { + pendingText = (await FileSystemUtility.ReadFile(pendingFilePath, ignoreFileSizeLimit: true, + EncodingUtility.GetEncodingByName(editorSessionData.StateMetaData.LastSavedEncoding))).Content; + } + else + { + recoveredFiles.Add(pendingFile); + pendingText = (await FileSystemUtility.ReadFile(pendingFile, ignoreFileSizeLimit: true, + EncodingUtility.GetEncodingByName(editorSessionData.StateMetaData.LastSavedEncoding))).Content; + } } textEditor.ResetEditorState(editorSessionData.StateMetaData, pendingText); } - return textEditor; + return Tuple.Create(textEditor, recoveredFiles); + } + + // Recover editos from backup files if they have failed to store metadata + private async Task> RecoverOrphanedTextEditors(IList backupFiles) + { + IList recoveredEditors = new List(); + while (backupFiles.Count > 0) + { + var backupFile = backupFiles[0]; + Guid editorId = Guid.NewGuid(); + TextFile lastSavedTextFile = null; + TextFile pendingTextFile = null; + if (backupFile.Name.EndsWith("-LastSaved")) + { + editorId = Guid.Parse(backupFile.Name.Replace("-LastSaved", "")); + lastSavedTextFile = await FileSystemUtility.ReadFile(backupFile, ignoreFileSizeLimit: true); + var pendingFile = backupFiles.First(file => file.Name.Equals(ToToken(editorId) + "-Pending")); + pendingTextFile = await FileSystemUtility.ReadFile(pendingFile, ignoreFileSizeLimit: true); + + backupFiles.Remove(backupFile); + backupFiles.Remove(pendingFile); + } + else if (backupFile.Name.EndsWith("-Pending")) + { + editorId = Guid.Parse(backupFile.Name.Replace("-Pending", "")); + var lastSavedFile = backupFiles.First(file => file.Name.Equals(ToToken(editorId) + "-LastSaved")); + lastSavedTextFile = await FileSystemUtility.ReadFile(lastSavedFile, ignoreFileSizeLimit: true); + pendingTextFile = await FileSystemUtility.ReadFile(backupFile, ignoreFileSizeLimit: true); + + backupFiles.Remove(backupFile); + backupFiles.Remove(lastSavedFile); + } + else + { + backupFiles.Remove(backupFile); + continue; + } + + var failedPendingFilePath = Path.Combine(SessionUtility.GetBackupFolderPath(_backupFolderName), + ToToken(editorId) + "-Pending.~TMP"); + if (pendingTextFile == null) + { + if (File.Exists(failedPendingFilePath)) + { + pendingTextFile = await FileSystemUtility.ReadLocalFile(failedPendingFilePath); + } + else + { + // If there are no pending changes no need to recover + continue; + } + } + + ITextEditor textEditor = null; + var defaultName = ResourceLoader.GetForCurrentView().GetString("TextEditor_DefaultNewFileName"); + var editingFile = await FutureAccessListUtility.GetFileFromFutureAccessList(ToToken(editorId)); + var textFile = await FileSystemUtility.ReadFile(editingFile, ignoreFileSizeLimit: true); + if (lastSavedTextFile == null) + { + textEditor = _notepadsCore.CreateTextEditor(editorId, + textFile ?? pendingTextFile, + editingFile, + editingFile?.Name ?? defaultName, + true); + } + else + { + textEditor = _notepadsCore.CreateTextEditor(editorId, + lastSavedTextFile, + editingFile, + editingFile?.Name ?? defaultName, + true); + } + + if (textEditor == null) continue; + + textEditor.ResetEditorState(textEditor.GetTextEditorStateMetaData(), pendingTextFile.Content); + + if (File.Exists(failedPendingFilePath)) + { + var failedPendingText = await File.ReadAllTextAsync(failedPendingFilePath); + textEditor.ResetEditorState(textEditor.GetTextEditorStateMetaData(), failedPendingText); + } + + recoveredEditors.Add(textEditor); + } + + return recoveredEditors; } private static async Task BackupTextAsync(string text, Encoding encoding, LineEnding lineEnding, StorageFile file) { try { - await FileSystemUtility.WriteToFile(LineEndingUtility.ApplyLineEnding(text, lineEnding), encoding, file); + await FileSystemUtility.SafeWriteToFile(LineEndingUtility.ApplyLineEnding(text, lineEnding), encoding, file); return true; } catch (Exception ex) @@ -484,6 +590,11 @@ private async Task DeleteOrphanedBackupFilesAsync(NotepadsSessionDataV1 sessionD } } } + + foreach (var filePth in Directory.GetFiles(ApplicationData.Current.LocalFolder.Path, "*.~TMP", SearchOption.AllDirectories)) + { + File.Delete(filePth); + } } // Cleanup orphaned/dangling entries in FutureAccessList diff --git a/src/Notepads/Notepads.csproj b/src/Notepads/Notepads.csproj index 279ad618a..ed1b479a6 100644 --- a/src/Notepads/Notepads.csproj +++ b/src/Notepads/Notepads.csproj @@ -429,9 +429,6 @@ 2.0.1 - - 13.0.1 - 5.0.1 diff --git a/src/Notepads/Utilities/FileSystemUtility.cs b/src/Notepads/Utilities/FileSystemUtility.cs index 833693334..d6b7b13e4 100644 --- a/src/Notepads/Utilities/FileSystemUtility.cs +++ b/src/Notepads/Utilities/FileSystemUtility.cs @@ -312,14 +312,61 @@ public static async Task GetFile(string filePath) } } + public static async Task ReadLocalFile(string filePath, Encoding encoding = null) + { + if (string.IsNullOrWhiteSpace(filePath)) return null; + + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + string text; + var bom = new byte[4]; + + using (var stream = File.OpenRead(filePath)) + { + await stream.ReadAsync(bom, 0, 4); // Read BOM values + stream.Position = 0; // Reset stream position + + var reader = CreateStreamReader(stream, bom, encoding); + + async Task PeekAndRead() + { + if (encoding == null) + { + reader.Peek(); + encoding = reader.CurrentEncoding; + } + var str = await reader.ReadToEndAsync(); + reader.Close(); + return str; + } + + try + { + text = await PeekAndRead(); + } + catch (DecoderFallbackException) + { + stream.Position = 0; // Reset stream position + encoding = GetFallBackEncoding(); + reader = new StreamReader(stream, encoding); + text = await PeekAndRead(); + } + } + + encoding = FixUtf8Bom(encoding, bom); + return new TextFile(text, encoding, LineEndingUtility.GetLineEndingTypeFromText(text), File.GetLastWriteTime(filePath).ToFileTime()); + } + public static async Task ReadFile(string filePath, bool ignoreFileSizeLimit, Encoding encoding) { StorageFile file = await GetFile(filePath); - return file == null ? null : await ReadFile(file, ignoreFileSizeLimit, encoding); + return await ReadFile(file, ignoreFileSizeLimit, encoding); } public static async Task ReadFile(StorageFile file, bool ignoreFileSizeLimit, Encoding encoding = null) { + if (file == null) return null; + var fileProperties = await file.GetBasicPropertiesAsync(); if (!ignoreFileSizeLimit && fileProperties.Size > 1000 * 1024) @@ -607,6 +654,71 @@ public static async Task WriteToFile(string text, Encoding encoding, StorageFile } } + /// + /// Safely Save text to a file with requested encoding with transaction model + /// https://docs.microsoft.com/en-us/windows/uwp/files/best-practices-for-writing-to-files + /// Exception will be thrown if not succeeded + /// Exception should be caught and handled by caller + /// + /// + /// + /// + /// + public static async Task SafeWriteToFile(string text, Encoding encoding, StorageFile file) + { + bool usedDeferUpdates = true; + + try + { + // Prevent updates to the remote version of the file until we + // finish making changes and call CompleteUpdatesAsync. + CachedFileManager.DeferUpdates(file); + } + catch (Exception) + { + // If DeferUpdates fails, just ignore it and try to save the file anyway + usedDeferUpdates = false; + } + + // Write to file + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + try + { + var content = encoding.GetBytes(text); + var result = encoding.GetPreamble().Concat(content).ToArray(); + if (IsFileReadOnly(file) || !await IsFileWritable(file)) + { + // For file(s) dragged into Notepads, they are read-only + // StorageFile API won't work on read-only files but can be written by Win32 PathIO API (exploit?) + // In case the file is actually read-only, WriteBytesAsync will throw UnauthorizedAccessException + await PathIO.WriteBytesAsync(file.Path, result); + } + else // Use FileIO API to save + { + await FileIO.WriteBytesAsync(file, result); + } + } + finally + { + if (usedDeferUpdates) + { + // Let Windows know that we're finished changing the file so the + // other app can update the remote version of the file. + FileUpdateStatus status = await CachedFileManager.CompleteUpdatesAsync(file); + if (status != FileUpdateStatus.Complete) + { + // Track FileUpdateStatus here to better understand the failed scenarios + // File name, path and content are not included to respect/protect user privacy + Analytics.TrackEvent("CachedFileManager_CompleteUpdatesAsync_Failed", new Dictionary() + { + { "FileUpdateStatus", nameof(status) } + }); + } + } + } + } + internal static async Task DeleteFile(string filePath, StorageDeleteOption deleteOption = StorageDeleteOption.PermanentDelete) { try diff --git a/src/Notepads/Utilities/SessionUtility.cs b/src/Notepads/Utilities/SessionUtility.cs index 8aa9e5061..1e199b72d 100644 --- a/src/Notepads/Utilities/SessionUtility.cs +++ b/src/Notepads/Utilities/SessionUtility.cs @@ -9,6 +9,10 @@ using Notepads.Services; using Windows.Storage; using Microsoft.AppCenter.Analytics; + using System.IO; + using Newtonsoft.Json.Linq; + using Notepads.Core.SessionDataModels; + using System.Text.Json; internal static class SessionUtility { @@ -45,6 +49,11 @@ public static ISessionManager GetSessionManager(INotepadsCore notepadCore, strin return sessionManager; } + public static string GetBackupFolderPath(string backupFolderName) + { + return Path.Combine(ApplicationData.Current.LocalFolder.Path, backupFolderName); + } + public static async Task GetBackupFolderAsync(string backupFolderName) { return await FileSystemUtility.GetOrCreateAppFolder(backupFolderName); @@ -56,16 +65,72 @@ public static async Task> GetAllBackupFilesAsync(stri return await backupFolder.GetFilesAsync(); } - public static async Task GetSerializedSessionMetaDataAsync(string sessionMetaDataFileName) + public static async Task GetSessionMetaDataAsync(string sessionMetaDataFileName) { try { StorageFolder localFolder = ApplicationData.Current.LocalFolder; if (await localFolder.FileExistsAsync(sessionMetaDataFileName)) { - var data = await localFolder.ReadTextFromFileAsync(sessionMetaDataFileName); - LoggingService.LogInfo($"[{nameof(SessionUtility)}] Session metadata Loaded from {localFolder.Path}"); - return data; + NotepadsSessionDataV1 sessionData = null; + string metadataFilePath = null; + + var tempMetaDataFilePath = Path.Combine(localFolder.Path, sessionMetaDataFileName + "~.TMP"); + if (File.Exists(tempMetaDataFilePath)) + { + var data = await File.ReadAllTextAsync(tempMetaDataFilePath); + try + { + var json = JsonDocument.Parse(data); + var version = json.RootElement.GetProperty("Version").GetInt32(); + + if (version == 1) + { + sessionData = JsonSerializer.Deserialize(data); + } + else + { + throw new Exception($"Invalid version found in temporary session metadata: {version}"); + } + + metadataFilePath = tempMetaDataFilePath; + } + catch (Exception ex) + { + LoggingService.LogError($"[{nameof(SessionManager)}] Failed to load temporary last session metadata: {ex.Message}"); + Analytics.TrackEvent("SessionManager_FailedToLoadLastTempSession", new Dictionary() { { "Exception", ex.Message } }); + } + } + + if (sessionData == null) + { + var data = await localFolder.ReadTextFromFileAsync(sessionMetaDataFileName); + + try + { + var json = JObject.Parse(data); + var version = (int)json["Version"]; + + if (version == 1) + { + sessionData = JsonSerializer.Deserialize(data); + } + else + { + throw new Exception($"Invalid version found in session metadata: {version}"); + } + + metadataFilePath = Path.Combine(localFolder.Path, sessionMetaDataFileName); + } + catch (Exception ex) + { + LoggingService.LogError($"[{nameof(SessionManager)}] Failed to load last session metadata: {ex.Message}"); + Analytics.TrackEvent("SessionManager_FailedToLoadLastSession", new Dictionary() { { "Exception", ex.Message } }); + } + } + + LoggingService.LogInfo($"[{nameof(SessionUtility)}] Session metadata Loaded from {metadataFilePath}"); + return sessionData; } } catch (Exception ex) @@ -84,17 +149,6 @@ public static async Task GetSerializedSessionMetaDataAsync(string sessio public static async Task SaveSerializedSessionMetaDataAsync(string serializedData, string sessionMetaDataFileName) { StorageFolder localFolder = ApplicationData.Current.LocalFolder; - - // Attempt to delete session meta data file first in case it was not been deleted - try - { - await DeleteSerializedSessionMetaDataAsync(sessionMetaDataFileName); - } - catch (Exception) - { - // ignored - } - await localFolder.WriteTextToFileAsync(serializedData, sessionMetaDataFileName, CreationCollisionOption.ReplaceExisting); }