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
111 changes: 111 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

**Elapsed** is a cross-platform native timelapse app for Hack Club's Lapse platform. It includes a multi-platform desktop/mobile UI (Uno Platform + .NET 10), a CLI tool, and supporting libraries.

- .NET 10 SDK, C# 14, file-scoped namespaces, implicit usings, nullable enabled
- Solution file: Elapsed.slnx

## Solution Structure

### Platform Projects (Executables)

- **Riverside.Elapsed.App** (`src/platforms/Riverside.Elapsed.App/`) — Uno Platform app targeting net10.0-desktop, net10.0-android, net10.0-ios, net10.0-browserwasm, net10.0-windows10.0.26100.0. MVVM architecture with Views, ViewModels, Models, Services. Startup project for debugging.
- **Riverside.Elapsed.CommandLine** (`src/platforms/Riverside.Elapsed.CommandLine/`) — .NET console app (net10.0) using System.CommandLine. Exposes the full Lapse API with JSON output. Installable as a dotnet tool.

### Core Libraries (Shared)

- **Riverside.Elapsed** (`src/core/Riverside.Elapsed/`) — .NET Standard 2.1. Lapse API projection auto-generated by Kiota from the OpenAPI spec at `https://api.lapse.hackclub.com/openapi.json`. Downloads the spec at build time.
- **Riverside.MediaRecording** (`src/core/Riverside.MediaRecording/`) — .NET 10 cross-platform media recording (camera, screen, microphone). Platform-specific code in `Windows/` subdirectory.
- **Riverside.ResumableUploads** (`src/core/Riverside.ResumableUploads/`) — .NET Standard 2.1 TUS protocol client for resumable uploads.

## Build Commands

```bash
# Restore workloads (required first time)
dotnet workload restore src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj

# Build desktop target
dotnet build src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj -f net10.0-desktop

# Build Windows target
dotnet build src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj -f net10.0-windows10.0.26100.0

# Build WebAssembly
dotnet build src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj -f net10.0-browserwasm

# Build CLI
dotnet build src/platforms/Riverside.Elapsed.CommandLine/Riverside.Elapsed.CommandLine.csproj

# Publish (any target, add -c Release for release builds)
dotnet publish src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj -f net10.0-desktop -c Release
```

VS Code tasks are preconfigured in `.vscode/tasks.json`: build-wasm, publish-wasm, build-desktop, publish-desktop, build-windows, publish-windows.

Build artifacts go to `bin/{ProjectName}/AnyCPU/{Configuration}/{TargetFramework}/`. Web builds use a relative `wwwroot/` path due to an Uno Wasm Bootstrap SDK bug.

## Code Style

- **Tabs** for indentation (not spaces)
- **File-scoped namespaces** (enforced as warning in .editorconfig)
- **American English** in code, **British English** in documentation and comments
- PascalCase for types/methods/properties, camelCase for locals, `I` prefix for interfaces
- See `.editorconfig` for full Roslyn analyzer rules

## Architecture

### App (MVVM with Uno Extensions)

- Services registered via `IHost` dependency injection in `App.xaml.cs`
- Navigation via `INavigator` (Uno.Extensions.Navigation)
- ViewModels use `ObservableObject` and `RelayCommand` (CommunityToolkit.Mvvm)
- Configuration loaded from `appsettings.json` into `AppConfig`
- UnoFeatures enabled: Hosting, Toolkit, Mvvm, Configuration, HttpKiota, Serialization, Localization, Authentication, Navigation, ThemeService, SkiaRenderer, Lottie, Logging/Serilog

Key service areas under `Services/`:
- `Auth/` — OAuth token management via ILapseAuthService
- `Api/` — Kiota-generated API service interfaces
- `Storage/` — Local app data persistence
- `Recording/` — Media recording (desktop/Windows only, gated by `HAS_MEDIA_RECORDING` define)
- `Drafts/` — Draft timelapse management
- `Build/` — Build metadata (version, timestamp generated at compile time via MSBuild target)

### Platform-Specific Code

Platform code lives in `Platforms/{Android,iOS,Desktop,WebAssembly}/` within the App project. Uno's single-project structure uses conditional compilation. The `HAS_MEDIA_RECORDING` define is set for desktop and Windows TFMs only.

### Lapse API Projection

The `Riverside.Elapsed` library uses the `Riverside.CompilerPlatform.CSharp.Features.Kiota` source generator. At build time, the OpenAPI spec is downloaded and C# client code is generated. The generated client uses Kiota's `IRequestAdapter` pattern. Do not manually edit generated API code.

## Versioning & Releases

Version is controlled in `eng/CurrentVersion.props`. Format: `{MAJOR}.{MINOR}.{YYMMDD}[-{LEVEL}{BETA}]` (e.g., `0.4.260531`, `2.1.260220-preview2`).

