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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ phase plan these entries follow.

### Added

- **The licences are readable from inside the app, not just present as files in the folder.**
"Licence and notices" on the Advanced page's Application section opens Offstream's own MIT
licence, the predecessor's copyright notice it carries, the bundled LGPL ffmpeg and its source
offer, TagLib#, and VB-CABLE's origin and donationware terms — with the running build's version
and commit at the top, so a bug report can name the build it came from. The text is embedded in
the executable rather than read from beside it: the MIT and LGPL notices are obliged to travel
with the software, and a loose file does not survive a zip unpacked selectively or a copy of just
the `.exe`. A test asserts the window still shows what `LICENSE` and `NOTICE` actually say, so the
two cannot drift.
- **Credit for the track details, beside the provider that supplies them.** Choosing Spotify or
Last.fm now says whose data is being written into the recordings — an attribution Spotify's
Developer Terms require and that the app had never carried anywhere. It is empty when no provider
is selected, because crediting a service the app is not calling would be a false statement.
- **A release pipeline, with the git tag as the only place a version number lives.** Pushing `v1.2.3`
builds, tests, publishes, signs and attaches a self-contained `win-x64` zip and its SHA-256 to a
GitHub release; the changelog becomes the release notes. Nothing in the repository records a
Expand Down
19 changes: 19 additions & 0 deletions docs/MODERNIZATION-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -841,8 +841,27 @@ error paths and the scope list.
- **No update mechanism of any kind in v1** (question 4). Not an updater, and not an in-app version check either: the installer registers the releases page with Windows (`AppUpdatesURL`) and that page is where a new version is announced. An in-app check is a later version's problem, and adding one now would be a network call, a settings toggle and a pair of resource strings spent on something the release notes already say.
- **Tag-driven GitHub Actions release pipeline** — `v*` tag → publish → package → attach installer and portable zip to a GitHub release.

**Status (2026-08-15).** Built and green: the tag-driven pipeline, the per-user installer, the pinned ffmpeg with its source archive, the inert signing step, and the in-app notices. **Not done, and not doable from here:** the clean-VM install → record → uninstall pass. It needs a virtual machine and a person at it, and no amount of CI substitutes — the runner installs nothing and uninstalls nothing. Until that pass happens, the installer is *known to compile and known to produce an executable*, which is a weaker claim than the exit criterion makes.

