Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/validate-tests.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
pushd %~dp0..
dotnet test --no-build "%~dp0validate-unit-tests.proj"
if %ERRORLEVEL% neq 0 ( popd & exit /b %ERRORLEVEL% )
dotnet test --no-build "%~dp0validate-e2e-tests.proj"
dotnet test --no-build "%~dp0validate-e2e-tests.proj" -m:1
if %ERRORLEVEL% neq 0 ( popd & exit /b %ERRORLEVEL% )
popd
2 changes: 1 addition & 1 deletion scripts/validate-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ cd "$SCRIPT_DIR/.."
echo 'Testing unit test projects...'
dotnet test --no-build "$SCRIPT_DIR/validate-unit-tests.proj"
echo 'Testing E2E test projects...'
dotnet test --no-build "$SCRIPT_DIR/validate-e2e-tests.proj"
dotnet test --no-build "$SCRIPT_DIR/validate-e2e-tests.proj" -m:1
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using AdaptiveRemote.Models.CloudAssets;
using AdaptiveRemote.Services;
using AdaptiveRemote.Services.CloudAssets;
using AdaptiveRemote.Services.IdleDetection;
using AdaptiveRemote.Services.Lifecycle;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -12,7 +14,12 @@ internal static IServiceCollection AddCloudAssetServices(this IServiceCollection
=> services
.AddSingleton<ICloudAssetStore, CloudAssetStore>()
.AddSingleton<IIdleDetector, IdleDetector>()
.AddSingleton<ICloudAssetDownloader, FileCloudAssetDownloader>()
.AddScopedLifecycleService<IdleDetector.ScopedIdleDetector>()
.AddSingleton<ICloudAssetCache, CloudAssetCache>()
.AddSingleton<ICloudAssetDownloader, FileSystemCloudAssetDownloader>()
.AddSingleton<FileSystemCloudAssetWatchService>()
.AddSingleton<ICloudAssetChangeNotifier>(sp => sp.GetRequiredService<FileSystemCloudAssetWatchService>())
.AddHostedService(sp => sp.GetRequiredService<FileSystemCloudAssetWatchService>())
.AddSingleton<CloudAssetOrchestrator>()
.AddSingleton<IPreScopeInitializer>(sp => sp.GetRequiredService<CloudAssetOrchestrator>())
.AddHostedService(sp => sp.GetRequiredService<CloudAssetOrchestrator>())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ internal static IServiceCollection AddConversationServices(this IServiceCollecti
.AddScoped(GetConversationViewModel)
.AddSingleton<Models.ModalMessageView>()
.AddSingleton<IModalMessageService, ModalMessageService>()
.AddScoped<IUserActivityDetector, ConversationIdleAdapter>();
.AddScoped<IUserActivityDetector, ConversationActivityDetector>();

internal static IServiceCollection AddConversationServices(this IServiceCollection services, IConfiguration config)
=> services
Expand Down
26 changes: 17 additions & 9 deletions src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using AdaptiveRemote.Contracts;
using AdaptiveRemote.Models.CloudAssets;
using AdaptiveRemote.Services;
using AdaptiveRemote.Services.CloudAssets;
using AdaptiveRemote.Services.Commands;
using AdaptiveRemote.Services.Layout;
using AdaptiveRemote.Services.Lifecycle;
Expand Down Expand Up @@ -35,13 +35,8 @@ internal static IServiceCollection AddRemoteServices(this IServiceCollection ser
eventName: "layout-ready",
resourcePath: "/layouts/compiled",
jsonContext: LayoutContractsJsonContext.Default))
.AddScopedLifecycleService<LifecycleCommandService>()
.AddScoped<IUserActivityDetector, ProgrammingModeIdleAdapter>()
.AddScoped<IUserActivityDetector, CommandExecutionIdleAdapter>()
.AddScoped<IRemoteDefinitionService, RemoteLayoutDefinitionService>()
.AddScoped<IDynamicStylesheetProvider, LayoutStylesheetProvider>()
.AddSingleton<IPersistSettings, PersistSettings>()
.Configure<ProgrammaticSettings>(configuration.GetSection(SettingsKeys.ProgrammaticSettings));
.AddCommandSystemServices()
.AddProgrammaticSettingsServices(configuration.GetSection(SettingsKeys.ProgrammaticSettings));

internal static IServiceCollection AddScopedLifecycleService<ServiceType>(this IServiceCollection services)
where ServiceType : class, IScopedLifecycle
Expand All @@ -62,5 +57,18 @@ private static IServiceCollection AddApplicationLifecycleServices(this IServiceC
.AddScoped<Components.BlazorAppScope>()
.AddSingleton<IApplicationScopeContainer, ApplicationScopeContainer>()
.AddSingleton(sp => (IApplicationScopeProvider)sp.GetRequiredService<IApplicationScopeContainer>())
.AddSingleton<IApplicationRecycleSignal, ApplicationRecycleSignal>();
.AddSingleton<IApplicationRecycleSignal, ApplicationRecycleSignal>()
.AddScopedLifecycleService<LifecycleCommandService>()
.AddScoped<IUserActivityDetector, ProgrammingModeActivityDetector>();

private static IServiceCollection AddCommandSystemServices(this IServiceCollection services)
=> services
.AddScoped<IUserActivityDetector, CommandsActivityDetector>()
.AddScoped<IRemoteDefinitionService, RemoteLayoutDefinitionService>()
.AddScoped<IDynamicStylesheetProvider, LayoutStylesheetProvider>();

private static IServiceCollection AddProgrammaticSettingsServices(this IServiceCollection services, IConfiguration configuration)
=> services
.AddSingleton<IPersistSettings, PersistSettings>()
.Configure<ProgrammaticSettings>(configuration);
}
36 changes: 30 additions & 6 deletions src/AdaptiveRemote.App/Logging/MessageLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,15 @@ public MessageLogger(ILogger logger)

// 1600–1699: CognitoTokenService

[LoggerMessage(EventId = 1600, Level = LogLevel.Information, Message = "Acquiring Cognito access token via Client Credentials flow")]
public partial void CognitoTokenService_AcquiringToken();

[LoggerMessage(EventId = 1601, Level = LogLevel.Information, Message = "Cognito access token acquired successfully")]
public partial void CognitoTokenService_TokenAcquired();

[LoggerMessage(EventId = 1602, Level = LogLevel.Error, Message = "Failed to acquire Cognito access token")]
public partial void CognitoTokenService_AcquireTokenFailed(Exception exception);

// 1700–1799: CloudAssetOrchestrator

[LoggerMessage(EventId = 1700, Level = LogLevel.Information, Message = "Downloading asset '{AssetName}'")]
Expand All @@ -367,12 +376,27 @@ public MessageLogger(ILogger logger)
[LoggerMessage(EventId = 1701, Level = LogLevel.Error, Message = "Failed to initialize cloud assets")]
public partial void CloudAssetOrchestrator_Failed(Exception error);

[LoggerMessage(EventId = 1600, Level = LogLevel.Information, Message = "Acquiring Cognito access token via Client Credentials flow")]
public partial void CognitoTokenService_AcquiringToken();
[LoggerMessage(EventId = 1702, Level = LogLevel.Information, Message = "Loaded asset '{AssetName}' from cache")]
public partial void CloudAssetOrchestrator_LoadedFromCache(string assetName);

[LoggerMessage(EventId = 1601, Level = LogLevel.Information, Message = "Cognito access token acquired successfully")]
public partial void CognitoTokenService_TokenAcquired();
[LoggerMessage(EventId = 1703, Level = LogLevel.Information, Message = "Asset '{AssetName}' is up to date")]
public partial void CloudAssetOrchestrator_AssetUpToDate(string assetName);

[LoggerMessage(EventId = 1602, Level = LogLevel.Error, Message = "Failed to acquire Cognito access token")]
public partial void CognitoTokenService_AcquireTokenFailed(Exception exception);
[LoggerMessage(EventId = 1704, Level = LogLevel.Information, Message = "Asset '{AssetName}' updated from server; scheduling recycle")]
public partial void CloudAssetOrchestrator_AssetUpdated(string assetName);

[LoggerMessage(EventId = 1705, Level = LogLevel.Warning, Message = "Failed to download latest '{AssetName}' from server; keeping cached version")]
public partial void CloudAssetOrchestrator_BackgroundFetchFailed(string assetName, Exception? exception);

[LoggerMessage(EventId = 1706, Level = LogLevel.Information, Message = "Layout service reported a change; re-downloading asset '{AssetName}'")]
public partial void CloudAssetOrchestrator_FileChangeDetected(string assetName);

[LoggerMessage(EventId = 1707, Level = LogLevel.Warning, Message = "Received change notification for unknown asset '{AssetName}'; ignoring")]
public partial void CloudAssetOrchestrator_UnknownAssetChange(string assetName);

[LoggerMessage(EventId = 1708, Level = LogLevel.Information, Message = "Asset '{AssetName}' not found in cache")]
public partial void CloudAssetOrchestrator_NotFoundInCache(string assetName);

[LoggerMessage(EventId = 1709, Level = LogLevel.Information, Message = "Downloaded asset '{AssetName}'")]
public partial void CloudAssetOrchestrator_Downloaded(string assetName);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace AdaptiveRemote.Services.CloudAssets;
namespace AdaptiveRemote.Models.CloudAssets;

internal abstract class BasicCloudAsset<T> : ICloudAsset<T>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace AdaptiveRemote.Services.CloudAssets;
namespace AdaptiveRemote.Models.CloudAssets;

/// <summary>
/// Per-asset capability bundle. One implementation per cloud-fetched asset type.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace AdaptiveRemote.Services.CloudAssets;
namespace AdaptiveRemote.Models.CloudAssets;

internal sealed class JsonCloudAsset<T>(
string name,
Expand Down
1 change: 1 addition & 0 deletions src/AdaptiveRemote.App/Models/Phrases.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal static class Phrases
public static string Startup_BuildingServiceGraph => "Building service graph";
public static string Startup_StartingServices => "Starting services";
public static string Startup_Preinitializing(string initializer) => $"Waiting for preinitializer {initializer}";
public static string Startup_LoadingCloudAssets => "Loading cloud assets";
public static string Startup_ConnectingToBroadlink => "Connecting to Broadlink device";
public static string Startup_ConnectingToTiVo => "Connecting to TiVo";
public static string Startup_Starting(string service) => $"Starting {service}";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
using System.ComponentModel;
using AdaptiveRemote.Mvvm;
using AdaptiveRemote.Services;

namespace AdaptiveRemote.Services;
namespace AdaptiveRemote.Mvvm;

// Subscribes to a bool MvvmProperty on an MvvmObject and holds a non-idle token via
// IIdleDetector while the property is true. Thread-safe: InitializeAsync, CleanUpAsync,
// and OnPropertyChanged all synchronize on _lock, and _subscribed prevents token leaks
// if a PropertyChanged callback races with CleanUpAsync.
internal abstract class MvvmPropertyIdleAdapter : IUserActivityDetector, IDisposable
internal abstract class MvvmPropertyActivityDetector : IUserActivityDetector, IDisposable
{
private readonly MvvmObject _target;
private readonly MvvmProperty<bool> _property;
private DateTime? _lastActivityTime;

protected MvvmPropertyIdleAdapter(MvvmObject target, MvvmProperty<bool> property)
protected MvvmPropertyActivityDetector(MvvmObject target, MvvmProperty<bool> property)
{
_target = target ?? throw new ArgumentNullException(nameof(target));
_property = property ?? throw new ArgumentNullException(nameof(property));
Expand Down
44 changes: 44 additions & 0 deletions src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using AdaptiveRemote.Services;
using Microsoft.Extensions.Options;

namespace AdaptiveRemote.Services.CloudAssets;

internal sealed class CloudAssetCache : ICloudAssetCache
{
private readonly string _cacheDirectory;
private readonly IFileSystem _fileSystem;

public CloudAssetCache(IOptions<CloudSettings> options, IFileSystem fileSystem)
{
_cacheDirectory = Environment.ExpandEnvironmentVariables(options.Value.CachePath);
_fileSystem = fileSystem;
}

public Task<Stream?> LoadAsync(string name, CancellationToken ct)
{
string path = GetCachePath(name);
if (!_fileSystem.FileExists(path))
{
return Task.FromResult<Stream?>(null);
}
return Task.FromResult<Stream?>(_fileSystem.OpenRead(path));
}

public async Task SaveAsync(string name, Stream assetData, CancellationToken ct)
{
string path = GetCachePath(name);
await using Stream dest = _fileSystem.OpenWrite(path, createDirectory: true);
await assetData.CopyToAsync(dest, ct);
}

private string GetCachePath(string name)
{
// Asset names are developer-controlled DI constants, but guard against accidental
// misconfiguration that would place a cache file outside the cache directory.
if (name.IndexOfAny(['/', '\\', ':']) >= 0 || name.StartsWith('.'))
{
throw new ArgumentException($"Invalid asset name '{name}'.", nameof(name));
}
return Path.Combine(_cacheDirectory, $"{name}.cache");
}
}
Loading