Use localappdata to store settings, instead of in WindowsApps (#304)

I'm smarter than that, really. 

As described in #302. You can't write into `WindowsApps`, where actual packages are installed. Instead, you need to use the local app data path. 

This replicates logic that we've got in the Terminal, for getting the right LocalAppData path, without using Windows.Storage. Original code looks like:

```c++
_TIL_INLINEPREFIX bool IsPackaged()
{
    static const auto isPackaged = []() {
        UINT32 bufferLength = 0;
        const auto hr = GetCurrentPackageId(&bufferLength, nullptr);
        return hr != APPMODEL_ERROR_NO_PACKAGE;
    }();
    return isPackaged;
}

std::filesystem::path GetBaseSettingsPath()
{
    static auto baseSettingsPath = []() {
        /* some portable mode code we don't need */

        wil::unique_cotaskmem_string localAppDataFolder;
        // KF_FLAG_FORCE_APP_DATA_REDIRECTION, when engaged, causes SHGet... to return
        // the new AppModel paths (Packages/xxx/RoamingState, etc.) for standard path requests.
        // Using this flag allows us to avoid Windows.Storage.ApplicationData completely.
        THROW_IF_FAILED(SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_FORCE_APP_DATA_REDIRECTION, nullptr, &localAppDataFolder));

        std::filesystem::path parentDirectoryForSettingsFile{ localAppDataFolder.get() };

        if (!IsPackaged())
        {
            parentDirectoryForSettingsFile /= UnpackagedSettingsFolderName;
        }

        // Create the directory if it doesn't exist
        std::filesystem::create_directories(parentDirectoryForSettingsFile);

        return parentDirectoryForSettingsFile;
    }();
    return baseSettingsPath;
}
```

I stuck this in a `Helpers.Utilities` class, because we will not be the only ones hitting this. 

Closes #302
This commit is contained in:
Mike Griese
2025-01-17 03:58:16 -06:00
committed by GitHub
parent b7c6c9c2df
commit 8ed322cce3
16 changed files with 137 additions and 113 deletions

View File

@@ -8,7 +8,6 @@ namespace Microsoft.CmdPal.Ext.Bookmarks;
[JsonSerializable(typeof(BookmarkData))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(string))]
internal sealed partial class BookmarkDataContext : JsonSerializerContext
{
}

View File

@@ -111,13 +111,10 @@ public partial class BookmarksCommandProvider : CommandProvider
internal static string StateJsonPath()
{
// Get the path to our exe
var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
// Get the directory of the exe
var directory = System.IO.Path.GetDirectoryName(path) ?? string.Empty;
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the state is just next to the exe
return System.IO.Path.Combine(directory, "state.json");
return System.IO.Path.Combine(directory, "bookmarks.json");
}
}

View File