**Exit:** clean-VM install → record → uninstall, no leftovers; third-party notices complete and accurate for whatever actually shipped. *(The original exit criterion said install → record → **update** → uninstall. Question 4 removed the update leg rather than reinterpreting it: there is nothing in the app to exercise. What replaces it is the installer's own upgrade path — installing a newer build over an older one and finding one entry in Apps & Features, not two — which is what a stable `AppId` buys and the only update behaviour v1 has.)*

**Phase 8 findings (2026-08-14 / 15):**

- **The tag is the only place a version is written down.** `Directory.Build.props` carries a `VersionPrefix` and a `VersionSuffix` of `dev`; a tagged build passes `-p:Version=1.2.3`, which overrides both. The alternative — a `<Version>` bumped in lockstep with the tag — is a file that eventually is not bumped, producing a build that claims a version nobody released. Worth knowing about the override: `-p:Version` beats *both* properties, so the `dev` suffix is dropped for tagged builds without a condition anywhere in the pipeline. Verified by building: no arguments gives `0.1.0-dev`, and `-p:Version=1.2.3-rc.1` gives `AssemblyVersion 1.2.3.0` with `InformationalVersion 1.2.3-rc.1+<sha>`.
- **Releasing is two steps, and deliberately not one.** The changelog section for the version is closed in an ordinary pull request; only then is the tag pushed. The workflow re-checks that the section exists **before it builds**, so the failure arrives in thirty seconds rather than after a full publish.
- **The release notes originally fell back to `[Unreleased]` when the version's section was missing.** That works exactly once and then republishes the previous release's entries forever. There is no fallback now — a missing section fails the run.
- **`$array -notmatch $pattern` is not `-not ($array -match $pattern)`.** It returns the elements that failed to match, which on any real changelog is a long, non-empty, entirely truthy array. The changelog check was written that way and would have thrown on every tag, including correct ones. Caught by extracting the script and running it both ways before merging, which is the only reason it was caught at all.
- **Only `ffmpeg.exe` is bundled, not `ffprobe.exe`.** `FFmpegLocator` resolves a probe path and one test asserts it; nothing at runtime ever runs it. Shipping just the encoder saves 108 MB. The build is pinned by digest in `build/windows/ffmpeg.json`, and `fetch-ffmpeg.ps1` **throws** on a fresh download whose digest does not match rather than updating the pin — the message says so, because the tempting fix is the wrong one.
- **The pinned build is genuinely LGPL, checked rather than assumed.** Its configure line has `--enable-version3` and no `--enable-gpl`, which makes it LGPL-3.0-or-later. `NOTICE` had implied LGPL-2.1 and claimed "no GPL build is bundled" at a time when nothing was bundled at all. Distributing an LGPL binary obliges distributing its source: every release **attaches** the source archive rather than offering to supply it, because a written offer has to outlive whatever was going to host it.
- **The installer is compiled in CI, not only at tag time.** A script that runs once per release is a script that breaks once per release. CI stages the existing publish output with a stand-in `ffmpeg/ffmpeg.exe` and compiles the real `.iss`.
- **Inno Setup 6.7.1 is preinstalled on `windows-latest`; the Windows SDK is not.** `iscc.exe` needs no install step; `signtool.exe` is not on `PATH` and has to be found under `Windows Kits\10\bin`. The signing script was exercised as far as this machine allows — it locates a certificate from the environment and refuses to proceed silently — but has **never signed anything**, because there is no SDK here and no certificate yet.
- **`PrivilegesRequired=lowest` moves `{autopf}` to `%LOCALAPPDATA%\Programs`,** which is what makes the per-user install admin-free. `AppMutex` is `Local\Offstream`, the same string as `OffstreamPaths.InstanceMutex`, so an install over a running copy asks rather than fails. `AppId` must never be regenerated: a new one turns an upgrade into a second, parallel installation.
- **Uninstall offers to remove `%APPDATA%\Offstream` and defaults to No,** and says plainly that recordings live elsewhere and are never touched. The one thing an uninstaller must not do is delete the output of the app.
- **The licence text is embedded in the executable rather than read from beside it.** Both obligations here — the predecessor's MIT notice, and the bundled LGPL ffmpeg — require the notice to travel with the software, and a loose file does not survive a zip unpacked selectively or a copy of just the `.exe`. `LICENSE` and `NOTICE` are `EmbeddedResource` items in `Offstream.App.csproj`, and a test asserts the shown text still contains both files' contents, so the window and the repository cannot drift.
- **Attribution and licensing are two different obligations and live in two different places.** Spotify's Developer Terms want content credited; that line belongs **beside the provider picker on Settings**, where it is obvious what it refers to, and it is empty when no provider is selected because crediting a service the app is not calling is a false statement. Licences belong in the notices window. Folding the first into the second would have satisfied neither well.
- **The notices entry point sits on the section header, and the first attempt was wrong.** A version line and a button under the ffmpeg path pushed both off the bottom of the window at its minimum size — the shell's `MinHeight` is a written promise that no setting is ever unreachable without scrolling, and this quietly broke it. Raising the floor was not available either: at 150% scaling the window is already 1050 physical pixels tall. The header line is the one place in a full card with room that costs nothing vertically. **Anything added to the Advanced page from here needs the same arithmetic done first.**
- **`System.IO` is imported explicitly in `Services/ThirdPartyNotices.cs`.** WPF compiles a throwaway project first to resolve XAML type references, and that pass does not inherit `ImplicitUsings` — so a type reached through them fails to compile there while compiling fine in the real assembly. The error names a `_wpftmp.csproj` that does not exist on disk.

### Phase 9 — Hardening (5+ days, ongoing)
- 12-hour soak; memory and handle profiling.
- Fault injection: device removed mid-recording, disk full, ffmpeg killed, Spotify closed, sleep/resume.
Expand Down
17 changes: 17 additions & 0 deletions src/Offstream.App/Offstream.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@
<Resource Include="Assets\*.ico" />
</ItemGroup>

<!--
The licence text ships inside the executable, not beside it. Offstream carries the
predecessor's MIT copyright notice and bundles an LGPL ffmpeg, and both licences oblige the
notice to travel with the software - so it cannot depend on a loose file surviving a zip
unpacked selectively, a copy of just the .exe, or a run out of bin\. LogicalName is set
explicitly because these sit above the project directory and the default name derived from
their path would not be a stable identifier.

Link makes them visible in the IDE without a second copy on disk; the files at the
repository root stay the only originals, which is what keeps the window and the repository
from ever disagreeing.
-->
<ItemGroup>
<EmbeddedResource Include="..\..\LICENSE" LogicalName="Offstream.App.LICENSE" Link="LICENSE" />
<EmbeddedResource Include="..\..\NOTICE" LogicalName="Offstream.App.NOTICE" Link="NOTICE" />
</ItemGroup>

<!--
Generates a public `Offstream.App.Resources.Strings` class from the neutral .resx, so XAML
can reach a key through `{x:Static res:Strings.NavRecord}` and a typo fails the build
Expand Down
15 changes: 15 additions & 0 deletions src/Offstream.App/Resources/Strings.fr.resx
Original file line number Diff line number Diff line change
Expand Up @@ -457,4 +457,19 @@
<data name="NavLogs" xml:space="preserve">
<value>Journaux</value>
</data>
<data name="SettingsAttributionSpotify" xml:space="preserve">
<value>Les détails des pistes et les pochettes sont fournis par Spotify. Spotify est une marque de Spotify AB.</value>
</data>
<data name="SettingsAttributionLastFm" xml:space="preserve">
<value>Les détails des pistes et les pochettes sont fournis par Last.fm.</value>
</data>
<data name="AdvancedNoticesButton" xml:space="preserve">
<value>Licence et mentions</value>
</data>
<data name="NoticesTitle" xml:space="preserve">
<value>Licence et mentions de tiers</value>
</data>
<data name="NoticesClose" xml:space="preserve">
<value>Fermer</value>
</data>
</root>
20 changes: 20 additions & 0 deletions src/Offstream.App/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -593,4 +593,24 @@
<value>Logs</value>
<comment>Fourth tab in the shell, after Advanced.</comment>
</data>
<data name="SettingsAttributionSpotify" xml:space="preserve">
<value>Track details and cover art are provided by Spotify. Spotify is a trademark of Spotify AB.</value>
<comment>Attribution shown under the metadata provider picker when Spotify is selected. Required by Spotify's Developer Terms; do not drop it or move it away from the provider it credits.</comment>
</data>
<data name="SettingsAttributionLastFm" xml:space="preserve">
<value>Track details and cover art are provided by Last.fm.</value>
<comment>Attribution shown under the metadata provider picker when Last.fm is selected.</comment>
</data>
<data name="AdvancedNoticesButton" xml:space="preserve">
<value>Licence and notices</value>
<comment>Button that opens the licence and third-party notices window.</comment>
</data>
<data name="NoticesTitle" xml:space="preserve">
<value>Licence and third-party notices</value>
<comment>Title of the window showing LICENSE and NOTICE, both embedded in the executable.</comment>
</data>
<data name="NoticesClose" xml:space="preserve">
<value>Close</value>
<comment>Button that dismisses the licence and notices window.</comment>
</data>
</root>
105 changes: 105 additions & 0 deletions src/Offstream.App/Services/ThirdPartyNotices.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// System.IO explicitly: the throwaway project WPF builds first to resolve XAML type references
// does not inherit ImplicitUsings, so a type this file reaches through them fails to compile
// there while compiling fine in the real assembly.
using System.IO;
using System.Reflection;
using System.Text;

namespace Offstream.App.Services;

/// <summary>
/// The licence and attribution text the app shows, and the version string that identifies the
/// build showing it.
/// </summary>
/// <remarks>
/// <para>
/// <c>LICENSE</c> and <c>NOTICE</c> are embedded into the executable rather than read from a
/// path beside it. Both are obligations — the MIT licence Offstream inherits from the
/// predecessor requires its copyright notice travel with the software, and the bundled ffmpeg
/// is LGPL — and a file read from disk is an obligation that a portable zip unpacked
/// selectively, or a run from <c>bin\Debug</c>, quietly fails to meet. Embedded, the text is
/// present wherever the executable is, and is the same text the repository holds because it is
/// literally that file.
/// </para>
/// <para>
/// This is App-layer, not Core: it exists to fill a window.
/// </para>
/// </remarks>
public static class ThirdPartyNotices
{
private const string LicenseResource = "Offstream.App.LICENSE";
private const string NoticeResource = "Offstream.App.NOTICE";

/// <summary>Enough of a commit hash to identify a build, and no more.</summary>
private const int ShortHashLength = 7;

private static readonly Lazy<string> LazyText = new(BuildText);
private static readonly Lazy<string> LazyVersion = new(BuildVersion);

/// <summary>
/// The full notices document: Offstream's own licence, then everything it is built on.
/// </summary>
public static string Text => LazyText.Value;

/// <summary>
/// The running build, as <c>1.2.3</c> or <c>0.1.0-dev+abc1234</c>.
/// </summary>
/// <remarks>
/// From <see cref="AssemblyInformationalVersionAttribute"/>, which is the only version that
/// carries the prerelease suffix — <c>AssemblyVersion</c> is numeric-only and reports
/// <c>1.2.3.0</c> for every prerelease of 1.2.3. The SDK appends the full 40-character
/// commit hash; a bug report needs enough of it to find the commit, so it is cut to seven
/// rather than shown whole or dropped.
/// </remarks>
public static string Version => LazyVersion.Value;

private static string BuildText()
{
var license = ReadResource(LicenseResource);
var notice = ReadResource(NoticeResource);

// NOTICE opens by pointing at LICENSE for the full text, so LICENSE comes first: the
// reader meets the reference after the thing it refers to.
return license.TrimEnd() + Environment.NewLine + Environment.NewLine + notice.TrimEnd();
}

private static string BuildVersion()
{
var assembly = typeof(ThirdPartyNotices).Assembly;

var informational = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;

if (string.IsNullOrWhiteSpace(informational))
{
// Only reachable if the attribute is stripped; AssemblyVersion always exists.
return assembly.GetName().Version?.ToString() ?? "0.0.0";
}

var plus = informational.IndexOf('+', StringComparison.Ordinal);
if (plus < 0)
{
return informational;
}

var hash = informational[(plus + 1)..];
return hash.Length <= ShortHashLength
? informational
: string.Concat(informational.AsSpan(0, plus + 1), hash.AsSpan(0, ShortHashLength));
}

private static string ReadResource(string name)
{
var assembly = typeof(ThirdPartyNotices).Assembly;

using var stream = assembly.GetManifestResourceStream(name)
?? throw new InvalidOperationException(
$"'{name}' is not embedded in {assembly.GetName().Name}. It is added by an " +
"EmbeddedResource item in Offstream.App.csproj; distributing the app without " +
"it would drop a licence notice the app is obliged to carry.");

using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd();
}
}
28 changes: 28 additions & 0 deletions src/Offstream.App/ViewModels/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ public sealed partial class SettingsViewModel : ObservableValidator
[NotifyPropertyChangedFor(nameof(IsSpotifyProvider))]
[NotifyPropertyChangedFor(nameof(IsLastFmProvider))]
[NotifyPropertyChangedFor(nameof(ProviderSummary))]
[NotifyPropertyChangedFor(nameof(ProviderAttribution))]
[NotifyPropertyChangedFor(nameof(HasProviderAttribution))]
[NotifyPropertyChangedFor(nameof(NeedsLastFmApiKey))]
[NotifyCanExecuteChangedFor(nameof(SignInToSpotifyCommand))]
private MetadataProvider _provider;
Expand Down Expand Up @@ -237,6 +239,32 @@ .. EncodingProfiles.Known
_ => Strings.SettingsProviderSummaryNone,
};

/// <summary>
/// Who the track details belong to, credited beside the provider that supplies them.
/// </summary>
/// <remarks>
/// <para>
/// An obligation, not a courtesy. Spotify's Developer Terms require content taken from the
/// Web API be attributed to Spotify, and Last.fm asks the same of its API. Both are met by
/// naming the source where the user chooses it — which is the only place in the app where it
/// is obvious what the line refers to, and why this is not folded into the third-party
/// notices window with the licences.
/// </para>
/// <para>
/// Empty when nothing is selected: with no provider there is no content to credit, and a
/// standing attribution to a service the app is not calling would be a false one.
/// </para>
/// </remarks>
public string ProviderAttribution => Provider switch
{
MetadataProvider.Spotify => Strings.SettingsAttributionSpotify,
MetadataProvider.LastFm => Strings.SettingsAttributionLastFm,
_ => string.Empty,
};

/// <summary>Whether <see cref="ProviderAttribution"/> has anything to show.</summary>
public bool HasProviderAttribution => ProviderAttribution.Length > 0;

/// <summary>
/// Last.fm is chosen but has no key, so nothing will be tagged.
/// </summary>
Expand Down
Loading
Loading