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
1 change: 0 additions & 1 deletion .gitattributes

This file was deleted.

18 changes: 6 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,23 +55,17 @@ jobs:
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- run: dotnet publish BiStudent/BiStudent.csproj -c Release -r win-x64 --self-contained true -o out/Client
- run: dotnet publish BiStudent.Server/BiStudent.Server.csproj -c Release -r win-x64 --self-contained true -o out/Server
- name: Build installer
- name: Package
shell: pwsh
run: |
choco install innosetup -y --no-progress
Copy-Item installer/BiStudent.cmd out/
& "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" /Q "/DSrc=$PWD\out" "/DOutDir=$PWD\dist" "/DAppVersion=1.0.${{ github.run_number }}" installer/BiStudent.iss
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
run: ./scripts/package.ps1
- uses: actions/upload-artifact@v5
with:
name: BiStudent-Setup
path: dist/BiStudent-Setup.exe
name: BiStudent-win-x64
path: dist/BiStudent-win-x64.zip
- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
run: >
gh release create "v1.0.${{ github.run_number }}" dist/BiStudent-Setup.exe
gh release create "v1.0.${{ github.run_number }}" dist/BiStudent-win-x64.zip
--target "${{ github.sha }}" --title "BiStudent 1.0.${{ github.run_number }}"
--notes "Установщик для Windows 10/11 x64: скачайте BiStudent-Setup.exe и запустите. Вход в демо-режиме: teacher / teacher123."
--notes "Windows 10/11 x64: скачайте BiStudent-win-x64.zip, распакуйте целиком и запустите BiStudent.exe. Устанавливать ничего не нужно. На новом компьютере вход teacher / teacher123."
2 changes: 2 additions & 0 deletions BiStudent.Server/BiStudent.Server.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>BiStudent.Server</RootNamespace>
<!-- Обработчики minimal API генерируются при сборке, а не собираются при каждом запуске сервера. -->
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12">
Expand Down
4 changes: 3 additions & 1 deletion BiStudent/Api/ApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -479,5 +479,7 @@ private void RaiseUnauthorized()
else _uiContext.Post(_ => { if (!Volatile.Read(ref _ended)) Unauthorized?.Invoke(); }, null);
}

public void Dispose() { EndSession(); _tokenLock.Dispose(); _http.Dispose(); }
// _tokenLock не освобождаем: незавершённый запрос обновления токена ещё вызовет Release(),
// а SemaphoreSlim без AvailableWaitHandle не держит системных ресурсов.
public void Dispose() { EndSession(); _http.Dispose(); }
}
12 changes: 10 additions & 2 deletions BiStudent/Api/ClientSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,17 @@ public void StoreRefreshToken(string? token)
ProtectedData.Protect(Encoding.UTF8.GetBytes(token), TokenEntropy, DataProtectionScope.CurrentUser));
}

// Не сохранившиеся настройки (файл занят или недоступен) не должны ронять приложение посреди входа или занятия.
public void Save()
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions), Encoding.UTF8);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions), Encoding.UTF8);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
CrashGuard.Log(ex, "settings");
}
}
}
2 changes: 1 addition & 1 deletion BiStudent/BiStudent.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<NoWarn>$(NoWarn);CA1416;WFO1000</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenCvSharp4.Windows" Version="4.13.0.20260627" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.13.0.20260627" />
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.13.0.20260627" />
<PackageReference Include="QRCoder" Version="1.6.0" />
</ItemGroup>
Expand Down
180 changes: 180 additions & 0 deletions BiStudent/LocalServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
using System.Diagnostics;
using System.Net;
using System.Runtime.InteropServices;

namespace BiStudent;

