1+ using Google . Protobuf . WellKnownTypes ;
12using System ;
23using System . Collections . Generic ;
34using System . IO ;
45using System . Text . Json ;
56using System . Text . Json . Serialization ;
7+ using System . Threading ;
8+ using System . Threading . Tasks ;
9+ using System . Xml . Linq ;
610
711namespace Coder . Desktop . App . Services ;
812
913/// <summary>
1014/// Settings contract exposing properties for app settings.
1115/// </summary>
12- public interface ISettingsManager
16+ public interface ISettingsManager < T > where T : ISettings , new ( )
1317{
1418 /// <summary>
15- /// Returns the value of the StartOnLogin setting. Returns <c>false</c> if the key is not found.
19+ /// Reads the settings from the file system.
20+ /// Always returns the latest settings, even if they were modified by another instance of the app.
21+ /// Returned object is always a fresh instance, so it can be modified without affecting the stored settings.
1622 /// </summary>
17- bool StartOnLogin { get ; set ; }
18-
23+ /// <param name="ct"></param>
24+ /// <returns></returns>
25+ public Task < T > Read ( CancellationToken ct = default ) ;
26+ /// <summary>
27+ /// Writes the settings to the file system.
28+ /// </summary>
29+ /// <param name="settings">Object containing the settings.</param>
30+ /// <param name="ct"></param>
31+ /// <returns></returns>
32+ public Task Write ( T settings , CancellationToken ct = default ) ;
1933 /// <summary>
20- /// Returns the value of the ConnectOnLaunch setting. Returns <c>false</c> if the key is not found .
34+ /// Returns null if the settings are not cached or not available .
2135 /// </summary>
22- bool ConnectOnLaunch { get ; set ; }
36+ /// <returns></returns>
37+ public T ? GetFromCache ( ) ;
2338}
2439
2540/// <summary>
2641/// Implemention of <see cref="ISettingsManager"/> that persists settings to a JSON file
2742/// located in the user's local application data folder.
2843/// </summary>
29- public sealed class SettingsManager : ISettingsManager
44+ public sealed class SettingsManager < T > : ISettingsManager < T > where T : ISettings , new ( )
3045{
3146 private readonly string _settingsFilePath ;
32- private Settings _settings ;
33- private readonly string _fileName = "app-settings.json" ;
3447 private readonly string _appName = "CoderDesktop" ;
48+ private string _fileName ;
3549 private readonly object _lock = new ( ) ;
3650
37- public const string ConnectOnLaunchKey = "ConnectOnLaunch" ;
38- public const string StartOnLoginKey = "StartOnLogin" ;
51+ private T ? _cachedSettings ;
3952
40- public bool StartOnLogin
41- {
42- get
43- {
44- return Read ( StartOnLoginKey , false ) ;
45- }
46- set
47- {
48- Save ( StartOnLoginKey , value ) ;
49- }
50- }
51-
52- public bool ConnectOnLaunch
53- {
54- get
55- {
56- return Read ( ConnectOnLaunchKey , false ) ;
57- }
58- set
59- {
60- Save ( ConnectOnLaunchKey , value ) ;
61- }
62- }
53+ private readonly SemaphoreSlim _gate = new ( 1 , 1 ) ;
54+ private static readonly TimeSpan LockTimeout = TimeSpan . FromSeconds ( 3 ) ;
6355
6456 /// <param name="settingsFilePath">
6557 /// For unit‑tests you can pass an absolute path that already exists.
@@ -81,109 +73,129 @@ public SettingsManager(string? settingsFilePath = null)
8173 _appName ) ;
8274
8375 Directory . CreateDirectory ( folder ) ;
76+
77+ _fileName = T . SettingsFileName ;
8478 _settingsFilePath = Path . Combine ( folder , _fileName ) ;
79+ }
8580
86- if ( ! File . Exists ( _settingsFilePath ) )
81+ public async Task < T > Read ( CancellationToken ct = default )
82+ {
83+ // try to get the lock with short timeout
84+ if ( ! await _gate . WaitAsync ( LockTimeout , ct ) . ConfigureAwait ( false ) )
85+ throw new InvalidOperationException (
86+ $ "Could not acquire the settings lock within { LockTimeout . TotalSeconds } s.") ;
87+
88+ try
8789 {
88- // Create the settings file if it doesn't exist
89- _settings = new ( ) ;
90- File . WriteAllText ( _settingsFilePath , JsonSerializer . Serialize ( _settings , SettingsJsonContext . Default . Settings ) ) ;
90+ if ( ! File . Exists ( _settingsFilePath ) )
91+ return new ( ) ;
92+
93+ var json = await File . ReadAllTextAsync ( _settingsFilePath , ct )
94+ . ConfigureAwait ( false ) ;
95+
96+ // deserialize; fall back to default(T) if empty or malformed
97+ var result = JsonSerializer . Deserialize < T > ( json ) ! ;
98+ _cachedSettings = result ;
99+ return result ;
91100 }
92- else
101+ catch ( OperationCanceledException )
93102 {
94- _settings = Load ( ) ;
103+ throw ; // propagate caller-requested cancellation
95104 }
96- }
97-
98- private void Save ( string name , bool value )
99- {
100- lock ( _lock )
105+ catch ( Exception ex )
101106 {
102- try
103- {
104- // We lock the file for the entire operation to prevent concurrent writes
105- using var fs = new FileStream ( _settingsFilePath ,
106- FileMode . OpenOrCreate ,
107- FileAccess . ReadWrite ,
108- FileShare . None ) ;
109-
110- // Ensure cache is loaded before saving
111- var freshCache = JsonSerializer . Deserialize ( fs , SettingsJsonContext . Default . Settings ) ?? new ( ) ;
112- _settings = freshCache ;
113- _settings . Options [ name ] = JsonSerializer . SerializeToElement ( value ) ;
114- fs . Position = 0 ; // Reset stream position to the beginning before writing
115-
116- JsonSerializer . Serialize ( fs , _settings , SettingsJsonContext . Default . Settings ) ;
117-
118- // This ensures the file is truncated to the new length
119- // if the new content is shorter than the old content
120- fs . SetLength ( fs . Position ) ;
121- }
122- catch
123- {
124- throw new InvalidOperationException ( $ "Failed to persist settings to { _settingsFilePath } . The file may be corrupted, malformed or locked.") ;
125- }
107+ throw new InvalidOperationException (
108+ $ "Failed to read settings from { _settingsFilePath } . " +
109+ "The file may be corrupted, malformed or locked." , ex ) ;
126110 }
127- }
128-
129- private bool Read ( string name , bool defaultValue )
130- {
131- lock ( _lock )
111+ finally
132112 {
133- if ( _settings . Options . TryGetValue ( name , out var element ) )
134- {
135- try
136- {
137- return element . Deserialize < bool ? > ( ) ?? defaultValue ;
138- }
139- catch
140- {
141- // malformed value – return default value
142- return defaultValue ;
143- }
144- }
145- return defaultValue ; // key not found – return default value
113+ _gate . Release ( ) ;
146114 }
147115 }
148116
149- private Settings Load ( )
117+ public async Task Write ( T settings , CancellationToken ct = default )
150118 {
119+ // try to get the lock with short timeout
120+ if ( ! await _gate . WaitAsync ( LockTimeout , ct ) . ConfigureAwait ( false ) )
121+ throw new InvalidOperationException (
122+ $ "Could not acquire the settings lock within { LockTimeout . TotalSeconds } s.") ;
123+
151124 try
152125 {
153- using var fs = File . OpenRead ( _settingsFilePath ) ;
154- return JsonSerializer . Deserialize ( fs , SettingsJsonContext . Default . Settings ) ?? new ( ) ;
126+ // overwrite the settings file with the new settings
127+ var json = JsonSerializer . Serialize (
128+ settings , new JsonSerializerOptions ( ) { WriteIndented = true } ) ;
129+ _cachedSettings = settings ; // cache the settings
130+ await File . WriteAllTextAsync ( _settingsFilePath , json , ct )
131+ . ConfigureAwait ( false ) ;
132+ }
133+ catch ( OperationCanceledException )
134+ {
135+ throw ; // let callers observe cancellation
155136 }
156137 catch ( Exception ex )
157138 {
158- throw new InvalidOperationException ( $ "Failed to load settings from { _settingsFilePath } . The file may be corrupted or malformed. Exception: { ex . Message } ") ;
139+ throw new InvalidOperationException (
140+ $ "Failed to persist settings to { _settingsFilePath } . " +
141+ "The file may be corrupted, malformed or locked." , ex ) ;
142+ }
143+ finally
144+ {
145+ _gate . Release ( ) ;
159146 }
160147 }
148+
149+ public T ? GetFromCache ( )
150+ {
151+ return _cachedSettings ;
152+ }
161153}
162154
163- public class Settings
155+ public interface ISettings
164156{
165157 /// <summary>
166- /// User settings version. Increment this when the settings schema changes.
158+ /// Gets the version of the settings schema.
159+ /// </summary>
160+ int Version { get ; }
161+
162+ /// <summary>
163+ /// FileName where the settings are stored.
164+ /// </summary>
165+ static abstract string SettingsFileName { get ; }
166+ }
167+
168+ /// <summary>
169+ /// CoderConnect settings class that holds the settings for the CoderConnect feature.
170+ /// </summary>
171+ public class CoderConnectSettings : ISettings
172+ {
173+ /// <summary>
174+ /// CoderConnect settings version. Increment this when the settings schema changes.
167175 /// In future iterations we will be able to handle migrations when the user has
168176 /// an older version.
169177 /// </summary>
170178 public int Version { get ; set ; }
171- public Dictionary < string , JsonElement > Options { get ; set ; }
179+ public bool ConnectOnLaunch { get ; set ; }
180+ public static string SettingsFileName { get ; } = "coder-connect-settings.json" ;
172181
173182 private const int VERSION = 1 ; // Default version for backward compatibility
174- public Settings ( )
183+ public CoderConnectSettings ( )
175184 {
176185 Version = VERSION ;
177- Options = [ ] ;
186+ ConnectOnLaunch = false ;
178187 }
179188
180- public Settings ( int ? version , Dictionary < string , JsonElement > options )
189+ public CoderConnectSettings ( int ? version , bool connectOnLogin )
181190 {
182191 Version = version ?? VERSION ;
183- Options = options ;
192+ ConnectOnLaunch = connectOnLogin ;
184193 }
185- }
186194
187- [ JsonSerializable ( typeof ( Settings ) ) ]
188- [ JsonSourceGenerationOptions ( WriteIndented = true ) ]
189- public partial class SettingsJsonContext : JsonSerializerContext ;
195+ public CoderConnectSettings Clone ( )
196+ {
197+ return new CoderConnectSettings ( Version , ConnectOnLaunch ) ;
198+ }
199+
200+
201+ }
0 commit comments