- Debug builds automatically get a `-preview1` suffix
- Release levels: `alpha`, `beta`, `preview`, `rc`, `final` (final hides the suffix)
- Changing `eng/CurrentVersion.props` on main triggers the CD pipeline, which publishes to GitHub Releases, NuGet, and Vercel (web)

## CI/CD

GitHub Actions in `.github/workflows/ci.yml`:
- Triggers on push to main, PRs, and manual dispatch
- Matrix builds across all target frameworks in Debug and Release
- XAML formatting validation with XamlStyler
- Runs on Windows Server 2025 with VS 2026

## Testing

No test projects exist yet. The CI pipeline has unit test jobs commented out.

## Important Notes

- **Native AOT & Trimming**: Desktop/Windows release builds use Native AOT. The CLI uses reflection and is NOT AOT-safe — it ships separately.
- **Kiota regeneration**: The API library auto-regenerates on build if the OpenAPI spec changes. Don't edit generated code.
- **HAS_MEDIA_RECORDING**: Conditional define only set for `-desktop` and `-windows` TFMs. Guard media recording code behind `#if HAS_MEDIA_RECORDING`.
- **Localization**: String resources in `Strings/` directory, multiple languages supported.
- **Design reference**: Figma mockups at https://www.figma.com/design/dUoOj27yGtoY3Y6HRKYJnF/Elapsed--WinUI-3-
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,29 @@ cd Elapsed
- Navigate to the debug pane in the left-side activity bar
- Run the appropriate debug profile for the platform you want to build for (or press <kbd>F5</kbd> anywhere in VS Code)

##### CLI

Building Elapsed with the MSBuild CLI is a great way to quickly test whether the app builds, as the MSBuild CLI does not rely on GUI-heavy apps such as Visual Studio.
It is recommended to use the .NET Framework version of MSBuild as it works best for the tech stack that the Elapsed project consumes.

- Restore the project dependencies via NuGet

```bash
msbuild -t:Restore -p:Configuration=Release
```

- Build/publish the project of your choice, such as `Riverside.Elapsed.App` for the main app project.

You must specify a `TargetFramework` property when building the app project, such as `net10.0-desktop`, `net10.0-windows`, or `net10.0-browserwasm`.
When building the Windows head of the app project, it is recommended to debug via an IDE such as Visual Studio as an IDE will provide a better experience for building WinUI apps.
It is also recommended to make use of the `Publish` task when building the WebAssembly/WinAppSDK/Skia heads as this will ensure the output is production ready.

```bash
msbuild src\platforms\Riverside.Elapsed.App -t:Publish -p:Configuration=Release -p:TargetFramework=net10.0-desktop
```

The build output can always be found in the `bin` folder of the project root directory.

<!--
### How to use Elapsed

Expand Down
77 changes: 75 additions & 2 deletions src/platforms/Riverside.Elapsed.App/App.xaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<Application
x:Class="Riverside.Elapsed.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:Riverside.Elapsed.App.Converters"
xmlns:wctconverters="using:CommunityToolkit.WinUI.Converters">

<Application.Resources>
<ResourceDictionary>
Expand All @@ -12,7 +14,78 @@
<ToolkitResources xmlns="using:Uno.Toolkit.UI" />
</ResourceDictionary.MergedDictionaries>

<!-- Add resources here -->
<!-- Converters -->
<wctconverters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<converters:NullToCollapsedConverter x:Key="NullToCollapsedConverter" />
<converters:BoolToPausedRecordingLabelConverter x:Key="BoolToPausedRecordingLabelConverter" />

<!-- Segmented toggle style for RadioButton (screen/window picker) -->
<Style x:Key="SegmentedToggleStyle" TargetType="RadioButton">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{ThemeResource TextFillColorPrimaryBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="8,6" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Grid
x:Name="RootGrid"
Padding="{TemplateBinding Padding}"
Background="Transparent"
CornerRadius="{TemplateBinding CornerRadius}">
<ContentPresenter
x:Name="ContentPresenter"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
FontSize="{TemplateBinding FontSize}"
FontWeight="{TemplateBinding FontWeight}"
Foreground="{TemplateBinding Foreground}" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CheckStates">
<VisualState x:Name="Checked">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource ControlFillColorDefaultBrush}" />
<Setter Target="RootGrid.BorderBrush" Value="{ThemeResource ControlStrokeColorDefaultBrush}" />
<Setter Target="RootGrid.BorderThickness" Value="1" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Unchecked" />
<VisualState x:Name="Indeterminate" />
</VisualStateGroup>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource ControlFillColorSecondaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource ControlFillColorTertiaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Target="ContentPresenter.Foreground" Value="{ThemeResource TextFillColorDisabledBrush}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
</Application.Resources>

Expand Down
44 changes: 34 additions & 10 deletions src/platforms/Riverside.Elapsed.App/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Riverside.Elapsed.App.Services.Recording;
using Riverside.Elapsed.App.Services.Upload;
using Riverside.Elapsed.App.ViewModels;
using Uno.Resizetizer;

Expand All @@ -11,8 +13,7 @@ public App()
InitializeComponent();
}