@@ -53,11 +53,8 @@ public class SettingsManager : JsonSettingsManager
internal static string SettingsJsonPath()
{
// Get the path to our exe
var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
// Get the directory of the exe
var directory = Path.GetDirectoryName(path) ?? string.Empty;
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the state is just next to the exe
return Path.Combine(directory, "settings.json");

View File

@@ -23,7 +23,7 @@ internal sealed partial class SettingsPage : FormPage
{
Name = Properties.Resources.settings_page_name;
Icon = new("\uE713"); // Settings icon
_settings = settingsManager.GetSettings();
_settings = settingsManager.Settings;
_settingsManager = settingsManager;
_settings.SettingsChanged += SettingsChanged;

View File

@@ -8,20 +8,21 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.Ext.WebSearch.Commands;
using Microsoft.CmdPal.Ext.WebSearch.Properties;
using Microsoft.CmdPal.Extensions.Helpers;
namespace Microsoft.CmdPal.Ext.WebSearch.Helpers;
public class SettingsManager
public class SettingsManager : JsonSettingsManager
{
private readonly string _filePath;
private readonly string _historyPath;
private readonly Microsoft.CmdPal.Extensions.Helpers.Settings _settings = new();
private readonly List<ChoiceSetSetting.Choice> _choices = new()
private static readonly string _namespace = "websearch";
private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}";
private static readonly List<ChoiceSetSetting.Choice> _choices = new()
{
new ChoiceSetSetting.Choice(Resources.history_none, Resources.history_none),
new ChoiceSetSetting.Choice(Resources.history_1, Resources.history_1),
@@ -30,8 +31,17 @@ public class SettingsManager
new ChoiceSetSetting.Choice(Resources.history_20, Resources.history_20),
};
private readonly ToggleSetting _globalIfURI = new(nameof(GlobalIfURI), Resources.plugin_global_if_uri, Resources.plugin_global_if_uri, false);
private readonly ChoiceSetSetting _showHistory;
private readonly ToggleSetting _globalIfURI = new(
Namespaced(nameof(GlobalIfURI)),
Resources.plugin_global_if_uri,
Resources.plugin_global_if_uri,
false);
private readonly ChoiceSetSetting _showHistory = new(
Namespaced(nameof(ShowHistory)),
Resources.plugin_show_history,
Resources.plugin_show_history,
_choices);
public bool GlobalIfURI => _globalIfURI.Value;
@@ -39,23 +49,17 @@ public class SettingsManager
internal static string SettingsJsonPath()
{
// Get the path to our exe
var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
// Get the directory of the exe
var directory = Path.GetDirectoryName(path) ?? string.Empty;
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the state is just next to the exe
return Path.Combine(directory, "websearch_state.json");
return Path.Combine(directory, "settings.json");
}
internal static string HistoryStateJsonPath()
{
// Get the path to our exe
var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
// Get the directory of the exe
var directory = Path.GetDirectoryName(path) ?? string.Empty;
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the state is just next to the exe
return Path.Combine(directory, "websearch_history.json");
@@ -142,12 +146,11 @@ public class SettingsManager
public SettingsManager()
{
_filePath = SettingsJsonPath();
FilePath = SettingsJsonPath();
_historyPath = HistoryStateJsonPath();
_showHistory = new(nameof(ShowHistory), Resources.plugin_show_history, Resources.plugin_show_history, _choices);
_settings.Add(_globalIfURI);
_settings.Add(_showHistory);
Settings.Add(_globalIfURI);
Settings.Add(_showHistory);
// Load settings from file upon initialization
LoadSettings();
@@ -178,17 +181,11 @@ public class SettingsManager
}
}
public Microsoft.CmdPal.Extensions.Helpers.Settings GetSettings() => _settings;
public void SaveSettings()
public override void SaveSettings()
{
base.SaveSettings();
try
{
// Serialize the main dictionary to JSON and save it to the file
var settingsJson = _settings.ToJson();
File.WriteAllText(_filePath, settingsJson);
if (ShowHistory == Resources.history_none)
{
ClearHistory();
@@ -219,33 +216,4 @@ public class SettingsManager
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
}
public void LoadSettings()
{
if (!File.Exists(_filePath))
{
ExtensionHost.LogMessage(new LogMessage() { Message = "The provided settings file does not exist" });
return;
}
try
{
// Read the JSON content from the file
var jsonContent = File.ReadAllText(_filePath);
// Is it valid JSON?
if (JsonNode.Parse(jsonContent) is JsonObject savedSettings)
{
_settings.Update(jsonContent);
}
else
{
ExtensionHost.LogMessage(new LogMessage() { Message = "Failed to parse settings file as JsonObject." });
}
}
catch (Exception ex)
{
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
}
}

View File

@@ -24,7 +24,7 @@ internal sealed partial class SettingsPage : FormPage
{
Name = Resources.settings_page_name;
Icon = new("\uE713"); // Settings icon
_settings = settingsManager.GetSettings();
_settings = settingsManager.Settings;
_settingsManager = settingsManager;
_settings.SettingsChanged += SettingsChanged;

View File

@@ -90,11 +90,8 @@ public class SettingsManager : JsonSettingsManager
internal static string SettingsJsonPath()
{
// Get the path to our exe
var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
// Get the directory of the exe
var directory = Path.GetDirectoryName(path) ?? string.Empty;
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the state is just next to the exe
return Path.Combine(directory, "settings.json");

View File

@@ -25,7 +25,7 @@ internal sealed partial class SettingsPage : FormPage
Name = Resources.windowwalker_settings_name;
Icon = new("\uE713"); // Settings icon
_settingsManager = SettingsManager.Instance;
_settings = _settingsManager.GetSettings();
_settings = _settingsManager.Settings;
_settings.SettingsChanged += SettingsChanged;
}

View File

@@ -42,11 +42,8 @@ public class SettingsManager : JsonSettingsManager
internal static string SettingsJsonPath()
{
// Get the path to our exe
var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
// Get the directory of the exe
var directory = Path.GetDirectoryName(path) ?? string.Empty;
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the state is just next to the exe
return Path.Combine(directory, "settings.json");

View File

@@ -24,7 +24,7 @@ internal sealed partial class SettingsPage : FormPage
{
Name = Resources.settings_page_name;
Icon = new("\uE713"); // Settings icon
_settings = settingsManager.GetSettings();
_settings = settingsManager.Settings;
_settingsManager = settingsManager;
_settings.SettingsChanged += SettingsChanged;

View File

@@ -90,11 +90,8 @@ public partial class SpongebotPage : MarkdownPage, IFallbackHandler
internal static string StateJsonPath()
{
// Get the path to our exe
var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
// Get the directory of the exe
var directory = Path.GetDirectoryName(path) ?? string.Empty;
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the state is just next to the exe
return Path.Combine(directory, "state.json");

View File

@@ -2,12 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.CmdPal.Extensions.Helpers;
namespace YouTubeExtension.Actions;
@@ -15,11 +11,8 @@ internal sealed class YouTubeHelper
{
internal static string StateJsonPath()
{
// Get the path to our exe
var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
// Get the directory of the exe
var directory = Path.GetDirectoryName(path) ?? string.Empty;
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the state is just next to the exe
return Path.Combine(directory, "state.json");

View File

@@ -7,6 +7,7 @@ using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.Extensions.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Windows.Foundation;
@@ -119,11 +120,8 @@ public partial class SettingsModel : ObservableObject
internal static string SettingsJsonPath()
{
// Get the path to our exe
var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
// Get the directory of the exe
var directory = Path.GetDirectoryName(path) ?? string.Empty;
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the settings is just next to the exe
return Path.Combine(directory, "settings.json");

View File

@@ -22,12 +22,7 @@ public abstract class JsonSettingsManager
Converters = { new JsonStringEnumConverter() },
};
public Settings GetSettings()
{
return _settings;
}
public void LoadSettings()
public virtual void LoadSettings()
{
if (string.IsNullOrEmpty(FilePath))
{
@@ -62,7 +57,7 @@ public abstract class JsonSettingsManager
}
}
public void SaveSettings()
public virtual void SaveSettings()
{
if (string.IsNullOrEmpty(FilePath))
{

View File

@@ -2,4 +2,7 @@
GetCurrentThread
OpenThreadToken
GetPackageFamilyNameFromToken
CoRevertToSelf
CoRevertToSelf
SHGetKnownFolderPath
KNOWN_FOLDER_FLAG
GetCurrentPackageId

View File

@@ -0,0 +1,83 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.Shell;
namespace Microsoft.CmdPal.Extensions.Helpers;
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1312:Variable names should begin with lower-case letter", Justification = "This file has more than a couple Windows constants in it, which don't make sense to rename")]
public class Utilities
{
/// <summary>
/// Used to produce a path to a settings folder which your app can use.
/// If your app is running packaged, this will return the redirected local
/// app data path (Packages/{your_pfn}/LocalState). If not, it'll return
/// %LOCALAPPDATA%\{settingsFolderName}.
///
/// Does not ensure that the directory exists. Callers should call
/// CreateDirectory before writing settings files to this directory.
/// </summary>
/// <example>
/// var directory = Utilities.BaseSettingsPath("Some.Unique.String.Here");
/// Directory.CreateDirectory(directory);
/// </example>
/// <param name="settingsFolderName">A fallback directory name to use
/// inside of %LocalAppData%, in the case this app is not currently running
/// in a package context</param>
/// <returns>The path to a folder to use for storing settings.</returns>
public static string BaseSettingsPath(string settingsFolderName)
{
// KF_FLAG_FORCE_APP_DATA_REDIRECTION, when engaged, causes SHGet... to return
// the new AppModel paths (Packages/xxx/RoamingState, etc.) for standard path requests.
// Using this flag allows us to avoid Windows.Storage.ApplicationData completely.
var FOLDERID_LocalAppData = new Guid("F1B32785-6FBA-4FCF-9D55-7B8E7F157091");
var hr = PInvoke.SHGetKnownFolderPath(
FOLDERID_LocalAppData,
(uint)KNOWN_FOLDER_FLAG.KF_FLAG_FORCE_APP_DATA_REDIRECTION,
null,
out var localAppDataFolder);
if (hr.Succeeded)
{
var basePath = new string(localAppDataFolder.ToString());
if (!IsPackaged())
{
basePath = Path.Combine(basePath, settingsFolderName);
}
return basePath;
}
else
{
throw Marshal.GetExceptionForHR(hr.Value)!;
}
}
/// <summary>
/// Can be used to quickly determine if this process is running with package identity.
/// </summary>
/// <returns>true iff the process is running with package identity</returns>
public static bool IsPackaged()
{
uint bufferSize = 0;
var bytes = Array.Empty<byte>();
// CsWinRT apparently won't generate this constant
var APPMODEL_ERROR_NO_PACKAGE = (WIN32_ERROR)15700;
unsafe
{
fixed (byte* p = bytes)
{
// We don't actually need the package ID. We just need to know
// if we have a package or not, and APPMODEL_ERROR_NO_PACKAGE
// is a quick way to find out.
var win32Error = PInvoke.GetCurrentPackageId(ref bufferSize, p);
return win32Error != APPMODEL_ERROR_NO_PACKAGE;
}
}
}
}