internal static class LocalServer
{
public const string Url = "http://localhost:5080";

private static readonly HttpClient http = new() { Timeout = Timeout.InfiniteTimeSpan };
private static readonly object sync = new();
private static Task? launchTask;
private static Process? process;
private static IntPtr jobHandle;
private static bool launchFailed;

public static string? ExecutablePath
{
get
{
var path = Path.Combine(AppContext.BaseDirectory, "Server", "BiStudent.Server.exe");
return File.Exists(path) ? path : null;
}
}

public static bool IsBundled => ExecutablePath is not null;

public static void Start()
{
if (ExecutablePath is not { } executable) return;
lock (sync)
launchTask ??= Task.Run(() => LaunchAsync(executable));
}

public static async Task<bool> WaitUntilReadyAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
{
if (!IsBundled) return true;
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(timeout);
try
{
var task = launchTask;
if (task is not null) await task.WaitAsync(deadline.Token);
while (!deadline.IsCancellationRequested)
{
if (launchFailed || process is { HasExited: true }) return false;
if (await IsHealthyAsync(TimeSpan.FromSeconds(2), deadline.Token)) return true;
if (launchFailed || process is { HasExited: true }) return false;
await Task.Delay(400, deadline.Token);
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { }
return false;
}

private static async Task LaunchAsync(string executable)
{
if (await IsHealthyAsync(TimeSpan.FromSeconds(1), CancellationToken.None)) return;
Process? started = null;
try
{
started = new Process
{
StartInfo = new ProcessStartInfo(executable)
{
Arguments = "--urls http://*:5080",
WorkingDirectory = Path.GetDirectoryName(executable)!,
UseShellExecute = false,
CreateNoWindow = true
}
};
started.StartInfo.Environment["Demo__Enabled"] = "true";
if (!started.Start() || !AttachToJob(started))
throw new InvalidOperationException("Не удалось запустить сервер BiStudent.");
process = started;
}
catch
{
launchFailed = true;
if (started is not null)
{
try { if (!started.HasExited) started.Kill(entireProcessTree: true); }
catch { }
started.Dispose();
}
}
}

private static async Task<bool> IsHealthyAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
// Windows отклоняет подключение к закрытому порту только через ~2 с, поэтому сначала смотрим, слушает ли его кто-нибудь.
var port = new Uri(Url).Port;
if (!System.Net.NetworkInformation.IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners().Any(x => x.Port == port))
return false;
using var requestTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
requestTimeout.CancelAfter(timeout);
try
{
using var response = await http.GetAsync($"{Url}/health", HttpCompletionOption.ResponseHeadersRead, requestTimeout.Token);
return response.StatusCode == HttpStatusCode.OK;
}
catch (HttpRequestException) { return false; }
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return false; }
}

private static bool AttachToJob(Process started)
{
if (jobHandle != IntPtr.Zero) return true;
var job = IntPtr.Zero;
try
{
job = CreateJobObject(IntPtr.Zero, null);
if (job == IntPtr.Zero) return false;
var limits = new JobObjectExtendedLimitInformation
{
BasicLimitInformation = new JobObjectBasicLimitInformation { LimitFlags = 0x2000 }
};
if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf<JobObjectExtendedLimitInformation>())
|| !AssignProcessToJobObject(job, started.Handle)) return false;
jobHandle = job;
job = IntPtr.Zero;
return true;
}
catch { return false; }
finally { if (job != IntPtr.Zero) CloseHandle(job); }
}

[StructLayout(LayoutKind.Sequential)]
private struct JobObjectBasicLimitInformation
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize;
public UIntPtr MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}

[StructLayout(LayoutKind.Sequential)]
private struct IoCounters
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}

[StructLayout(LayoutKind.Sequential)]
private struct JobObjectExtendedLimitInformation
{
public JobObjectBasicLimitInformation BasicLimitInformation;
public IoCounters IoInfo;
public UIntPtr ProcessMemoryLimit;
public UIntPtr JobMemoryLimit;
public UIntPtr PeakProcessMemoryUsed;
public UIntPtr PeakJobMemoryUsed;
}

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr CreateJobObject(IntPtr securityAttributes, string? name);

