Squirrel / Squirrel.Windows

An installation and update framework for Windows desktop apps
MIT License
7.23k stars 1.02k forks source link

AppSettings lost across upgrade #1764

Open AArnott opened 2 years ago

AArnott commented 2 years ago

Squirrel version(s) 2.0.2-netcore.3 (a private build with minimal changes based on the official 2.0.1 version).

Description

Updating a squirrel .NET app causes it to lose all its AppSettings.

Steps to recreate

  1. In a WPF app (or maybe any .NET app) that uses Squirrel, consume appsettings like this.
  2. Within the app, set a value to the setting
  3. Update the app with Squirrel
  4. Re-run the app and read the setting.

Expected behavior

The app setting's value should be what was set in the prior version.

Actual behavior

The app setting's value has been cleared.

anaisbetts commented 2 years ago

This has been a longstanding bug because AppSettings (despite being entirely appropriate to store in roaming app profiles) gets put in LocalAppData. The current recommendation is to not use AppSettings 😅

AArnott commented 2 years ago

Thanks for looking so quickly. Where is "local app settings"? Is that something that squirrel can copy from an old location to a new one?

AArnott commented 2 years ago

Or heck... given Squirrel offers callbacks around upgrade time, maybe I could do something to preserve them. If I didn't use AppSettings, what would I use? Anything I store in the app dir itself I suppose would be wiped out in the upgrade as well, wouldn't it?

anaisbetts commented 2 years ago

I typically store settings and other stuff one directory up from my EXE's directory, this doesn't get wiped across upgrades, but does get wiped on uninstall

anaisbetts commented 2 years ago

Thanks for looking so quickly. Where is "local app settings"?

This was me getting confused 😓 I'm talking about %LocalAppData%

AArnott commented 2 years ago

And somehow the app upgrade makes .NET read/write app settings in a different path under LocalAppData?

AArnott commented 2 years ago

It took Process Monitor to figure it out since I couldn't find any documentation for it, but in my case the settings are saved in C:\Users\andarno\AppData\Local\Andrew_Arnott\MoneyMan.WPF_Url_zor3ou4qwd14tf0bxfj3xami0oyfzk5t\0.2.0.0\user.config

I wonder what goes into that zor... character sequence. Whatever it is, I guess it must change with each update. Maybe the path of the app itself.

AArnott commented 2 years ago

Yup. When I rename the directory the app is in, the AppSettings writes to a new path, and all prior settings are unreachable.

As AppSettings has a Providers property, I may be able to replace the default provider with another that does as you say and writes the settings to the parent directory.

AArnott commented 2 years ago

I think it's theoretically possible, but the .NET BCL doesn't expose the types required to make it remotely easy. I'm going to go with your original suggestion and serialize my settings another way.

caesay commented 2 years ago

It looks like the logic for generating this path is here https://referencesource.microsoft.com/#System.Configuration/System/Configuration/ClientConfigPaths.cs,343 - and it will use a SHA1 hash of the strong name if your assembly has one, and fall back to the exe path if not. If it's simple enough to sn sign your assembly, it may fix this for you.

Depending on your framework version, the following may also be useful as it forces a custom application path. If set before you use the configuration manager for the first time, it will be used instead of your app path.

AppDomain.CurrentDomain.SetData("APP_CONFIG_FILE", path); (from the dotnet runtime), or;

AppDomain.CurrentDomain.SetupInformation.ConfigurationFile (from reference source)

AArnott commented 2 years ago

@caesay: I wasn't able to set APP_CONFIG_FILE and detect any difference in where the file was saved. But strong name signing did seem to give it a new and stable path for settings. Thank you.

adamhewitt627 commented 2 years ago

This isn't the most elegant solution, but strong naming produced too many other build errors. I dug a bit on that Providers property in Reference Source and settled on this workaround:

public Settings()
{
    LocalFileSettingsProvider localProvider = Providers
        .OfType<LocalFileSettingsProvider>()
        .FirstOrDefault();

    if (localProvider is not null)
    {
        Configuration userConfig = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.PerUserRoamingAndLocal);

        /* User config is landing in: %LocalAppData%\Publisher\<exe-name><hash>\<version>\user.config
            * This "hash" includes the file path of the *executable* meaning it changes with each Squirrel version.
            * For example:
            *                                                 |****** THIS PART CHANGES *******|  
            * %LocalAppData%\<company-name>\<exe-filename>_Url_fvqct42v5v155z0imoat5bi2twry5min\1.2.3.4\user.config
            * 
            * The framework looks for other version folders (e.g. 1.2.2.0) in that same location and will upgrade from them.
            * We can override that behavior by forcing its search one level higher into the <company-name> folder.
            */

        DirectoryInfo? parent = Directory.GetParent(userConfig.FilePath) // e.g. 1.2.3.4
            ?.Parent    // e.g. <exe-filename>_Url_fvqct42v5v155z0imoat5bi2twry5min
            ?.Parent;   // e.g. <company-name>

        FileInfo? previous = parent?.EnumerateFiles("*user.config", SearchOption.AllDirectories)
            .Where(f => f.FullName != userConfig.FilePath)
            .OrderByDescending(f =>
            {
                try
                {
                    return new Version(f.Directory.Name);
                }
                catch
                {
                    return new Version();
                }
            }, new VersionComparison())
            .FirstOrDefault();

        // .NET Framework resolves this field to previous folders, we can bypass that behavior by
        // pre-calculating it ourselves before calling Upgrade()
        FieldInfo fileNameField = typeof(LocalFileSettingsProvider).GetField("_prevLocalConfigFileName",
            bindingAttr: BindingFlags.Instance | BindingFlags.NonPublic);

        fileNameField.SetValue(localProvider, previous?.FullName);
    }
}
AArnott commented 2 years ago

Thanks for sharing an alternative. Strong naming was definitely the simplest solution for me but I can see where some folks wouldn't have that option.