protected Window? MainWindow { get; private set; }
protected IHost? Host { get; private set; }
public static Window? MainWindow { get; private set; }

[SuppressMessage("Trimming", "IL2026", Justification = "Uno app builder usage is trim-safe for configured features.")]
protected override async void OnLaunched(LaunchActivatedEventArgs args)
Expand All @@ -32,10 +33,11 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args)
.UseSerilog(consoleLoggingEnabled: true, fileLoggingEnabled: true)
.UseConfiguration(configure: (IConfigBuilder configBuilder) => configBuilder
.EmbeddedSource<App>()
.Section<AppConfig>())
// .Section<AppConfig>()
)
.UseLocalization()
.ConfigureServices((context, services) => { })
.UseNavigation(RegisterRoutes)
//.UseNavigation(RegisterRoutes)
.UseSerialization(serialization => serialization.AddSingleton(Constants.SerializerOptions))
);

Expand All @@ -46,20 +48,42 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args)
#endif
MainWindow.SetWindowIcon();

Host = await builder.NavigateAsync<Shell>(initialNavigate: async (services, navigator) =>
ICaptureSourceProvider sourceProvider;

if (OperatingSystem.IsWindows())
sourceProvider = new WindowsCaptureSourceProvider();
else if (OperatingSystem.IsMacOS())
sourceProvider = new MacCaptureSourceProvider();
else if (OperatingSystem.IsLinux())
sourceProvider = new LinuxCaptureSourceProvider();
else
sourceProvider = new NoOpCaptureSourceProvider();

IRecordingFacade recording = new TimelapseRecordingFacade(sourceProvider);
/*
IRecordingFacade recording = new NoOpRecordingFacade();
ICaptureSourceProvider sourceProvider = new NoOpCaptureSourceProvider();
*/

var lapse = new LapseService();

MainWindow.Content = new RecordingPage
{
//var authService = services.GetRequiredService<ILapseAuthService>();
//await authService.TryRestoreSessionAsync();
await navigator.NavigateViewModelAsync<MainViewModel>(this, qualifier: Qualifiers.Nested);
});
//await navigator.NavigateViewModelAsync<MainViewModel>(this, qualifier: Qualifiers.Nested);
DataContext = new RecordingViewModel(recording, sourceProvider, lapse)
};
MainWindow.Activate();
}

/*
private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes)
{
views.Register(
new ViewMap<Shell, ShellViewModel>(),
//new ViewMap<LoginPage, LoginViewModel>(),
new ViewMap<MainPage, MainViewModel>()
//new ViewMap<MainPage, MainViewModel>()
//new ViewMap<VideoPage, PlayerViewModel>(),
//new ViewMap<RecordingPage, RecordingViewModel>(),
//new ViewMap<UserProfilePage, UserProfileViewModel>()
Expand All @@ -72,12 +96,12 @@ private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes)
Nested:
[
//new RouteMap("Login", View: views.FindByViewModel<LoginViewModel>()),
new RouteMap("Main", View: views.FindByViewModel<MainViewModel>(), IsDefault: true),
//new RouteMap("Main", View: views.FindByViewModel<MainViewModel>(), IsDefault: true),
//new RouteMap("Video", View: views.FindByViewModel<PlayerViewModel>()),
//new RouteMap("Recording", View: views.FindByViewModel<RecordingViewModel>()),
//new RouteMap("UserProfile", View: views.FindByViewModel<UserProfileViewModel>()),
]
)
);
}
} */
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using CommunityToolkit.WinUI.Converters;

namespace Riverside.Elapsed.App.Converters;

public sealed partial class BoolToPausedRecordingLabelConverter : BoolToObjectConverter
{
public BoolToPausedRecordingLabelConverter()
{
TrueValue = "Paused";
FalseValue = "Recording";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using CommunityToolkit.WinUI.Converters;
using Microsoft.UI;

namespace Riverside.Elapsed.App.Converters;

public sealed class BoolToToggleColorConverter : BoolToObjectConverter
{
public BoolToToggleColorConverter()
{
TrueValue = new SolidColorBrush(Colors.DodgerBlue); ;
FalseValue = new SolidColorBrush(Colors.Coral);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Microsoft.UI.Xaml.Data;

namespace Riverside.Elapsed.App.Converters;

public sealed class NullToCollapsedConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
bool isNull = value is null or "";
if (parameter is "Invert")
isNull = !isNull;
return isNull ? Visibility.Collapsed : Visibility.Visible;
}

public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotSupportedException();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#if false
namespace Riverside.Elapsed.App.Models.Admin;

public sealed class AdminExport
{
public object Data { get; set; } = new();
}

#endif
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if false
using System.Text.Json;

namespace Riverside.Elapsed.App.Models.Admin;
Expand All @@ -10,3 +11,5 @@ public sealed class AdminListPage
public long Page { get; set; }
public long PageSize { get; set; }
}

#endif
Loading
Loading