[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject(IntPtr job, int informationClass,
ref JobObjectExtendedLimitInformation information, uint length);

[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);

[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
}
28 changes: 23 additions & 5 deletions BiStudent/LoginForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public LoginForm(string? initialError = null)
StartPosition = FormStartPosition.CenterScreen; AutoScaleMode = AutoScaleMode.Dpi;
BackColor = Theme.Background; ForeColor = Theme.Text; Font = Theme.Font(10);
Build();
server.Text = settings.ServerUrl;
server.Text = LocalServer.IsBundled ? LocalServer.Url : settings.ServerUrl;
login.Text = settings.LastLogin;
remember.Checked = settings.RememberMe;
error.Text = initialError ?? "";
Expand All @@ -36,14 +36,18 @@ private void Build()
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 54)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 46));
var left = new SmoothPanel { Dock = DockStyle.Fill, Margin = new Padding(36, 28, 16, 28), Padding = new Padding(28) };
var form = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 11, BackColor = Theme.Surface };
foreach (var height in new[] { 48, 45, 22, 38, 22, 38, 22, 38, 36, 50, 44 }) form.RowStyles.Add(new RowStyle(SizeType.Absolute, height));
var bundled = LocalServer.IsBundled;
foreach (var height in new[] { 48, 45, bundled ? 0 : 22, bundled ? 0 : 38, 22, 38, 22, 38, 36, 50, 44 }) form.RowStyles.Add(new RowStyle(SizeType.Absolute, height));
var logo = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false };
var mark = new Label { Text = "✓", Width = 42, Height = 42, BackColor = Theme.Accent, ForeColor = Theme.Text, Font = Theme.Font(20, FontStyle.Bold), TextAlign = ContentAlignment.MiddleCenter };
var brand = new Label { Text = "BiStudent", Width = 240, Height = 42, ForeColor = Theme.Text, Font = Theme.Font(19, FontStyle.Bold), TextAlign = ContentAlignment.MiddleLeft };
logo.Controls.Add(mark); logo.Controls.Add(brand); form.Controls.Add(logo, 0, 0);
form.Controls.Add(Ui.Label("Кабинет преподавателя", 13, Theme.Text, FontStyle.Bold), 0, 1);
form.Controls.Add(Ui.Label("Адрес сервера", 9, Theme.Muted), 0, 2);
server.Dock = DockStyle.Fill; form.Controls.Add(server, 0, 3);
if (!bundled)
{
form.Controls.Add(Ui.Label("Адрес сервера", 9, Theme.Muted), 0, 2);
server.Dock = DockStyle.Fill; form.Controls.Add(server, 0, 3);
}
form.Controls.Add(Ui.Label("Логин", 9, Theme.Muted), 0, 4);
login.Dock = DockStyle.Fill; form.Controls.Add(login, 0, 5);
form.Controls.Add(Ui.Label("Пароль", 9, Theme.Muted), 0, 6);
Expand Down Expand Up @@ -79,7 +83,20 @@ private async Task SignInAsync()
ApiClient? client = null;
try
{
var address = server.Text.Trim();
if (LocalServer.IsBundled)
{
error.ForeColor = Theme.Muted;
error.Text = "Запуск сервера…";
if (!await LocalServer.WaitUntilReadyAsync(TimeSpan.FromSeconds(60)))
{
error.ForeColor = Theme.Red;
error.Text = "Не удалось запустить сервер BiStudent. Перезапустите программу.";
return;
}
error.ForeColor = Theme.Red;
error.Text = "";
}
var address = LocalServer.IsBundled ? LocalServer.Url : server.Text.Trim();
if (!ClientSettings.SameServer(settings.ServerUrl, address))
settings.StoreRefreshToken(null);
settings.ServerUrl = address; settings.LastLogin = login.Text.Trim();
Expand Down Expand Up @@ -107,6 +124,7 @@ private async Task SignInAsync()
finally
{
signingIn = false;
error.ForeColor = Theme.Red;
if (!IsDisposed) { button.Enabled = true; UseWaitCursor = false; }
}
}
Expand Down
8 changes: 5 additions & 3 deletions BiStudent/MainForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public MainForm(ApiClient api)

private void BuildShell()
{
var sidebar = new TableLayoutPanel { Dock = DockStyle.Left, Width = 240, BackColor = Theme.Sidebar, RowCount = 3, Padding = new Padding(16, 22, 16, 16) };
var sidebar = new TableLayoutPanel { Dock = DockStyle.Left, Width = 250, BackColor = Theme.Sidebar, RowCount = 3, Padding = new Padding(16, 22, 16, 16) };
sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 84));
sidebar.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 64));
Expand All @@ -61,7 +61,7 @@ private void BuildShell()
foreach (var (key, text, icon) in Pages)
{
if (key == "admin" && api.Teacher?.IsAdmin != true) continue;
var button = new ModernButton { Text = text, Icon = icon, Width = 206, Height = 44, TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, key == "report" ? 14 : 0, 0, 6), Dashed = key == "report", BackColor = Theme.Sidebar };
var button = new ModernButton { Text = text, Icon = icon, Width = 218, Height = 44, TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, key == "report" ? 14 : 0, 0, 6), Dashed = key == "report", BackColor = Theme.Sidebar };
button.Click += async (_, _) => await ShowPageAsync(key);
nav[key] = button;
menu.Controls.Add(button);
Expand Down Expand Up @@ -119,14 +119,16 @@ private static string Initials(string name) =>

internal async Task ShowPageAsync(string key)
{
if (switching || IsDisposed) return;
if (switching || leaving || IsDisposed) return;
switching = true;
if (api.Teacher is { } teacher && userName is not null && userAvatar is not null) { userName.Text = teacher.FullName; userAvatar.Text = Initials(teacher.FullName); }
try
{
if (current is not null)
{
await current.OnLeaveAsync();
// Пока страница останавливалась (например, камера), окно могли начать закрывать.
if (leaving || IsDisposed) return;
host.Controls.Remove(current);
current.Dispose();
current = null;
Expand Down
Loading