Show unseen Server commits after login.
Bake first-parent git log at Server build and remember the SHA in a browser cookie. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,6 +21,8 @@ const ru = {
|
||||
sessionInvalidName: 'Имя должно быть от 1 до 40 символов.',
|
||||
sessionFailed: 'Не удалось войти. Попробуйте ещё раз.',
|
||||
sessionLogout: 'Выйти',
|
||||
changelogTitle: 'Что нового',
|
||||
changelogOk: 'Понятно',
|
||||
|
||||
schoolsTitle: 'Школы',
|
||||
schoolsMine: 'Мои',
|
||||
@@ -392,6 +394,8 @@ const en: Messages = {
|
||||
sessionInvalidName: 'The name must be 1–40 characters.',
|
||||
sessionFailed: 'Could not sign in. Try again.',
|
||||
sessionLogout: 'Sign out',
|
||||
changelogTitle: "What's new",
|
||||
changelogOk: 'OK',
|
||||
|
||||
schoolsTitle: 'Schools',
|
||||
schoolsMine: 'Mine',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GameScreen } from './ui/gameScreen.ts';
|
||||
import { localeSwitch } from './ui/localeSwitch.ts';
|
||||
import { MainMenu } from './ui/mainMenu.ts';
|
||||
import { ensureSession } from './ui/sessionGate.ts';
|
||||
import { showWhatsNew } from './ui/whatsNew.ts';
|
||||
import type { School } from './net/api.ts';
|
||||
import './style.css';
|
||||
|
||||
@@ -26,6 +27,7 @@ async function bootstrap(): Promise<void> {
|
||||
footer.prepend(localeSwitch());
|
||||
|
||||
await ensureSession();
|
||||
await showWhatsNew();
|
||||
|
||||
let openSchool: School | null = null;
|
||||
let connectionStatus: ConnectionStatus = 'connecting';
|
||||
@@ -109,6 +111,7 @@ async function bootstrap(): Promise<void> {
|
||||
menu.stop();
|
||||
await logoutSession();
|
||||
await ensureSession();
|
||||
await showWhatsNew();
|
||||
connection.connect();
|
||||
showMenu();
|
||||
}
|
||||
|
||||
@@ -476,6 +476,24 @@ export async function logoutSession(): Promise<void> {
|
||||
await request<void>('/api/session', { method: 'DELETE' }, { expectBody: false });
|
||||
}
|
||||
|
||||
export interface ChangelogCommit {
|
||||
readonly sha: string;
|
||||
readonly date: string;
|
||||
readonly subject: string;
|
||||
}
|
||||
|
||||
export interface ChangelogResponse {
|
||||
readonly current: string;
|
||||
readonly commits: readonly ChangelogCommit[];
|
||||
}
|
||||
|
||||
export async function fetchChangelog(since?: string | null): Promise<ChangelogResponse> {
|
||||
const query = since !== null && since !== undefined && since.length > 0
|
||||
? `?since=${encodeURIComponent(since)}`
|
||||
: '';
|
||||
return request<ChangelogResponse>(`/api/changelog${query}`);
|
||||
}
|
||||
|
||||
export interface SwarmUiKindPreset {
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
@@ -1425,6 +1425,39 @@ body {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dialog--changelog {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.changelog__list {
|
||||
max-height: min(50dvh, 360px);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.changelog__item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.changelog__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.changelog__date {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.changelog__subject {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { SEEN_REVISION_COOKIE, readSeenRevision, writeSeenRevision } from './seenRevision.ts';
|
||||
|
||||
const current = '0123456789abcdef0123456789abcdef01234567';
|
||||
|
||||
describe('seen revision cookie', () => {
|
||||
afterEach(() => {
|
||||
document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`;
|
||||
});
|
||||
|
||||
it('round-trips a 40-character SHA', () => {
|
||||
writeSeenRevision(current);
|
||||
expect(readSeenRevision()).toBe(current);
|
||||
});
|
||||
|
||||
it('ignores a value that is not a SHA', () => {
|
||||
writeSeenRevision('not-a-sha');
|
||||
expect(readSeenRevision()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Last Server build SHA this browser already showed. Not the HttpOnly session cookie. */
|
||||
|
||||
export const SEEN_REVISION_COOKIE = 'hschool.seen-rev';
|
||||
|
||||
const SHA = /^[0-9a-f]{40}$/i;
|
||||
const MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
||||
|
||||
export function readSeenRevision(): string | null {
|
||||
const prefix = `${SEEN_REVISION_COOKIE}=`;
|
||||
const parts = document.cookie.split(';');
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = decodeURIComponent(trimmed.slice(prefix.length));
|
||||
return SHA.test(value) ? value.toLowerCase() : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeSeenRevision(sha: string): void {
|
||||
if (!SHA.test(sha)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pieces = [
|
||||
`${SEEN_REVISION_COOKIE}=${encodeURIComponent(sha.toLowerCase())}`,
|
||||
'Path=/',
|
||||
'SameSite=Lax',
|
||||
`Max-Age=${MAX_AGE_SECONDS}`,
|
||||
];
|
||||
if (globalThis.location?.protocol === 'https:') {
|
||||
pieces.push('Secure');
|
||||
}
|
||||
|
||||
document.cookie = pieces.join('; ');
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchChangelog } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { SEEN_REVISION_COOKIE, readSeenRevision, writeSeenRevision } from './seenRevision.ts';
|
||||
import { showWhatsNew } from './whatsNew.ts';
|
||||
|
||||
vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../net/api.ts')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchChangelog: vi.fn(),
|
||||
fetchSession: vi.fn(),
|
||||
fetchSchools: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const current = '0123456789abcdef0123456789abcdef01234567';
|
||||
const previous = '89abcdef0123456789abcdef0123456789abcdef';
|
||||
|
||||
describe('whats-new after login', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.querySelector('dialog')?.remove();
|
||||
document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`;
|
||||
});
|
||||
|
||||
it('stores current without a dialog when there is no cookie', async () => {
|
||||
vi.mocked(fetchChangelog).mockResolvedValue({ current, commits: [] });
|
||||
|
||||
await showWhatsNew();
|
||||
|
||||
expect(fetchChangelog).toHaveBeenCalledWith(null);
|
||||
expect(readSeenRevision()).toBe(current);
|
||||
expect(document.querySelector('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not open a dialog when the cookie already matches current', async () => {
|
||||
writeSeenRevision(current);
|
||||
vi.mocked(fetchChangelog).mockResolvedValue({ current, commits: [] });
|
||||
|
||||
await showWhatsNew();
|
||||
|
||||
expect(fetchChangelog).toHaveBeenCalledWith(current);
|
||||
expect(document.querySelector('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the dialog for unseen commits and writes current on dismiss', async () => {
|
||||
writeSeenRevision(previous);
|
||||
vi.mocked(fetchChangelog).mockResolvedValue({
|
||||
current,
|
||||
commits: [
|
||||
{
|
||||
sha: current,
|
||||
date: '2026-08-20T08:21:00+00:00',
|
||||
subject: "Merge branch 'phase/53-weather-commute'",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const pending = showWhatsNew();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const dialog = document.querySelector('dialog');
|
||||
expect(dialog?.textContent).toContain(t('changelogTitle'));
|
||||
expect(dialog?.textContent).toContain("Merge branch 'phase/53-weather-commute'");
|
||||
|
||||
const ok = dialog?.querySelector('button');
|
||||
ok?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await pending;
|
||||
|
||||
expect(readSeenRevision()).toBe(current);
|
||||
expect(document.querySelector('dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { fetchChangelog, type ChangelogCommit } from '../net/api.ts';
|
||||
import { intlTag } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import { Modal } from './modal.ts';
|
||||
import { readSeenRevision, writeSeenRevision } from './seenRevision.ts';
|
||||
|
||||
/**
|
||||
* After login: first visit stores the baked SHA; a newer build opens the changelog once.
|
||||
* Returning from a school to the menu does not call this.
|
||||
*/
|
||||
export async function showWhatsNew(): Promise<void> {
|
||||
try {
|
||||
const changelog = await fetchChangelog(readSeenRevision());
|
||||
if (!/^[0-9a-f]{40}$/i.test(changelog.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (changelog.commits.length > 0) {
|
||||
await whatsNewDialog(changelog.commits);
|
||||
}
|
||||
|
||||
writeSeenRevision(changelog.current);
|
||||
} catch {
|
||||
// Menu still works if the host skipped the embed or the request failed.
|
||||
}
|
||||
}
|
||||
|
||||
function whatsNewDialog(commits: readonly ChangelogCommit[]): Promise<void> {
|
||||
const modal = new Modal(undefined);
|
||||
const ok = el('button', {
|
||||
class: 'button button--primary',
|
||||
type: 'button',
|
||||
text: t('changelogOk'),
|
||||
onClick: () => modal.close(undefined),
|
||||
});
|
||||
|
||||
const items = commits.map((commit) => {
|
||||
const item = el('li', { class: 'changelog__item' });
|
||||
const date = formatCommitDate(commit.date);
|
||||
if (date !== null) {
|
||||
item.append(el('time', { class: 'changelog__date', text: date }));
|
||||
}
|
||||
|
||||
item.append(el('span', { class: 'changelog__subject', text: commit.subject }));
|
||||
return item;
|
||||
});
|
||||
|
||||
modal.element.classList.add('dialog--changelog');
|
||||
modal.element.append(
|
||||
el('h2', { class: 'dialog__title', text: t('changelogTitle') }),
|
||||
el('ul', { class: 'changelog__list' }, ...items),
|
||||
el('div', { class: 'dialog__actions' }, ok),
|
||||
);
|
||||
|
||||
return modal.open(ok);
|
||||
}
|
||||
|
||||
function formatCommitDate(iso: string): string | null {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(intlTag(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using HSchool.Server.Changelog;
|
||||
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
internal static class ChangelogEndpoints
|
||||
{
|
||||
public static void MapChangelogEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("/api/changelog", GetAsync);
|
||||
}
|
||||
|
||||
private static IResult GetAsync(string? since, BuildChangelog changelog)
|
||||
{
|
||||
var commits = changelog.VisibleSince(since)
|
||||
.Select(commit => new ChangelogCommitResponse(
|
||||
commit.Sha,
|
||||
commit.Date.ToUniversalTime(),
|
||||
commit.Subject))
|
||||
.ToArray();
|
||||
|
||||
return Results.Json(new ChangelogResponse(changelog.CurrentSha, commits));
|
||||
}
|
||||
|
||||
private sealed record ChangelogResponse(string Current, IReadOnlyList<ChangelogCommitResponse> Commits);
|
||||
|
||||
private sealed record ChangelogCommitResponse(string Sha, DateTimeOffset Date, string Subject);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace HSchool.Server.Changelog;
|
||||
|
||||
internal sealed record ChangelogCommit(string Sha, DateTimeOffset Date, string Subject);
|
||||
|
||||
/// <summary>First-parent git log baked into the Server assembly at compile time.</summary>
|
||||
internal sealed partial class BuildChangelog
|
||||
{
|
||||
internal const string ResourceName = "HSchool.Server.changelog.tsv";
|
||||
internal const int MaxVisibleCommits = 100;
|
||||
|
||||
private static readonly UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
public BuildChangelog(string currentSha, IReadOnlyList<ChangelogCommit> commits)
|
||||
{
|
||||
CurrentSha = currentSha;
|
||||
Commits = commits;
|
||||
}
|
||||
|
||||
public string CurrentSha { get; }
|
||||
|
||||
/// <summary>Newest first, including <c>Mark phase</c> commits used as slice anchors.</summary>
|
||||
public IReadOnlyList<ChangelogCommit> Commits { get; }
|
||||
|
||||
public static BuildChangelog LoadEmbedded()
|
||||
{
|
||||
var assembly = typeof(BuildChangelog).Assembly;
|
||||
using var stream = assembly.GetManifestResourceStream(ResourceName);
|
||||
if (stream is null)
|
||||
{
|
||||
return new BuildChangelog("", []);
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(stream, Utf8);
|
||||
return Parse(reader.ReadToEnd());
|
||||
}
|
||||
|
||||
public static BuildChangelog Parse(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return new BuildChangelog("", []);
|
||||
}
|
||||
|
||||
var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
if (lines.Length == 0)
|
||||
{
|
||||
return new BuildChangelog("", []);
|
||||
}
|
||||
|
||||
var current = IsSha(lines[0]) ? lines[0].ToLowerInvariant() : "";
|
||||
var commits = new List<ChangelogCommit>();
|
||||
foreach (var line in lines.Skip(1))
|
||||
{
|
||||
if (TryParseCommit(line, out var commit))
|
||||
{
|
||||
commits.Add(commit);
|
||||
}
|
||||
}
|
||||
|
||||
return new BuildChangelog(current, commits);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChangelogCommit> VisibleSince(string? since)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(since)
|
||||
|| string.Equals(since, CurrentSha, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var newer = new List<ChangelogCommit>();
|
||||
var found = false;
|
||||
foreach (var commit in Commits)
|
||||
{
|
||||
if (string.Equals(commit.Sha, since, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
newer.Add(commit);
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
newer.Reverse();
|
||||
var visible = new List<ChangelogCommit>();
|
||||
foreach (var commit in newer)
|
||||
{
|
||||
if (IsMarkPhase(commit.Subject))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
visible.Add(commit);
|
||||
if (visible.Count >= MaxVisibleCommits)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
internal static bool IsMarkPhase(string subject) => MarkPhasePattern().IsMatch(subject);
|
||||
|
||||
private static bool TryParseCommit(string line, out ChangelogCommit commit)
|
||||
{
|
||||
commit = null!;
|
||||
var parts = line.Split('\x1f');
|
||||
if (parts.Length < 3 || !IsSha(parts[0]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTimeOffset.TryParse(parts[1], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var subject = string.Join('\x1f', parts.Skip(2)).Trim();
|
||||
if (subject.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
commit = new ChangelogCommit(parts[0].ToLowerInvariant(), date, subject);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsSha(string value) => ShaPattern().IsMatch(value);
|
||||
|
||||
[GeneratedRegex(@"^[0-9a-fA-F]{40}$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ShaPattern();
|
||||
|
||||
[GeneratedRegex(@"^Mark phase \d+", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex MarkPhasePattern();
|
||||
}
|
||||
@@ -32,4 +32,26 @@
|
||||
<InternalsVisibleTo Include="HSchool.Server.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<HSchoolRepoRoot>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..'))</HSchoolRepoRoot>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Bake git HEAD + first-parent log; the running process does not call git. -->
|
||||
<Target Name="EmbedGitChangelog" BeforeTargets="CreateManifestResourceNames" Condition="'$(DesignTimeBuild)' != 'true'">
|
||||
<MakeDir Directories="$(IntermediateOutputPath)" />
|
||||
<PropertyGroup>
|
||||
<_ChangelogTsv>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\$(IntermediateOutputPath)changelog.tsv'))</_ChangelogTsv>
|
||||
</PropertyGroup>
|
||||
<Exec Condition="$([MSBuild]::IsOSPlatform('Windows'))" Command="powershell -NoProfile -ExecutionPolicy Bypass -File "$(HSchoolRepoRoot)\tools\embed-changelog.ps1" -RepoRoot "$(HSchoolRepoRoot)" -Output "$(_ChangelogTsv)"" />
|
||||
<Exec Condition="!$([MSBuild]::IsOSPlatform('Windows'))" Command="sh "$(HSchoolRepoRoot)/tools/embed-changelog.sh" "$(HSchoolRepoRoot)" "$(_ChangelogTsv)"" />
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="$(_ChangelogTsv)" Condition="Exists('$(_ChangelogTsv)')">
|
||||
<LogicalName>HSchool.Server.changelog.tsv</LogicalName>
|
||||
<Type>Non-Resx</Type>
|
||||
<WithCulture>false</WithCulture>
|
||||
</EmbeddedResource>
|
||||
<FileWrites Include="$(_ChangelogTsv)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.WebSockets;
|
||||
using HSchool.Server;
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Server.Changelog;
|
||||
using HSchool.Server.Game;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Server.Session;
|
||||
@@ -42,6 +43,7 @@ builder.Services.AddSingleton<GameCommandQueue>();
|
||||
builder.Services.AddSingleton<ClientRegistry>();
|
||||
builder.Services.AddSingleton<UserStore>();
|
||||
builder.Services.AddSingleton<SessionService>();
|
||||
builder.Services.AddSingleton(_ => BuildChangelog.LoadEmbedded());
|
||||
builder.Services.AddSingleton<GameMetrics>();
|
||||
builder.Services.AddSingleton<SchoolStore>();
|
||||
builder.Services.AddSingleton<ModContent>();
|
||||
@@ -79,6 +81,7 @@ app.UseWebSockets(new WebSocketOptions
|
||||
app.UseMiddleware<SessionAuthMiddleware>();
|
||||
|
||||
app.MapSessionEndpoints();
|
||||
app.MapChangelogEndpoints();
|
||||
app.MapSchoolEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapTimetableEndpoints();
|
||||
|
||||
Reference in New Issue
Block a user