Skip to content

Latest commit

 

History

History
326 lines (308 loc) · 121 KB

AGENTS.md

File metadata and controls

326 lines (308 loc) · 121 KB

WARNING

DO NOT WRITE THIS WHEN THE PROJECT CAN COMPILE NORMALLY: private void InitializeComponent() => AvaloniaXamlLoader.Load(this);

PROJECT KNOWLEDGE BASE

Last Update: 2026-08-30 Last Submit: 51f7f7fd Last modified model: MiMoCode

OVERVIEW

SecRandom is a GPLv3 C#/.NET desktop app for fair random drawing in education scenarios. Stack: .NET solution, Avalonia + FluentAvalonia UI, Microsoft.Extensions.Hosting DI, xUnit v3 tests.

STRUCTURE

SecRandom-C/
├── SecRandom/             # Avalonia app layer: App host, views, viewmodels, app services, localization, assets
├── SecRandom.Core/        # Core/domain + reusable UI controls/styles + config/logging/draw services
├── SecRandom.Shared/      # Cross-project contracts, base config/model types, IPC/profile models
├── SecRandom.PluginSdk/    # In-process plugin base types and SRPX packaging target
├── SecRandom.ExamplePlugin/# Minimal plugin template
├── SecRandom4Ci.Interface/ # Shared ClassIsland v2 IPC contract for the SecRandom4Ci plugin
├── SecRandom.Desktop/     # Tiny executable launcher; Program.cs bootstraps Avalonia and UiAccessStartup.cs prepares Windows UIAccess
├── SecRandom.Launcher/    # Minimal portable-package version selector; starts an activated app-* payload only
├── SecRandom.Platforms.Abstractions/ # Platform-neutral window capability contracts and result DTOs
├── SecRandom.Platforms/   # Startup context, DI registration, and unsupported-platform stub
├── SecRandom.Platforms.Windows/ # Windows-native window feature implementation
├── SecRandom.Platforms.Linux/ # Linux-native window feature implementation boundary
├── SecRandom.Platforms.MacOs/ # macOS-native window feature implementation boundary
├── SecRandom.Mobile.Tests/  # Avalonia Headless tests for mobile styles, native controls, and phone-size layout
├── SecRandom.Android/       # Android entry head: net10.0-android Exe with BuildMobile=true, otherwise empty neutral library
├── SecRandom.iOS/           # iOS entry head: net10.0-ios Exe with BuildMobile=true, otherwise empty neutral library
├── SecRandom.Core.Tests/  # xUnit v3 test project; currently covers legacy privacy/telemetry migration
├── scripts/               # Standalone tooling and verification scripts, including fairness audits
├── docs/                  # Project rules, localization, namespace boundaries
├── ChangeLog.md           # Fork-owned user-facing release notes, append-only, organized by release time
├── DownStream_Change.md   # Fork-vs-upstream divergence registry (engineering ledger for upstream sync)
├── CHANGELOG/             # Versioned release notes, mostly v3 tree; CHANGELOG/README.md holds the stable-release note template
├── resources/             # README mirrors, screenshots, banners, root static assets
├── vendors/EdgeTtsSharp/  # Edge TTS synthesis submodule; app supplies a cross-platform transport seam
├── Global.props           # Main shared MSBuild policy; imported by projects
├── Directory.Build.props  # Avalonia version pin only
└── SecRandom.sln          # Build/test solution entrypoint

Nested instruction files:

  • SecRandom/AGENTS.md: app layer, DI composition, views/viewmodels, app services, localization.
  • SecRandom/Views/SettingsPages/AGENTS.md: settings page subtree, page IDs, restart semantics, grouped localization expectations.
  • SecRandom.Core/AGENTS.md: Core draw/config/logging services and shared controls/styles.
  • SecRandom.Shared/AGENTS.md: UI-free shared contracts and persistence model boundaries.

WHERE TO LOOK

Task Location Notes
Run/build/test SecRandom.sln, .github/workflows/Build.yml Use solution commands; no Makefile/CMake. CodeQL builds the desktop project with BuildInParallel=false because the desktop asset override can otherwise schedule duplicate Core project nodes that race on Avalonia's generated resource file.
Desktop startup SecRandom.Desktop/Program.cs Process entry → Avalonia lifetime.
Platform capability contracts SecRandom.Platforms.Abstractions/, SecRandom.Platforms/ App-internal platform root, window feature requests/results, startup context, and DI bridge.
Native window features SecRandom.Platforms.Windows/, SecRandom.Platforms.Linux/, SecRandom.Platforms.MacOs/ Each platform owns native feature handling; views must not add platform API calls.
Mobile startup SecRandom/App.axaml.cs, SecRandom/Mobile/, SecRandom/Views/Mobile/, SecRandom.Android/, SecRandom.iOS/ The shared App owns the one Host and branches by Avalonia lifetime; SecRandom owns the mobile root, routes, services, and platform-neutral seams while heads own entry points.
Mobile UI tests SecRandom.Mobile.Tests/ Avalonia Headless smoke tests load mobile styles and lay out native shell controls at phone dimensions.
Mobile point-call orchestration SecRandom/Services/Mobile/MobileRollCallService.cs Mobile-only list/scope/count orchestration over existing Core filtering, sampling, and transactional commit services.
App composition / DI SecRandom/App.axaml.cs BuildHost() is the registration source of truth.
Plugins SecRandom.PluginSdk/, SecRandom/Services/Plugins/, SecRandom.ExamplePlugin/, docs/plugins.md Desktop SRPX plugins are loaded before Host build; plugin pages/services register through Core APIs. Stable plugin-facing contracts live in SecRandom.Core/Abstraction/Services/ (views under Abstraction/Services/Views/). The plugin market index and signed generation workflow live in the separate SECTL/SecRandom-PluginIndex repository.
Main navigation SecRandom/Views/MainView.axaml.cs Default page main.rollCall; keyed DI page factory. Built-in draw pages are main.rollCall and main.lottery; quick draw opens from the floating window instead of the main sidebar.
Settings navigation SecRandom/Views/SettingsView.axaml.cs Default page settings.overview; has back stack + restart dialog. General group now includes settings.general.basic, settings.general.privacy, settings.general.backup, and settings.general.control.
Page registration helpers SecRandom.Core/Extensions/Registry/ AddMainPage, AddSettingsPage, group, and separator helpers.
ClassIsland notifications SecRandom/Services/Notification/, SecRandom4Ci.Interface/ Typed v2 IPC client for the installed SecRandom4Ci ClassIsland plugin.
Control-plane node (集控) docs/client-protocol.md, SecRandom.Core/Services/ControlNode/, SecRandom/Services/ControlNode/, SecRandom/Views/SettingsPages/General/ControlSettingsPage.axaml(.cs) control-v1 node agent: outbound node WebSocket, hello/heartbeat/command.ack/command.result frames, desired_state revision convergence, idempotent commands with expires_at dropped when expired, and the device-owned "allow remote control" switch persisted in data/config/control/node-state.json (never settings.json). Declared capabilities are node.status.read, draw.lock, draw.trigger, media.play (voice announce only — show is refused rather than faked), settings.write (whitelist only, see ControlSettingsWhitelist), roster.write (JSON replace/merge for roll-call rosters or prize pools — optional roster_kind: "prizes" with the array named prizes, absent kind means students — with the optional tri-state per-member tags, previous list backed up first, activate off by default; both write capabilities pass one byte gate before any field is parsed — ControlPayloadBudget rejects a payload over ControlProtocolJson.PayloadBudgetBytes (61 440 B) with payload_too_large:<bytes>:<budget>, because an oversized frame is fatal at the transport layer, and the row limit MaxStudents is only a coarse pre-filter: the same 60 members cost several times more bytes once names, tags, and the special-voice fields are long) and the two read capabilities settings.read (Operator+, emits the reflected settings catalog whose label/description are resolved for the request's locale — the console sends its own UI language and an unrecognised tag falls back to the device culture; three-language maps are deliberately not sent, because six extra strings per field pushed five categories past the 64 KiB frame limit and the frame was silently dropped) and roster.read (Admin+, members carry tags, and the response trims members to fit the payload budget while reporting count/total/truncated); reads arrive as kind: "query" commands and answer through command.result.result_payload, so they are not REST endpoints — only proof.list remains unimplemented and therefore undeclared; node.restart is deliberately not implemented; the node also never reports the class/roster name (current_class), because which class a classroom is teaching is teaching-context privacy the control plane does not need. It reports a device display name instead (display_name, resolved by ControlNodeDisplayName): the name typed on that device, falling back to the host name so the console never shows only a random node_id.
Crash recovery SecRandom/Services/CrashRecovery/, SecRandom/Views/CrashRecoveryWindow.axaml.cs Fatal/dispatcher crash report prompt, guarded auto-restart, and shared desktop relaunch logic.
Page registry state SecRandom.Core/Services/PagesRegistryService.cs Main/settings/group collections.
Cross-platform view engine SecRandom.Core/Views/ Logical view/session contracts; desktop and mobile shells provide DI-registered physical hosts.
Fair draw logic SecRandom.Core/Services/Draw/ Partial DrawEngine, weighted draw, filters, crypto RNG, plus DrawCommitCoordinator (IDrawCommitService) transactional commits and shared DrawRepeatPolicy/DrawCandidateFilter.
Config persistence SecRandom.Core/Services/Config/ FileConfigService and handlers are host-internal Core runtime services; desktop keeps its existing package-root data path. v3 backup/archive transfer lives in SecRandom.Core/Services/Archive/ (DataArchiveService).
Account cloud backup SecRandom/Services/ImportExport/CloudBackupService.cs, SecRandom/Services/ImportExport/CloudAutomaticBackupService.cs, SecRandom/Services/Auth/SectlCloudStorageClient.cs, SecRandom.Core/Services/Archive/CloudBackupPackage.cs Desktop and mobile account cloud sync over the SECTL personal cloud API: CloudBackup archives split into verified parts plus a trailing manifest, cloud listing/delete, and verified download before the normal v3 restore. Every upload carries a device alias (BackupConfig.CloudDeviceAlias; when empty, the mobile heads' device label — Android/iOS report localhost as their host name — and otherwise the host name) that labels the cloud list and scopes retention per device. CloudAutomaticBackupService is the hosted service behind the separate BackupConfig.CloudAutoBackup* cadence and retention settings. Cloud backups are uploaded as the plain v3 archive: there is deliberately no client-side encryption and no passphrase, so account access is the only key and any signed-in device can restore every backup.
Audit tooling scripts/FairnessAudit/ Standalone fairness/performance validation script and HTML report generator.
Release history dataset scripts/ReleaseHistory/, docs/release-history/ Multi-sheet XLSX over GitHub Releases + CHANGELOG/ + local git: version timeline, per-release highlights, adjacent-version deltas, removed/downgraded features, contributors, star growth, asset download counts. Refresh with powershell -File scripts/ReleaseHistory/update-release-history.ps1, then verify_workbook.py. Star timelines need a logged-in gh CLI; manual conclusions live in docs/release-history/curated/annotations.json.
Release update signing scripts/ReleaseManifest/, scripts/validate-release-metadata.ps1, .github/workflows/build_publish.yml, .github/workflows/build_ios.yml Ed25519 key-generation helper and CI manifest signer; private key is Actions-secret-only. Release intermediates and final artifacts are grouped under artifacts/release/. The channel gate validates metadata.yaml against the release tag before every build. iOS builds out of band and then attaches the IPA, a re-signed manifest, and a refreshed release note to the release the publish job created (scripts/ReleaseManifest/Update-ReleaseManifest.ps1).
Reusable controls/styles SecRandom.Core/Controls/, SecRandom.Core/Styles/, SecRandom.Core/StylesBase.axaml App style entrypoint includes Core bundle.
Localization rules SecRandom/Langs/, SecRandom.Core/Langs/, docs/localization.md Per-page resource folders; .csproj registers base resx/designer only. Privacy page resources live under SecRandom/Langs/SettingsPages/General/Privacy/.
Shared contracts SecRandom.Shared/ Keep UI/runtime dependencies out. Profile list items use hidden stable RecordId keys; visible Id/student number/prize number is optional metadata.
Project rules docs/project_rules.md Strongest local convention source.

CODE MAP

Keep this map short and stable. When code moves, AI agents should re-read the moved files and update this map in the same task.

Symbol Type Location Role
Program.Main entry SecRandom.Desktop/Program.cs Starts Avalonia desktop lifetime.
UiAccessStartup startup helper SecRandom.Desktop/UiAccessStartup.cs When UIAccess topmost is configured on Windows, elevates a bootstrap process and starts a replacement process with a UIAccess token before Avalonia initializes.
Program.BuildAvaloniaApp entry helper SecRandom.Desktop/Program.cs Platform detect, MiSans default font, trace logging.
App Avalonia app SecRandom/App.axaml.cs, App.Consts.cs Culture, XAML load, Host/DI, windows, restart/stop, theme/font refresh.
IAppHost static service access SecRandom.Core/Abstraction/IAppHost.cs Holds Host and exposes GetService<T>() / TryGetService<T>().
MainView shell view SecRandom/Views/MainView.axaml.cs Main NavigationView, drawer, default page, settings window bridge.
SettingsView shell view SecRandom/Views/SettingsView.axaml.cs Settings NavigationView, history/back, restart prompt.
PagesRegistryService registry SecRandom.Core/Services/PagesRegistryService.cs Static collections backing generated navigation menus.
DrawEngine domain service SecRandom.Core/Services/Draw/DrawEngine*.cs Student/prize drawing, fairness weights, repeat/avg-gap filtering.
WeightedDrawEngine<T> algorithm SecRandom.Core/Services/Draw/WeightedDrawEngine.cs Validates weights and samples without replacement.
MainConfigHandler config handler SecRandom.Core/Services/Config/MainConfigHandler.cs Main config wrapper over ConfigHandlerBase<MainConfigModel>; persists the canonical General subtree and still loads legacy root basic/backup JSON.
ProfileService runtime service SecRandom.Core/Services/Profiles/ProfileService.cs Current profile runtime state, active student-list/history switching, and persistence for desktop and mobile hosts.
IProfileService service contract SecRandom.Core/Abstraction/Services/IProfileService.cs Current lists/history + student profile switch + profile save boundary.
SettingsSearchService app service SecRandom/Services/Settings/SettingsSearchService.cs Indexes settings pages via reflected localization resources.
CrashRecoveryRuntime app service helper SecRandom/Services/CrashRecovery/CrashRecoveryRuntime.cs Reads crash recovery mode, writes bounded crash reports, and builds restart process plans.
ISecurityService app service contract SecRandom/Services/Security/ Owns credential verification, lockout policy, selected-factor authorization, and protected-operation gating.
ProtocolCommandRouter app service SecRandom/Services/Ipc/ProtocolCommandRouter.cs Normalizes URL/IPC routes, routes protected commands, and returns structured IPC results.
DeviceUuidStore app service SecRandom/Services/Config/DeviceUuidStore.cs Persists the pseudo-anonymous device UUID separately in data/config/device-uuid.json and migrates legacy settings values.
AttachedSettingsRegistryService registry SecRandom.Core/Services/AttachedSettingsRegistryService.cs Static collections for attached-settings controls.
ViewModelBase base VM SecRandom/ViewModels/ViewModelBase.cs Base VM exposing MainConfig; inherits ObservableRecipient.
GlobalConstants constants SecRandom.Core/GlobalConstants.cs Version, platform, and development-mode constants.
DrawCommitCoordinator domain service SecRandom.Core/Services/Draw/DrawCommitCoordinator.cs IDrawCommitService implementation: single DrawRoundId, temp→history commit order, snapshot compensation, serialized gate.
DrawRepeatPolicy / DrawCandidateFilter draw rules SecRandom.Core/Services/Draw/ Shared repeat-threshold and candidate-filter rules; replaces formerly duplicated copies.
DataArchiveService domain service SecRandom.Core/Services/Archive/DataArchiveService.cs Platform-neutral v3 backup/archive engine: validation, staging commit/rollback, snapshots.
IArchivePostImportHooks seam SecRandom.Core/Services/Archive/IArchivePostImportHooks.cs Platform follow-up after archive import; Core registers Null hooks, desktop overrides them.
ProfileCatalogManager domain service SecRandom.Core/Services/Profiles/ProfileCatalogManager.cs List/profile CRUD and student/prize history clearing behind IProfileCatalogManager.
RosterImportParser parser SecRandom.Core/Services/Profiles/RosterImportParser.cs Shared roster spreadsheet parsing and column mapping for desktop/mobile imports; ScoreColumn is also the keyword scoring used to auto-detect the header row.
RosterImportRegionBuilder parser SecRandom.Core/Services/Profiles/RosterImportRegion.cs Pure import-range logic behind the list-management import drawers: Excel-aligned header-row detection, region → column names + row dictionaries, blank/repeated header handling, and column-letter conversion.
IHistoryExportService / HistoryExportService domain service SecRandom.Core/Services/HistoryQuery/HistoryExportService.cs Exports any set of one history kind (roll-call or lottery) from IHistoryQueryService + IProfileCatalogManager snapshots: one profile writes one XLSX (record sheet + summary sheet + one sheet per member that has records) or CSV (detail only), several profiles are packaged as a ZIP with one file each. Rows mirror the history settings pages (roster candidates first, then history-only records), so a member who was never drawn still gets a summary row; every export covers all members of the selected lists, and the only filters are course and time range plus a pickable time sort order. User-facing headers, cell values, sheet names, and archive file names come from the caller's HistoryExportLabels, never from Core.
IRosterQrCameraCapture / IRosterQrCameraCaptureFactory app service seam SecRandom/Services/RosterTransfer/IRosterQrCameraCapture.cs Common in-memory frame, permission, error, and lifetime boundary for list-import QR cameras. It resolves named IPlatformCameraDeviceCatalog devices from Host: Windows uses DirectShow/OpenCV for physical and virtual cameras, while macOS/Linux use AVFoundation/V4L2 indexes and Android maps Camera2 camera IDs to CameraView facing. The CameraViewRosterQrCameraCapture photo fallback (used only while no preview frame has arrived) is capped at three captures per session, and the session reclaims the full-resolution files the CameraView Android backend writes per shutter release through the head-injected IMobileCameraCaptureStore (MobilePlatformServiceRoot.CameraCaptureStore; Android AndroidCameraCaptureStore over RosterQrCaptureFiles, iOS no-op).
IMobileCameraCaptureStore mobile seam SecRandom/Mobile/IMobileCameraCaptureStore.cs Head-injected reclamation of camera capture files written outside the app data root: ClearLeftoversAsync(minimumAge, ct) deletes only captures at least that old, and implementations must not block the caller. MobilePlatformServiceRoot.CameraCaptureStore defaults to UnsupportedMobileCameraCaptureStore; the QR capture session clears with a five-second grace window on start and disposal and immediately after every photo fallback capture (the backend has already read that file), and App.StartMobileApplication clears everything once in the background at startup, with its own try/catch so a cleanup fault can never fail mobile startup.
IPlatformCameraDeviceCatalog platform contract SecRandom.Platforms.Abstractions/IPlatformCameraDeviceCatalog.cs Supplies the active platform's camera device ID, display name, capture index, and facing hint without leaking native APIs into the app layer.
MobileRollCallService mobile service SecRandom/Services/Mobile/MobileRollCallService.cs Mobile list/scope/count snapshots, multi-member draws, remaining list, and scoped temporary reset without changing the Core session contract.
MobileMediaLibraryService / MobileDrawMediaService mobile services SecRandom/Services/Mobile/ Mobile-private media import/reference cleanup and draw-time per-record image/music/voice orchestration through head-injected native playback.
SectlAuthService app service SecRandom/Services/Auth/SectlAuthService.cs SECTL Auth OAuth 2.0 PKCE sign-in on desktop and mobile, user info/avatar, and the single-flight refresh-token lifecycle behind the settings title-bar account flyout and the mobile cloud tab. The redirect arrives through an IAuthRedirectBroker: loopback on desktop, the app's custom scheme on a phone.
SectlTokenStore app service SecRandom/Services/Auth/SectlTokenStore.cs Owns data/config/sectl-auth.json: atomic (temp + fsync + rename) token persistence plus the cross-process refresh lock file beside it.
SectlHeartbeatService hosted app service SecRandom/Services/Auth/SectlHeartbeatService.cs OAuth device heartbeat for desktop and mobile: reports immediately for a signed-in session and then every five minutes.
SectlCloudStorageClient app service SecRandom/Services/Auth/SectlCloudStorageClient.cs Typed client for the SECTL personal cloud API (/api/cloud/*): platform-scoped listing, base64 upload, short-lived download URLs, delete, and quota, with service error codes preserved.
CloudBackupService app service SecRandom/Services/ImportExport/CloudBackupService.cs Account cloud-sync orchestration: packages a CloudBackup archive, uploads verified parts plus a trailing manifest, lists/groups cloud backups, verifies and rebuilds a download, deletes parts before the manifest, and purges incomplete backups. Every upload stamps this device's alias into the backup id, retention keeps only this device's own surplus, and UploadAutomaticAsync is the only path that frees account space. It uploads the archive as produced — client-side encryption was removed, so account access is the only key — and exposes DeviceTag/IsOwnBackup so a consumer can tell this device's backups from another machine's. A package the removed feature uploaded keeps its IsEncrypted name marker: it stays listed (and deletable) but CanRestore is false, because this build holds no way to derive its key.
CloudAutomaticBackupService hosted app service SecRandom/Services/ImportExport/CloudAutomaticBackupService.cs Automatic cloud backup on the BackupConfig.CloudAutoBackup* cadence, on desktop and mobile; follows this device's own newest cloud upload and keeps each signed-in device inside its own retention limit, while an exhausted quota still removes the account-wide oldest backup.
CloudBackupPackage domain helper SecRandom.Core/Services/Archive/CloudBackupPackage.cs Cloud backup part/manifest format: file naming, backup-id generation with the optional device alias, backup-id safety and alias parsing, splitting, manifest validation, and per-part plus whole-archive SHA-256 merging. Schema 1 is the only supported version: the parts carry the v3 ZIP archive itself. The removed feature's -enc name marker is still parsed so a schema-2 package from an older build is recognised and refused (its manifest is rejected by the schema check) instead of being mis-grouped as a current backup.
SectlTrafficPolicy app service SecRandom/Services/Consent/SectlTrafficPolicy.cs The one gate every path that could send data to SECTL must pass. IsEgressAllowed answers "may anything leave for SECTL" from Basic.AcceptedCrossBorderTransferVersion; IsAttestationUploadAllowed adds the ordinary-draw choice (VerificationSettingsConfig.AttestationUpload == Enabled, where Unset never uploads); EnsureTransferAcceptedAsync is the late sign-off dialog (required checkbox + ConfirmDialogGate). Never gate SECTL traffic on OnlineStatusMode alone — PlatformVersionReportService is deliberately not privacy-gated.
OwnProofChainStore / OwnProofExportService fork proof services SecRandom/Services/Verification/ The reference-only Own chain: node hash over (index, prevHash, Up chain index/hash, beacon pulse index, sequence), sibling *.ownproof.json files, head at data/proofs/own-chain-head.json. Never a fairness input; BeaconPulsePeriodPolicy (Core) is the time-stamp period preset the Up proof is checked against.
PlatformUsageReportService app service SecRandom/Services/PlatformUsageReportService.cs Hosted reporter for the SECTL usage counters (daily_roll_call_count family); counts draws after a successful commit and app launches at startup, gated by OnlineStatusMode.
UsageMetricCounter domain helper SecRandom.Core/Services/Stats/UsageMetricCounter.cs Pure current-local-day event counters plus the pinned platform_id/field_key/delta increment payload shape (no time field: the service stamps receive time).
PlatformVersionReportService hosted app service SecRandom/Services/PlatformVersionReportService.cs Hosted version-usage reporter (POST /api/stats/version): one best-effort report per start carrying the build version plus the device UUID only, run on the thread pool so startup is never delayed, and deliberately not gated by OnlineStatusMode.
VersionUsageReportPayload domain helper SecRandom.Core/Services/Stats/VersionUsageReportPayload.cs Pure version-report payload: platform_id/version/device_uuid with the API's pinned field names, a required parseable device UUID (never a user_id), and version-format validation.

CONVENTIONS

  • Three changelog documents with distinct jobs — never mix them. ChangeLog.md (root) is the fork's own user-facing release log, append-only, organized by release time, describing effects a user can see; CHANGELOG/ is upstream's core release history, which arrives with upstream syncs and must not be edited to record our work or overwritten on sync; DownStream_Change.md is the divergence registry — file-level and technical, answering "what must survive an upstream merge". A fork release writes user-facing entries to ChangeLog.md, and any divergence it introduces must already be registered in DownStream_Change.md. A fork change recorded in ChangeLog.md but missing from DownStream_Change.md is an incomplete change.
  • Downstream change log is mandatory. This repository is the VeriRandom fork of SECTL/SecRandom. DownStream_Change.md records every divergence from upstream (behavior, wording, files, CI, governance documents) together with the "internal identifiers must stay SecRandom" boundary. Any change that adds/renames/deletes a file or alters an upstream-inherited file must update DownStream_Change.md in the same change. Treat an unlogged fork change as an incomplete change: without it the next upstream sync either drops the fork change or reintroduces the upstream behavior, and the resulting diff is effectively unattributable.
  • docs/project_rules.md overrides inference when adding features.
  • General settings now live under MainConfigModel.General; MainConfigModel.Basic / Backup remain compatibility bridges for existing callers while new config splits belong under SecRandom.Core/Models/SubConfigs/General/.
  • Basic settings are functional runtime controls: ShowStartupWindow, primary-window topmost mode, and background residency apply only to the primary MainWindow; AutoSaveWindowSize preserves independent geometry/maximized state for the primary and settings windows. Cross-platform autostart and secrandom:// protocol registration belong to app-layer DesktopIntegrationService, which must use user-level Windows/Linux/macOS mechanisms and not silently persist a failed integration request.
  • Selecting TopmostMode.UiAccess for the primary or floating window persists the setting and requests a restart. SecRandom.Desktop/UiAccessStartup is the Windows-only pre-Avalonia launcher boundary: following killtimer0/uiaccess, it elevates a bootstrap process, impersonates same-session winlogon.exe, sets TokenUIAccess on a duplicate of the bootstrap's current token, and starts the UIAccess replacement before Avalonia initializes. The original process waits only for the bootstrap to create the replacement, never for Avalonia or Host initialization; denied elevation, preparation failure, or timeout keeps the original process in ordinary topmost and preserves the configured mode for a later retry.
  • Windows UIAccess topmost builds may use SecRandom.Desktop/app.uiaccess.manifest when EnableUiAccess=true; that manifest requires Authenticode signing and installation under Program Files. Independently, the runtime UiAccessStartup token preparation follows killtimer0/uiaccess and works from the ordinary manifest after UAC authorization, so debug and portable Windows builds can exercise UIAccess as well. The release workflow enables the manifest only when Windows code-signing secrets are available.
  • Point-call students and lottery prizes use RecordId as the internal stable identity for history/fairness. The visible Id field is optional display metadata only and must not be required by import, draw, or history logic.
  • A roll-call student or lottery prize candidate must be enabled and have a nonblank Id or Name. Import may map either column and excludes rows where both are blank.
  • Picking ClearRecord controls temporary draw records only; do not clear persistent profile histories from that setting. RollCall and QuickDraw share the same student temporary record store, while Lottery uses prize temporary records.
  • Privacy settings split Sentry upload from online status reporting: SentryTelemetryEnabled only controls SecRandom/Services/Telemetry/, while OnlineStatusMode only controls SecRandom/Services/OnlineStatusService.cs. In this fork both channels default to off (SentryTelemetryEnabled = false, OnlineStatusMode = Off, which also disables PlatformUsageReportService), because their data still goes to the upstream SecRandom/SECTL infrastructure — the Sentry DSN and the SECTL endpoints are unchanged. Any surface that exposes these toggles must state that enabling them sends data to SecRandom and SECTL rather than to this fork; the OOBE privacy step and the privacy settings page both carry that notice, and the OOBE privacy policy repeats it per section. The OOBE legal page also carries a separate SECTL disclosure (C_SecRandomServicesClause: online features require a separate agreement with SECTL, an independent entity, which gains access to uploaded data; C_SecRandomServicesDisclaimer: using the online features accepts SecRandom's terms and privacy policy and forms a direct service relationship with SECTL to which VeriRandom is not a party) with the copied SecRandomPrivacyPolicy text (the fork's policy with the product name changed back to SecRandom) and its own checkbox. That acknowledgement is optional during first-run setup (the page says so in bold) and does NOT gate IsPrivacyPolicyOnlyRequired(): SecRandomServicesConsent demands it, through the same second-level confirmation dialog, the first time an action would actually send data to SecRandom/SECTL — switching to formal notarization or enabling Sentry telemetry / online status — and declining reverts that action. SectlTrafficPolicy (app-layer) is the one gate every path that could send data to SECTL must pass, and it requires both acknowledgements: upstream's SECTL online-services policy (AcceptedSecRandomServicesVersion) and the fork's cross-border data transfer notice (AcceptedCrossBorderTransferVersion). Declining either one closes sign-in, heartbeat, cloud backup, online status, usage counters, the version report, and proof attestation. Never gate SECTL traffic on OnlineStatusMode alone — PlatformVersionReportService is deliberately not privacy-gated, so it must consult this policy explicitly. Ordinary-draw attestation additionally requires VerificationSettingsConfig.AttestationUpload == Enabled, and Unset (never asked) must never upload. The formal-notarization confirmation dialog carries the upstream description plus a bold "note" and warning, and appends the SECTL disclosure with a required checkbox whenever the consent version is below current. Every deliberate-consent dialog in this flow (the notarization mode switch and the online-services acknowledgement) must arm ConfirmDialogGate, so its confirm button stays disabled for a five-second minimum reading period in addition to the required checkboxes.
  • OnlineStatusMode gates the SECTL online-status and usage-counter channels (SecRandom/Services/OnlineStatusService.cs, PlatformUsageReportService) but deliberately not PlatformVersionReportService; a version figure is not a privacy-gated channel, so a fork-level "no SECTL traffic" consent must gate it explicitly instead of relying on OnlineStatusMode.
  • Usage statistics are a third, separate channel: PlatformUsageReportService counts roll-call, lottery, and app-launch events through the platform-neutral UsageMetricCounter and posts platform_id/field_key/delta daily increments to https://appwrite.sectl.cn/api/stats/usage/increment. The payload deliberately carries no time field: the service side stamps each increment with its own receive time and owns the weekly/monthly/lifetime figures, so the client's clock and timezone can never move an event into another bucket and no client-generated period string (period) is sent at all. The client therefore reports only the daily_* field keys (daily_roll_call_count, daily_lottery_count, daily_app_launch_count) and keeps just the current local day's counts plus the unsent increment queue in data/config/usage-stats.json. It is gated by PrivacySettingsConfig.OnlineStatusMode == Off, counts a draw only after the transactional commit succeeded, persists counters by atomic replace, restores a failed increment for the next attempt (so a partial batch is never replayed wholesale), and must stay best-effort — no statistics failure may reach a draw. The service-side counters are shared with the v2 client, which still reports its own period strings, so a server that starts deriving the coarser periods from daily rows must not do so for v2 traffic as well.
  • Version usage is the fourth statistics figure: PlatformVersionReportService posts platform_id/version/device_uuid to https://appwrite.sectl.cn/api/stats/version, where the service dedups by identity and keeps one current version per identity (the sum over versions is the platform's deduplicated user count, which /api/fields/values and /api/stats/usage/increment cannot produce). The account identity the API also accepts (user_id) is deliberately never sent: a version figure must not be tied to a SECTL account, so the lowercase DeviceUuidStore device UUID is the only identity and the payload carries nothing else. It is deliberately not gated by OnlineStatusMode either — the version mix is the project's own release baseline — and its payload stays minimal for that reason: the version plus that one device UUID, never an address, region, or account. It reports exactly once per process (the API asks for one report per start; no polling, no retry storm, no state), so nothing has to be re-reported when a user signs in or out. The reported version is GlobalConstants.Version including its v prefix, because the service groups people by that exact string (do not switch it to Tag or strip the prefix). The report must stay on the thread pool (Task.Run from ExecuteAsync): Host.StartAsync runs a hosted service synchronously up to its first incomplete await, so the device-UUID file access and the HTTP send must neither run on nor delay the startup thread; a rejected or unreachable endpoint only costs statistics.
  • Telemetry access is seam-based: runtime code depends on ITelemetryTransaction / ITelemetrySdkAdapter and must not reference Sentry types directly (the DSN lives in GlobalConstants.SentryDsn). TelemetryRuntimeService is Sentry-free; the desktop-only TelemetryTransactionSentryExtensions shim is excluded from mobile builds, while mobile links the same telemetry sources, adds MobileProfilingIntegrationStub, and owns its Sentry DI/init/shutdown plus Android/iOS unhandled-exception hooks.
  • The pseudo-anonymous device UUID belongs in data/config/device-uuid.json; legacy settings values are migration-only and must not be serialized back into settings.json. Full-data and configuration backups include this file, while settings-only exports do not overwrite a device identity.
  • ClassIsland notifications require the installed SecRandom4Ci v2 plugin. NotificationService uses the local SecRandom4Ci.Interface contract and ClassIsland.IPC.v2.Server; it must not use loopback TCP. SecRandom's built-in notification channel is the existing QuickDraw result window, shared by roll-call, QuickDraw, and lottery notifications. Each draw channel only controls whether it is enabled; NotificationSettings.Default owns the one global service, duration, threshold, fallback, and built-in auto-close configuration. UseBuiltInOnServiceFailure is the backend-neutral fallback policy for a selected external notification service and defaults to enabled; current ClassIsland-only delivery uses it when v2 IPC fails. For automatic QuickDraw delivery, the built-in or dual service opens this window before the draw to show the rolling animation and then its result; external-only delivery must not open the QuickDraw window unless the fallback runs. Notification settings must not duplicate the QuickDraw window's placement, opacity, or draw-animation controls.
  • Crash recovery mode lives under MainConfigModel.General.CrashRecovery; prompt startup handling must run before single-instance acquisition, while normal restart must release the single-instance service before relaunch. CrashRecoverySettingsConfig.DisableCrashedPlugin (default enabled) gates the plugin crash auto-disable path: when a dispatcher unhandled exception stack belongs to a plugin load context, that plugin is marked .disabled for the next startup; the toggle lives beside the crash-recovery mode in the basic settings page.
  • First-run OOBE is a top-level window shown immediately after BuildHost() and before floating-window/main-app initialization. Its carousel begins with a non-blocking, centered welcome screen with the application icon; it is outside the eight configuration pages, whose first item places the collapsed-by-default verifiable-draw notice before the privacy-policy and GPLv3 acknowledgement controls, and whose last three are desktop integration, 集控 (control node), and privacy in that order. The control-node page sits between desktop integration and privacy on purpose: it and desktop integration are both device-local "how this machine is reached" settings that never touch settings.json, while privacy is "what this machine sends out" and must stay the final page that asks the user to decide something. That page binds the device-owned switch, display name, node ID, and group ID straight through IControlNodeStateStore (data/config/control/node-state.json, so an import can never turn remote control on) plus the control-platform link, and it deliberately shows no live connection status — the node's hosted service only starts after the OOBE closes and the Host starts, so the page states when the node connects instead of pretending to know. Each acknowledgement requires explicit confirmation. The welcome page owns the lower-left language selector; changing it persists the language, refreshes ViewModel-derived localized values, then recreates only the OOBE visual tree into localized resources without restarting or leaving the first-run flow. An active import drawer defers the visual replacement until its preview/mapping flow closes. GuideCompleted, AcceptedVerificationNoticeVersion, AcceptedPrivacyPolicyVersion, and AcceptedGplVersion determine whether an OOBE acknowledgement is required. The fork's AcceptedSecRandomServicesVersion is versioned but deliberately excluded from that gate: its acknowledgement is optional during setup and is enforced later at the point of use by SecRandomServicesConsent. After the full guide has completed, a verifiable-draw notice, privacy-policy, or GPL version update opens only the acknowledgement page and its privacy controls, not unrelated setup pages. OOBE class/prize spreadsheet imports reuse the list-management import controls in its own right-side drawer, so their file mapping, preview, duplicate handling, and cancel behavior stay aligned with the settings list pages. Completing OOBE continues into normal startup and explicitly opens the primary main window; it must not exit merely because the OOBE window closes. Verifiable-draw notice, privacy-policy, and GPL changes increment their app-layer OOBE versions independently.
  • Floating-window startup visibility is owned by App.ApplyFloatingWindowStartupVisibility() and must never be left to the Avalonia lifetime. ClassicDesktopStyleApplicationLifetime.StartCore shows MainWindow exactly once, unconditionally (ShowMainWindow() is called only there and does not check visibility), so normal startup depends on that single show — while the deferred paths (first-run OOBE, settings-integrity gate) reach App.ContinueDesktopStartup after the lifetime already started and therefore get no show at all, which is how the floating window silently stayed invisible after the OOBE. Turning 启动时显示悬浮窗 off cannot be expressed by Hide() before Start() either, because Window.Hide() returns immediately for a window that was never shown; the hide is instead posted after the lifetime's show and gated on UserWantsVisible, so a tray re-open during that instant is respected and a course-linkage-hidden window is not restored.
  • Settings JSON, full-data ZIP, and backup restore only accept manifest/envelope exports produced by SecRandom v3. Validate producer_version before taking an import snapshot or changing data; unsupported sources must be explained in a modal dialog. producer_version is the raw GlobalConstants.Version, so it keeps a SemVer pre-release/build suffix (v3.0.0-alpha.2); the gate must strip that suffix and compare only the numeric core's major, never feed the raw value to Version.TryParse — otherwise a pre-release build rejects the archives it produced itself.
  • Security credentials must never be stored in MainConfigModel or settings.json. Keep passwords, TOTP seeds, USB binding tokens, and lockout state in SecRandom/Services/Security's separate credential store; ordinary settings only select factors and protected operations. The sole credential file is data/config/security/credentials.json: its internal FormatVersion is authoritative, uses Argon2id-derived key material plus AES-256-GCM for portable storage, and must not depend on DPAPI, Keychain, secret-tool, or a versioned filename. Do not read or migrate older credential files. SecurityCredentialStore restricts the whole data/config/security directory to the current user once per process and on every save (Unix 0700 directory with 0600 files and temporary files; Windows breaks ACL inheritance and keeps only the current user plus SYSTEM and Administrators), because that directory also holds the readable totp-standalone.json copy; the hardening is best-effort and must never leave the directory unreadable to the app itself. Lockout state deliberately stays outside the AES-GCM authenticated data — a failed attempt happens without the encryption key, so FailedAttempts/LockedUntilUtc must remain writable through SaveMetadata, which also means anyone who can rewrite the credential file can reset the lockout: the lockout only restrains callers that cannot write that file.
  • The security settings themselves are no longer in settings.json. SecuritySettingsConfig (protection master switch, factor selection, protected operations, Sudo, integrity check) lives in data/config/security/settings.json through the app-layer SecuritySettingsStore: an AES-256-GCM envelope (FormatVersion/Nonce/Tag/Ciphertext, authenticated with the fixed SecRandom/SecuritySettings/v1 associated data) plus a device-local random 256-bit key in the sibling settings.key. MainConfigModel no longer declares the property; a set-only security_settings bridge captures the legacy plaintext block once so the first run of this build migrates it and then rewrites settings.json to drop it. The read rules are load-bearing: a file that exists but cannot be decrypted, or a file that is gone while a security password is configured, must not fall back to "protection off" — the store returns maximal protection (SecurityEnabled plus every Protect*, with the factor flags left to the credential-derived NormalizeSettings) and sets IsIntegrityCompromised, so editing or deleting the file is never a way to turn protection off. A fresh install with no credentials still writes defaults with protection off, and the legacy bridge is consumed only while the encrypted file is absent, so neither a later settings.json edit nor an imported archive can re-specify these switches. The device key is what makes this work without a password prompt at startup — "should this operation verify?" must be answerable before any verification happens — so it shares the credential directory's owner-only hardening instead of the password-derived key; anyone who can read that directory can forge the settings, the same boundary as rewriting credentials.json to reset the lockout.
  • Because the protection switches left settings.json, ControlSettingsCatalog no longer describes a security category at all (it used to be described-but-read-only), so the control plane can neither read nor write them. The archive pre-import gate was reworked to match: IArchivePreImportGuard.AuthorizeImport() takes no candidate and asks the host for one fresh verification (no sudo state counts) whenever protection is active, so importing a settings file or restoring a backup stays a protected operation even though no archive can carry the switches any more. Export is deliberately not gated: automatic and cloud backups run in the background where no dialog can be raised. The whole data/config/security directory — now also holding the protection switches — stays excluded from every export, cloud backup, and the Android provider.
  • Fork policy: TOTP-only verification is disabled. A TOTP code can only be checked without the password by keeping a plaintext seed copy, so GlobalConstants.AllowStandaloneTotpVerification is false outside a debug build and outside the explicit --allow-standalone-totp startup switch (wired in SecRandom.Desktop/Program.Main). When it is off, SecurityCredentialStore.LoadStandaloneTotp returns null unconditionally, SecurityService never counts totpPassed on its own, and the next credential save deletes any leftover totp-standalone.json. USB-only and password-augmented verification are unaffected, and the tests opt in through SecRandom.Core.Tests/TestStandaloneTotpOptIn.cs because CI runs Release.
  • While SecuritySettingsConfig.RequireAllSelectedFactors is off ("any selected method" mode) every selected factor verifies on its own, and TOTP cannot be checked without its seed, so that mode keeps a password-free seed copy in the sibling data/config/security/totp-standalone.json (written only while the mode is off and TOTP is configured; deleted when the mode turns on or the password is removed). The directory stays excluded from every export, cloud backup, and the Android provider, and the settings description discloses the trade-off. Never move that copy into credentials.json: its AES-GCM authentication data covers only FormatVersion, the password credential, HasTotp, and USB bindings, so a readable seed field there would be read before any authentication and would silently bypass the existing tamper detection.
  • SecuritySettingsConfig.SettingsIntegrityCheckEnabled (desktop-only, password-gated) records a SHA-256 fingerprint of the whole data/config/settings.json in the credential envelope's plaintext SettingsIntegrity record on every MainConfigHandler.Save()/Reload(), and App verifies it before desktop startup. A mismatch routes through SettingsIntegrityAction: Confirm (the default) keeps the confirmation gate that requires the security password before the app continues, otherwise it exits; AutoRestore hands startup to SettingsIntegrityRecoveryService, which walks the newest data/backup/*.zip and/or account cloud backups per the separately configured SettingsIntegrityRestoreSource (Local / LocalThenCloud / CloudThenLocal), takes config/settings.json from the first supported v3 archive that actually carries a loadable settings payload, copies the current file to data/backup/integrity/settings_before_restore_*.json, atomically replaces it, reloads the handler (which refreshes the fingerprint), and restarts the app; only the settings file is touched, and a failed recovery keeps the original file and falls back to the password gate with the failure reason. Cloud candidates go through SettingsIntegrityRecoveryService.OrderCloudCandidates, which keeps only backups uploaded by this device (CloudBackupService.IsOwnBackup, newest first) and drops every other machine's: a settings file describes the machine it came from, so another device's backup is never a restore source, not even as a last resort. When this device has no own cloud backup the cloud source yields nothing (the skip is logged), recovery stays with data/backup, and otherwise the original file is kept and startup falls back to the password gate. The fingerprint needs existing security credentials as its anchor, is cleared when protection is turned off, and — because it lives outside the AES-GCM authenticated data — detects accidental or casual edits rather than a determined local attacker who can rewrite both files. Because the switch itself now lives in the encrypted security-settings file, SettingsIntegrityService also subscribes to SecuritySettingsStore.Saved: toggling the check no longer writes settings.json, so that event is what records or clears the fingerprint — without it the check could be turned on and still never have a recorded digest.
  • OmniTTS cloud speech API keys are credentials too: they live in data/config/voice/omnitts-keys.json via OmniTtsCredentialStore (app-layer, atomic replace), never in VoiceSettingsConfig/settings.json, IPC payloads, logs, telemetry, backups, or the Android DocumentsProvider. OmniTTS is voice engine 2 (OmniTtsSpeechProvider.OmniEngine), sits beside System SAPI (0) and Edge TTS (1), and is a unified cloud TTS engine whose providers are OpenAI, Gemini, FishAudio, MiMo, and Custom OpenAI-compatible. OpenAI, Gemini, and MiMo voice choices use documented preset sets; Gemini uses native /v1beta/interactions synthesis and wraps its 24 kHz PCM response as WAV. MiMo voice-design and voice-clone models use model-specific chat-completions payloads; clone references are WAV files kept privately under data/config/voice/mimo-voice-reference.wav, identified by a hash, and excluded from settings, archives, IPC, and logs. Models are fetched from provider APIs or typed manually. The voice settings page shows OmniTTS-specific rows (provider, base URL, key, model, voice, batch cache) only while VoiceEngine == 2. data/config/voice/ is excluded from archives and protected from the Android provider/directory launcher. iOS keeps the voice settings page unregistered (!OperatingSystem.IsIOS()); OmniTTS targets Windows, Linux, macOS, and Android.
  • Security authorization always flows through ISecurityService; do not add direct validation checks to tray handlers, windows, ViewModels, or linkage code. Passwords require at least 6 characters, with no artificial character-class rule.
  • Sudo mode is opt-in but on by default (SecuritySettingsConfig.SudoModeEnabled); turning it off makes every protected operation verify again. While it is on, a successful verification skips re-verification for SudoModeDurationSeconds (default 20s, 10–600s in the security page). The global timer covers draw/window/restart/exit/shortcut/IPC operations, while the settings window keeps a separate timer that is renewed by every verified settings update and ends when the duration elapses or the settings window closes, whichever comes first. Sudo never covers protection-loosening changes: UpdateSecuritySettingsAsync diffs the security settings with SecuritySettingsSnapshot.IsProtectionDowngrade, and a downgrade (turning security off, clearing a factor, disabling the settings-integrity check, switching the integrity action to automatic restore, turning a protection off, or lengthening the sudo duration) requires a fresh password verification that neither the settings-window sudo nor the global timer can satisfy; that fresh verification then authorizes further downgrades for one sudo duration so a single editing session does not re-prompt for every switch. BypassClassTimeRestriction and ChangeSecuritySettings are deliberately immune to every sudo state, so a confirmed non-class-time restriction can never be bypassed by an open sudo window. TOTP setup (BeginTotpSetupAsync), TOTP removal (RemoveTotpAsync, which clears the seed from the credential envelope, deletes the totp-standalone.json copy, discards any unconfirmed pending setup, and turns the factor off), password changes, and USB binding always require fresh verification too. The tray menu exposes an "Exit Sudo Mode" option (visible while any sudo state is active) that calls DeactivateSudoMode() and therefore clears both the global timer and the settings-window authorization. Every security-setting control on the security page — including the Sudo switch and duration — must write through UpdateSecuritySettingsAsync; never mutate the security-settings model directly and save it, because UpdateSecuritySettingsAsync is what performs the fresh verification and SecuritySettingsStore is what writes the encrypted file.
  • Full IPC/URL compatibility is app-layer routed through ProtocolCommandRouter: structured current-user named-pipe IPC is additive to legacy ShowMainWindow/Restart/Url: delivery, and all external mutations must use ISecurityService. data/* queries must use non-mutating profile snapshots, never active-profile loading APIs.
  • HistoryItem.DrawRoundId identifies every record committed by one logical draw. Populate it for new history writes; IPC history projections group by it and must never expose internal RecordId.
  • Draw commits must go through IDrawCommitService (DrawCommitCoordinator): one logical draw gets exactly one DrawRoundId (a caller may supply one), temporary records commit before persistent history, a mid-commit failure rolls back through snapshot compensation, and commits serialize behind the coordinator gate. Never reintroduce bare two-step writes of IProfileService.Record*History plus temporary-record calls; the optional drawRoundId parameter (and drawMethod on RecordPrizeHistory) exists for coordinator use.
  • File persistence saves are atomic replacements: FileConfigService.SaveConfig and DrawTemporaryRecordService.SaveState write a temporary file and atomically replace the target; new file-persistence code must follow the same pattern instead of overwriting files in place.
  • Draw proofs use the SecRandom v3 algorithm ID and an algorithmEngineVersion. Engine v3.1 commits the sampling mode into the request: student draws use history-balanced weighted sampling, count-based lottery inventory uses equal-probability partial permutation when no internal rule applies, and pan or internal-rule lottery draws use weighted sampling without replacement. Keep the verifier's replay logic byte-compatible with each supported proof engine version.
  • Ordinary draws immediately save an OfflineReproducible proof, then submit that newly generated proof to fair.sectl.cn for background replay/signing. A submission must use the proof object captured at draw time and never a re-read of the proof file: DrawProofAttestationService keeps the pending item in memory, persists only its path, proof ID, and canonical proof hash to data/proofs/attestation-queue.json, retries failures with bounded exponential backoff, resumes queued items after a restart, and refuses plus reports an item whose file no longer matches the recorded hash (it then keeps the receipt in the queue instead of overwriting the changed file). Startup must never scan or re-attest historical proof files that were never queued. A current server receipt signs a canonical hash of all proof fields except the receipt itself, plus the proof ID, input hash, payload hash, anonymous audit-payload hash, and mode; public sharing requires that receipt. This detects changes after the signed submission, but it does not attest to an unmodified local executable, an authentic real-world candidate pool, or absence of pre-draw result selection. Do not call it a pre-draw server witness. Local proof retention applies both the configured age and total storage limits; after every new save, delete oldest .srproof.json files until the configured storage cap is met.
  • External beacon entropy is a per-install opt-in for ordinary mode only (VerificationSettingsConfig.BeaconEntropyEnabled). When on, VerificationDrawCoordinator replaces the local CSPRNG seed with BeaconEntropyProvider.ReserveAsync, which fetches the latest NIST Beacon v2 pulse and derives the 32-byte seed as SHA256("SecRandomBeacon/v1" ‖ pulseIndex ‖ chainIndex ‖ outputValue ‖ sequence) (BeaconSeedDerivation). No local random material, clock, or caller value may enter that derivation, because the whole point of this path is that the operator cannot inject custom entropy. The per-pulse sequence is forced to 0 for a new pulse and then +1 per draw (BeaconSequencePolicy), and the anchor is persisted to data/proofs/beacon-state.json by atomic replace before the seed is released, so a failed draw leaves a visible gap instead of a reused seed; a pulse-index regression or a same-index value conflict is refused rather than reset. Beacon failure must never fall back to a local seed — the draw fails, exactly like formal notarization.
  • Proofs are written as a dual-chain pair, not one file. The Up chain (*.srproof.json) is the upstream-compatible record and deliberately carries no beacon field (DrawProof.Beacon is read for legacy files only and is stripped on write, and WitnessClient.AttestAsync still nulls it on the wire, because the server recomputes the canonical hash from a fixed field list). Instead of storing the pulse, the Up proof is checked against the published pulse through a preset: BeaconPulsePeriodPolicy.Match (Core) takes the time-stamp token's period and accepts that period's pulse, or the previous one inside TolerancePeriods = 1 (with a 60 s clock-skew allowance), so a substituted pulse is still detectable by re-deriving the seed. The Own chain (*.ownproof.json, a sibling with the same base name) is written only when beacon entropy is enabled and is a reference declaration, never extra cryptographic trust and never a fairness input: each node records the Up node it depends on (OwnProofUpReference.ChainIndex/ChainHash), the seed's increment factor (Beacon.Sequence) and the pulse index, its own chain position, and the raw signed pulse copy. OwnProofChainStore.ComputeSelfHash covers (index, prevHash, Up chain index, Up chain hash, pulse index, sequence), so a node cannot be re-pointed at another draw. The Own node's RFC 3161 token is requested before the Up chain's, which is what shows the reference node is not a post-hoc addition to an already-stamped proof; the ordering is enforced by DrawProofAttestationService.TimestampOwnProofAsync running as the first stage of each queue item. Verification reads the Own chain as ProofIntegrityReport.OwnReference only — ProofIntegrityVerifier recomputes each node hash and requires each pulse's sequence to start at 0 and rise by exactly 1, and those counters never mark the Up chain unhealthy. The data/proofs Up chain still carries index/prevHash/selfHash (selfHash = SHA256("SecRandomProof/v3/chain" ‖ index ‖ prevHash ‖ canonicalProofHash)), ProofChainStore keeps the head plus a retainedFromIndex floor in data/proofs/chain-head.json, retention deletes the Own sibling together with its Up file, and ProofIntegrityVerifier is still the only reader that judges the Up chain. Chain fields and witness.timestampToken must never enter WitnessClient.ComputeAttestedProofHash. A local chain buys visibility, not protection: an attacker who controls the machine can rebuild chain and head consistently.
  • data/proofs also carries the older Up-chain bullet points that the dual-chain paragraph above may appear to repeat: the head file is data/proofs/chain-head.json, and retention/storage-limit cleanup must record every Up proof it deletes through RecordRemovedIndices (and every reference node it deletes through OwnProofExportService.RecordRemoved), so an absence below the floor is explained and only an unrecorded prefix/interior gap is reported. Upstream additionally tightened two rules that must survive: the retention floor may only advance across a contiguous prefix of recorded removals (a sparse deletion, such as removing index 10 while 1-9 survive, would otherwise explain away every hole below it), and storage-limit cleanup must never delete the proof the same save just wrote (RemoveProofsOverStorageLimit(bytes, protectedPath)), because that would leave the chain head pointing at a file that never landed and hand the attestation queue a path that no longer exists.
  • The anonymous audit payload is validated by fair.sectl.cn against a fixed property allow-list (ValidateAnonymousAuditPayload / ValidateFairness), so adding a field there requires the server change to land first — the client must never ship a new audit field before the service accepts it, or every ordinary attestation fails with audit_payload_invalid. rosterDigest was added that way: RosterDigest.Compute hashes the canonical RecordId-to-display mapping (entries sorted by record id, so reordering is not a change while renaming, renumbering, regrouping, or disabling is) of the roster the draw was taken from, and VerificationDrawCoordinator omits it when the active profile is not the drawn list. It is the only commitment that ties a winning record id back to a person, because the candidate pool itself is anonymous — without it, editing the list after the draw silently re-points a winning record id at someone else while every receipt, replay check, and time stamp still validates. Only the digest leaves the device, never names or numbers; the digest of a low-entropy roster does allow someone who already knows nearly the whole list to confirm a membership guess. RFC 3161 time stamping is on by default and can be switched off explicitly (VerificationSettingsConfig.TimestampAuthorityEnabled): TimestampAuthorityClient.IsEnabled reads that setting, and the settings page must show the coupling warning dialog and require its acknowledgement before the change is persisted, because without a token the beacon match loses its period anchor and can only go back to the pulse through the reference proof's recorded index. TimestampAuthorityClient sends only the canonical proof hash to the fixed authority https://tsa.wotrus.com/, stores the returned token in witness.timestampToken for the Up proof and in OwnProof.timestampToken for the reference node, and validates its signature plus the certificate chain, accepting either the operating-system trust store or the pinned WoTrus (沃通) root it embeds (revocation is not checked so validation stays offline-capable). The authority URL is deliberately not configurable — a user-facing URL would let a broken or hostile endpoint silently disable the anchor — and it is reachable from mainland networks, unlike the free public authorities. A time stamp proves the digest existed no later than the stamped time and never that the draw happened then. Because the only thing that leaves the device is a de-identified digest plus the reference node's own chain hash, the fork's own privacy policy carries a bold notice disclosing that this information goes to WoTrus and its time-stamp service (C_TimestampPrivacyNotice, rendered in the OOBE acknowledgement page).
  • fair.sectl.cn anchors the local proof chain: DrawProofAttestationService submits the proof including chain, and the attest response's chain.status (genesis/continuous/duplicate/broken/gap/behind/conflict) is surfaced as ProofAttestationStatus.ChainAlertCount and logged. A behind status means the service already holds a higher chain position for this device, which is how a deleted-and-rebuilt local chain becomes visible; the server-side anchor never moves backwards. Receipt and time-stamp failures are counted separately (ReceiptFailedCount/TimestampFailedCount) so an unavailable third-party authority never reads as a failed attestation.
  • Formal notarization is the explicit alternative to ordinary replay attestation: the client sends a zero-seed anonymous request, fair.sectl.cn durably locks it, persists server random material after the lock, calculates the result, and returns an immutable OnlineWitnessed proof. Formal mode waits for that response and must never substitute a local draw after an error; its ledger is independent of retention-limited public proof sharing. It protects the locked flow against local code/seed/proof replacement, but cannot establish real-world roster authenticity, completeness, or pre-submission pool integrity.
  • Proof ordering TODO: attestation is still enqueued when a draw completes rather than strictly after the commit boundary; deferring proof finalization/attestation until after the IDrawCommitService commit remains an open task. Keep this note until that ordering lands.
  • Profile persistence paths validate names through ProfileConfigBase.ValidateProfileName before composing paths or saving a previous profile; settings imports reject unsafe default class/pool names before creating a snapshot or mutating live data. Empty settings selections mean no selection, but empty persisted profile names are invalid.
  • OmniTTS keys are bound to the provider and normalized API origin (scheme, host, and port). Provider-only legacy keys have no trusted destination and must not be upgraded from imported settings; users must enter them again. Origin changes cannot reuse another origin's key, while same-origin path changes preserve it. Remote endpoints require HTTPS; HTTP is permitted only for loopback. The omnitts HTTP client must not follow redirects with credentials.
  • Android document mutation authorization uses canonical paths, not document ID strings. Security/voice credential subtrees and their ancestors cannot be deleted, renamed, moved, or recursively copied; canonical provider/data roots are immutable. Creation and transfer destinations must also pass canonical containment and protected-path checks.
  • Account avatar downloads accept only the fixed SECTL API HTTPS origin and never attach an OAuth Bearer token.
  • Settings preview is a security-prompt outcome, not URL authorization bypass. When enabled, it freezes page content while preserving settings navigation and must not mutate configuration. URL/IPC preview navigation must use NavigateToPreviewPage, never the ordinary NavigateToPage entry that exits preview.
  • ViewModels must be registered in SecRandom/App.axaml.cs BuildHost(); reusable services also go through Host. The mobile branch directly registers its mobile runtime services, navigation, pages, and single-view host rather than delegating to a separate application-service extension.
  • The settings title bar account flyout and the mobile cloud tab use SectlAuthService and SECTL OAuth Authorization Code + PKCE. Desktop receives the redirect on a loopback callback; a mobile head receives it through the app's custom scheme (cn.sectl.secrandom.mobile://oauth/callback, registered on the SECTL authorization service for the shared client id), delivered by MobileAuthCallbackRouter — Android through the system browser and an intent filter, iOS through ASWebAuthenticationSession, which captures the redirect itself because a suspended iOS app cannot be handed a deep link. Tokens are stored only in data/config/sectl-auth.json through SectlTokenStore; the client ID is centralized in SectlAuthService.ClientId, and a mobile attempt records its PKCE verifier in data/config/sectl-auth-pending.json so a redirect that arrives after the process was reclaimed is still exchanged (SectlAuthService.ResumePendingAuthorizationAsync, never a second authorization prompt). The refresh endpoint rotates a single-use refresh token on every success and invalidates the previous one immediately, so the client contract is: SectlTokenStore.SaveAsync replaces the file atomically (temporary file + flush to disk + rename) and a rotation is persisted before the new access token is used — never memory-first, never in place; all concurrent refreshes collapse into one single-flight task (callers await it with their own cancellation token, so one caller giving up cannot abort a rotation the others need) under a cross-process lock file (sectl-auth.lock) that also makes a second process re-read and adopt a pair another process already rotated. Outcomes are classified instead of retried blindly: 400 (invalid_grant/invalid_request/unsupported_grant_type) and 401 invalid_client are terminal — the local pair is cleared, RequiresReauthorization is set, StateChanged fires, and the dead refresh token is never submitted again; 429 rate_limited retries the same refresh after its retry_after_ms; 503 temporarily_unavailable retries with exponential backoff plus jitter; an unknown network outcome first re-reads the token file (adopting another process's rotation) and then retries once, and keeps the session when it still cannot tell whether the server rotated. Access tokens carry a locally stamped access_token_expires_at and are refreshed five minutes early (expires_in from the response, 3600s fallback), while refresh_token_issued_at records the first authorization because the fixed 180-day window is never extended by rotation. SectlHeartbeatService calls /api/oauth/heartbeat with the active Bearer token immediately after startup/sign-in and then every five minutes, independent of the anonymized online-status policy. SectlAuthService.SendAuthorizedAsync is the one authorized-request boundary for other SECTL APIs: it attaches the bearer token, refreshes it once on 401, and treats error_description: Access token has been revoked as a reason to re-authorize rather than to rotate the refresh token. SectlAuthService.InitializeAsync must stay local-only: it loads the token file and raises StateChanged, while the /api/oauth/userinfo profile and avatar download run on an unobserved background task that reports through the same event. App.StartRuntimeServicesAsync awaits account initialization before Host.StartAsync(), and ShowSettingsWindowCoreAsync awaits that startup task, so awaiting the account network calls there makes a slow SECTL API stall every window that follows — the settings window must never depend on account network latency. The /api/oauth/token and /api/oauth/refresh payloads carry only the grant fields plus the device UUID: the client must not resolve its own public IP (the former third-party lookup call is gone for good) nor upload an ip_address, because the service derives the address from the connection itself.
  • Account cloud backup is an app-layer feature on desktop and mobile, built on the SECTL personal cloud API (/api/cloud/*), and is separate from the anonymous SecRandom Sync channel, which stays encrypted, short-lived, and 1 MiB-capped. DataArchiveService.ExportCloudBackupAsync produces a CloudBackup archive whose root set is picked from the dedicated CloudInclude* selection but never leaves CloudBackupRoots, so config/device-uuid.json, audio/voice, theme/themes, and logs can never be uploaded (plugins, credentials, backups, and staging were already excluded for every archive kind), while proofs is uploadable only when BackupConfig.CloudIncludeProofs is on — default off, because a cloud copy is the last resort once local proofs were deleted; ImportCloudBackupAsync accepts only that kind and filters the device identity out of its commit roots. One backup is uploaded as 512 KiB verified parts plus a trailing manifest, cloud state is always rediscovered by listing (SecRandom-cloud- prefix, parts grouped per backup) instead of being cached in configuration, and a download must pass per-part and whole-archive SHA-256 verification before the normal v3 inspection, recovery snapshot, staging commit, and restart request. Client-side encryption and its passphrase were removed on purpose: the archive is uploaded as produced, so account access is the only key and any signed-in device can list and restore any backup — the platform can read a cloud backup, which is the accepted trade-off for recoverability. A package the removed feature uploaded is still recognised by its -enc file-name marker and stays listed with a 旧版加密,无法恢复 label, but CloudBackupService.CanRestore is false for it and its schema-2 manifest is rejected by ValidateManifest, because this build holds no way to derive its key. The cleartext manifest still reveals which content categories a backup carries and its size. Automatic cloud backup is opt-in and separate from the local backup cadence (BackupConfig.CloudAutoBackupEnabled/IntervalDays/MaxCount); the CloudAutomaticBackupService runs it while an account is signed in and follows this device's own newest upload, and only CloudBackupService.UploadAutomaticAsync may delete cloud backups. Every upload stamps a device alias into its backup id (CloudBackupPackage.CreateBackupId, _<alias>, ASCII-safe and at most MaxDeviceTagLength characters, taken from BackupConfig.CloudDeviceAlias or the host name, with a short digest when neither is ASCII-safe), so the cloud listing shows which machine a backup came from without downloading its manifest; the alias is a display label rather than an identity, so config/device-uuid.json still never leaves the device. Retention is per device: UploadAutomaticAsync trims only this device's surplus beyond MaxCount and never deletes a backup it cannot attribute (ids uploaded before aliases existed stay untrimmed), while an exhausted quota is the one account-wide deletion and removes the oldest backup of the whole account. The manual upload path never deletes implicit backups.
  • Desktop plugins use .srpx ZIP packages with a root manifest.yml; the entry assembly contains one non-abstract PluginBase implementation. Plugins are discovered from data/plugins, pending packages are staged in data/cache/plugin-packages, and plugin initialization runs before Host construction. Plugin page IDs follow the plugin.<id>.* namespace, are owned by the plugin, and must be unique at the application registry level. External dynamic plugins are desktop-only for now; mobile hosts do not load them.
  • Plugin API surface: PluginBase implements IAsyncDisposable; the host disposes each entrance (DisposePluginsAsync) before Host shutdown, and forwards application start/stop to the PluginBase.OnAppStarted / OnAppStopping overrides through the PluginLifecycleBridge hosted service (no plugin-registered hosted service needed just for lifecycle). Stable plugin-facing contracts resolve through DI from SecRandom.Core/Abstraction/Services/: IMainView/ISettingsView/IDrawerView (under Views/, wrapping MainView.Current/SettingsView.Current with UI-thread marshaling and empty-shell no-ops), IAppNavigationService (wraps App.ShowMainWindow/ShowSettingsWindow/ShowQuickDrawWindow and preserves the settings authorization gate), IAppLifecycleService (forwards App.AppStarted/AppStopping; primary use is plugin lifecycle hooks), IFloatingWindowButtonRegistry + FloatingWindowButtonDescriptor (runtime-only floating-window button registration; visibility is picked in the floating-window settings MultiComboBox and persisted as FloatingWindowSettingsConfig.VisiblePluginButtonIds, pruned at refresh when a registration disappears), and IPluginDrawService (controlled draw facade). Plugin draws must go through IPluginDrawService, which authorizes via LinkageDrawCoordinator and reuses RollCallDrawService/LotteryDrawService; plugins must never call IDrawCommitService, IRollCallSession/ILotterySession, or IProfileService.Record*History directly. Plugin management supports uninstall markers (.uninstall, config dir preserved), crash auto-disable (.disabled written when an unhandled exception stack belongs to a plugin load context), and external dev plugin directories via repeatable --epp/--externalPluginPath startup args. PluginManager is registered as both IPluginManager and its concrete type. The srpx packaging target excludes host-provided assemblies (SecRandom/Avalonia/FluentAvalonia/Microsoft.Extensions/runtimes/resources) so plugin packages stay host-free even with the local SDK reference. The full guide is docs/plugins.md.
  • Plugin SDK packaging and release: SecRandom.PluginSdk tracks the application version. PluginApiVersions.Current in the SDK defines the minimum host API version and its major must equal the application major version (enforced by the build_pluginsdk CI job). SecRandom.Shared, SecRandom.Core, and SecRandom.PluginSdk are packed together on every CI run (-p:PackageVersion=<app version>) and uploaded as the pluginsdk artifact; only the release publish job pushes them to NuGet.org using NuGet/login@v1 (OIDC Trusted Publishing, user=${{ secrets.NUGET_USER }}). The host rejects plugins whose manifest.yml apiVersion major is below PluginApiVersions.Current.Major. Local pack workflow: scripts/publish-pluginsdk.ps1.
  • Plugin market: the signed index lives in SECTL/SecRandom-PluginIndex, where one plugins/<id>.yaml per plugin declares repoOwner/repoName plus metadata. A workflow rebuilds index.json + index.json.sig on push and nightly, then uploads them to the fixed release tag generated. PluginMarketService (app layer, desktop only) downloads mirror-first with a GitHub fallback, verifies the whole index with the embedded Ed25519 key (avares://SecRandom/Assets/Plugins/plugin-market-public-key.txt, excluded from the desktop overlay), resolves the required dependency closure in topological order with cycle detection, verifies each package's SHA-256, then stages through IPluginManager.StagePackage. The market public key is independent of the update signing key; the private key exists only as the PLUGIN_MARKET_PRIVATE_KEY_PEM_BASE64 Actions secret. Plugin authors use scripts/publish-plugin.ps1 to build metadata and embed <!-- SECRANDOM_SHA256: <hex> --> in their release note; the workflow requires exactly one .srpx asset per release. Index/entry DTOs live in SecRandom.Shared/Models/Plugins/PluginCatalog.cs. PluginMarketService.IsCompatible must be called with a full application version (GlobalConstants.Version, pre-release suffix tolerated), never just its major: an unparseable value silently skips the minimum-host-version check.
  • The cross-platform view engine lives in SecRandom.Core/Views/. It separates logical Avalonia Control sessions, presentation intent, close/result handling, factories, and hosts. Core contracts must not expose Window, platform lifetimes, native APIs, or raw IServiceProvider; desktop and mobile shells register their physical presenters through DI.
  • MVE is reserved for independent logical views with their own lifecycle, including MainView, SettingsView, crash recovery, and the modal main.remainingList view. Desktop shell child pages remain keyed UserControl instances hosted only by FAFrame; desktop.main and desktop.settings name the physical windows that host the two shell views, never hidden regions inside those shells. Settings read-only preview freezes the FAFrame content while leaving navigation available.
  • Platform feature callers must resolve narrow platform contracts from Host. PlatformStartupContext is startup-only: desktop Program and Android/iOS heads set it before Avalonia starts, then shared App reads it once to select desktop or mobile registrations. Do not use it from views, ViewModels, Core, or business services. Removable-storage discovery follows the same boundary through IRemovableStorageCatalog: Windows, Linux, and macOS enumerate ready removable volumes in their matching platform projects, while the app security service consumes opaque device IDs and user-facing display locations and resolves mount paths internally. IPlatformCameraDeviceCatalog follows the same boundary for named QR camera choices: only platform projects may enumerate native devices; the app layer receives opaque IDs, display names, capture indexes, and facing hints. A catalog mount root is internal implementation data and must not cross into ISecurityService public binding APIs, UI models, dialogs, or IPC payloads. Platform discovery commands must read output asynchronously and enforce a bounded timeout before returning an empty catalog. USB binding markers use the sibling IRemovableStorageBindingMarker contract and always use .VeriRandom.safety.key; binding presence requires both the platform-hidden marker token and the matching stored device record. Binding records persist only the stable device ID and token hash; mount roots are never persisted or used as identity.
  • Window feature requests use IWindowFeatureService with a neutral PlatformWindowHandle, WindowFeatureRequest, and explicit Applied/Unsupported/Failed result. Keep Win32/X11/AppKit operations in the matching SecRandom.Platforms.<OS> project.
  • Mobile MobileViewHost uses Avalonia IInputPane to keep a focused text input above the system keyboard by translating the stable page viewport, including keyboard animation timing and cleanup on host detach. Android MainActivity must use SoftInput.AdjustNothing; do not combine system resize with the host translation. Windows touch input initializes SecRandom.Platforms.Windows.WindowsTouchKeyboardIntegration before Avalonia startup; it opens tabtip.exe through the system COM bridge and uses IInputPane to shift an occluded focused TextBox. Its window-closing guard skips Avalonia Win32 input-pane disposal on shutdown because some Windows builds crash in the native Unadvise call; this is process-exit cleanup only and must stay outside Core.
  • TopmostMode.UiAccess remains a Windows process-token capability controlled by SecRandom.Desktop/UiAccessStartup.cs; it is not a generic window feature or a responsibility of platform window services.
  • Mobile front-end code is part of SecRandom: composition/platform contracts live in SecRandom/Mobile/, views in SecRandom/Views/Mobile/, controls in SecRandom/Controls/Mobile/, services in SecRandom/Services/Mobile/, and resources in SecRandom/Langs/Mobile/. SecRandom.Android / SecRandom.iOS remain the only platform entry heads. BuildMobile=true enables their platform TFMs (net10.0-android / net10.0-ios Exe); build the relevant head project directly for platform CI. Platform-specific code stays in the heads behind IMobileUpdateInstaller, IMobileMediaPlayer, and MobilePlatformServiceRoot.StartupErrorLogger. iPadOS and Android tablets set MobilePlatformServiceRoot.UsesDesktopMainView before Avalonia startup, so the shared app presents MainView and desktop main-page registrations; iPhone and Android phones retain MobileRootView. Shared SecRandom.App calls Utils.ConfigureMobileDataRoot() before it builds the mobile Host, registers Core/mobile services, assigns/clears IAppHost.Host, mounts MobileViewHost as ISingleViewApplicationLifetime.MainView, then shows the selected initial view through its NavigationPage. The mobile branch must not register desktop-only services, windows, tray, shortcuts, OOBE, or protocol startup paths. Android additionally exposes only data/ through SecRandomDocumentsProvider; data/config/security/ and descendants are never provider-visible. iOS has no provider and hides open-directory actions. The provider virtual root shows a single data child and Android path launching targets that child document via ACTION_VIEW (directory picker is only the fallback); all path containment and protection checks must canonicalize through JavaFile.CanonicalPath because app-private storage resolves through the /data/user/0 symlink to /data/data, never compare raw AbsolutePath strings. Android settings "更多" shows the log/data/app directory entries plus their separator through CanOpenFileManagerDirectories; path launching maps data/logs and data to their provider documents and falls back to the provider root for non-browsable paths such as the package root.
  • Mobile UI uses native Avalonia controls with FluentAvalonia settings expanders and one NavigationPage inside MobileViewHost; MobileRootView owns the ordinary bottom-bar UserControl routes while MobileRootView and SettingsView are the only mobile MVE pages. Do not add NavigationView, InfoBar, a third-party mobile component library, MobileStyles.axaml, or a mobile token dictionary. Stable mobile layouts are AXAML-first; code-behind is limited to runtime data projection, StorageProvider operations, DataGrid construction, dialogs, media, and service calls.
  • ViewBase derives from ContentPage so the same logical view can be hosted by desktop windows or the mobile NavigationPage. Every independent view must set a page Header; it may decide its own navigation-bar and back-button visibility through NavigationPage attached properties.
  • Mobile settings open the independent SettingsView MVE through MobileViewHost's one NavigationPage, rather than becoming a normal bottom-bar route. Keep the mobile settings catalog as the navigation shell, but have it route to registered settings pages, including the desktop UpdateSettingsPage for settings.update. Do not register or restore the deprecated mobile general, personalization, list-management, draw, backup, or about settings content pages.
  • Phone MobileDrawPage is a layout shell over the singleton desktop RollCallPageViewModel and LotteryPageViewModel. Its result area uses the same RollCallResultPresenter / LotteryResultPresenter controls as desktop, so result models, display settings, animation revisions, reset semantics, and operation text remain shared. After mobile settings closes, refresh these shared sessions through RefreshAfterProfileChange() rather than adding mobile file watchers or another draw ViewModel.
  • IFeatureAvailabilityService is the sole live source for the mobile lottery tab and history tab. When lottery is disabled, hide both immediately, stop only the lottery preview, and return the draw shell to roll-call without interrupting an in-flight transactional commit.
  • Resolve shared services via IAppHost.GetService<T>() / TryGetService<T>() unless constructor injection is already the local style.
  • Navigation pages need [PageInfo(...)] plus services.AddMainPage<T>() or services.AddSettingsPage<T>() in BuildHost(). AddMainPage/AddSettingsPage now carry navigation metadata only; every built-in main/settings page is a Core ViewBase additionally registered with AddView<T>(pageId) so the view engine can host it.
  • Built-in main navigation entries may use PageLocation.Bottom for bottom-pinned sidebar items; roll-call (main.rollCall) and lottery (main.lottery) are bottom-pinned and full-width/title-hidden. Quick draw is not a main navigation page and opens from the floating window.
  • Page IDs: main.xxx, settings.xxx, settings.group.xxx.
  • Picking animation style is unified: settings expose it as AnimationStyle / “动画样式”, and RollCall, QuickDraw, and Lottery use the same style for both rolling preview/process animation and final result reveal. Do not split process/result animation style settings.
  • Managed draw music uses a platform-specific runtime boundary: desktop settings.personalized.music imports/deletes/previews MP3/WAV/FLAC files through MusicLibraryService/private SoundFlow playback, while mobile MobileMediaLibraryService uses StorageProvider streams into data/audio/music and IMobileMediaPlayer supplied by Android/iOS heads. Draw-settings and student/prize attached settings use the same managed IDs, no-music, or random-play options; the first drawn record supplies both overrides for a multi-record result. Deleting a managed track clears global and per-record references. Mobile display images likewise import through StorageProvider streams into data/images; image/music/voice failures must not invalidate a committed draw.
  • Course linkage uses fixed v2 data-source values: 0=Off, 1=CSES, 2=ClassIsland. CSES schedules live at data/CSES/cses_schedule.yml; ClassIsland is accessed only through the app-layer official IPC adapter. Only a confirmed course break restricts local draw/reset or hides the floating window. Missing/invalid CSES data, ClassIsland connection loss, or an unknown state must permit normal operation. ClassIsland reports a confirmed non-class period as Breaking, AfterSchool, PrepareOnClass, or None (today's plan loaded but no current time point, i.e. before the first class or a gap); IsLessonConfirmed is only true inside a class/break time point and must never be used as an availability gate, because before-school and after-school hours are exactly the periods the linkage restriction must cover. A day without a loaded plan (IsClassPlanLoaded == false) stays permissive.
  • CSES schedule errors follow the “InvalidDataException + Data error code” convention: CsesScheduleException is a static factory that throws InvalidDataException (sealed, so it cannot be subclassed) with a CsesScheduleError code and optional argument stored in Exception.Data; UI retrieves them through CsesScheduleException.TryGetError and localizes from resources.
  • LinkageSettings.VerificationRequired governs the course-time bypass prompt and is distinct from SecuritySettings.ProtectLinkage, which continues to protect only external SecRandom URL/IPC mutations. Student course history uses HistoryItem.CourseName with RecordId identity; empty legacy course values remain global history.
  • File/data paths should go through Utils.GetFilePath(...). Desktop and portable deployments keep mutable data under the package root's data/ directory. The shared App mobile branch is the controlled exception: before any path is read, it sets Utils once to its app-private local-data root; no Core service, view, or desktop code may redirect it later. data/config keeps its stable name for compatibility but is hidden at the filesystem level where supported (Windows Hidden|System; Unix-like hosts use the parent .hidden convention as a fallback), and hiding failures must not block config I/O.
  • Desktop startup calls Utils.PrepareDesktopDataRoot() before reading settings. Installed packages use <PackageRoot>/data when it passes a real write probe and otherwise use LocalApplicationData/SecRandom/data; portable ZIP packages never fall back to a per-user directory, and a failed write probe blocks startup with the transient SecRandomTmpRootWindow/FATaskDialog warning host.
  • Desktop builds copy SecRandom/Assets beside the executable and bind OverlayAssetLoader so existing avares://SecRandom/Assets/... URIs load physical files first, including Debug builds. Android and iOS keep the same assets embedded. Assets/Updates/release-public-key.txt and Assets/Plugins/plugin-market-public-key.txt are embedded on every target and must never be resolved from the physical overlay. System.Uri lower-cases the authority of an absolute avares URI while the scheme is unregistered (as it is for the GlobalConstants static font families created before Avalonia starts), so the overlay must compare that authority case-insensitively, or the first layout asking for the Assets/Fonts/ font collection throws Could not create glyphTypeface.
  • Portable ZIP updates use SecRandom.Launcher at the stable package root and activated app-* payload directories. The application, not Launcher, validates/downloads/extracts/activates a complete ZIP; Utils.PackageRoot / DataRoot keep user data stable across payload versions.
  • Update discovery uses a fixed GitHub mirror first, with GitHub direct access as fallback. A detached Ed25519-signed release manifest and each artifact's length/SHA-512 must verify before deployment or a system installer is started; signing keys remain outside the repository. Desktop artifacts, the signed Android arm64 APK, and the unsigned iOS arm64 IPA share this manifest. Android downloads its verified APK then delegates installation to the system package installer; iOS device distribution and in-app update delivery remain deferred.
  • This fork ships with updates turned off via GlobalConstants.UpdatesEnabled = false, because update discovery still points at the upstream SECTL/SecRandom metadata (metadata.yaml, SecRandom-update-manifest.json) and the fork must never consume upstream binaries. Keep the whole update implementation intact: UpdateScheduler returns immediately, UpdateCenterService.UpdatesDisabled() makes every entry point (CheckAsync/DownloadAsync/DownloadAndInstallAsync/ApplyDownloadedUpdateAsync) a no-op with the localized M_UpdatesDisabled status, and CanCheck/CanDownloadAndInstall/CanApplyUpdate/IsSupported report the disabled state so the settings page disables its buttons. Re-enabling is a one-line policy change once the fork has its own metadata and release server; do not delete the update services.
  • Localization is per page folder: Resources.resx, Resources.Designer.cs, Resources.en-US.resx, and Resources.ja-JP.resx; every new or changed user-facing string must be supplied in Simplified Chinese, English, and Japanese, preserving exact filename casing used on disk.
  • In .csproj, register only Resources.resx and Resources.Designer.cs; do not register every language variant.
  • Resource designer generator must be PublicResXFileCodeGenerator.
  • Localization keys: S_ settings, S_xxx_D description, S_xxx_R real key, O_ options, M_ messages, C_ controls.
  • Chinese i18n values must not use the Chinese full stop (。).
  • Settings-page explanation values (*_D, including S_*_D and C_*_D) must not use sentence-ending or sentence-separating full stops (。 or .); preserve technical dots in file names, domains, process names, and version identifiers.
  • Views commonly set DataContext = this and expose ViewModel; bindings use ViewModel.*.
  • ViewModels use CommunityToolkit MVVM (ObservableRecipient, [ObservableProperty]); app VMs inherit SecRandom.ViewModels.ViewModelBase.
  • Global.props carries the main MSBuild behavior: unsafe enabled, Windows targeting enabled, SourceLink, full debug symbols, and default exclusion of project-local artifacts/ / publish/ output trees from SDK item globbing. The shared AssemblyInfo.cs is enabled only by entry assemblies that explicitly set EnableGitInfoGenerator (SecRandom.Desktop, SecRandom.Android, and SecRandom.iOS); SecRandom.Core reads that head-assembly metadata rather than generating a second public SecRandom.GitInfo type. Every head must publish itself through GlobalConstants.SetVersionAssembly during startup (SecRandom.Desktop/Program.Main, SecRandom.Android MobileApplication.CustomizeAppBuilder, SecRandom.iOS/MobileEntryPoint.Main) before any version value is read: Android has no managed Main, so Assembly.GetEntryAssembly() is null there and the lookup would otherwise fall back to SecRandom.Core and render the whole version surface — settings title bar, producer_version, Sentry release, update checks — as v0.0.0.0. A new platform head must therefore set its own assembly rather than rely on the entry-assembly fallback.
  • Directory.Build.props only pins AvaloniaVersion.
  • Standalone verification scripts live under scripts/; keep them self-contained and write outputs under artifacts/.
  • Release CI keeps generated material under artifacts/release/: RID publish trees under publish/, portable ZIP assembly under portable/, Windows installer staging under installer/ and setup/, platform package workspaces under linux/ / macos/, upload candidates under dist/, and release-job downloads/final signed assets under downloaded/ / output/. Every Android build must receive the same configured release keystore secrets, rejects missing signing configuration, and verifies the produced APK certificate against that keystore before upload. The Android job stages signed arm64 and x64 APKs separately; the release job includes the supported APK artifacts in output/, the signed manifest, and the GitHub release. Portable ZIP contents must remain a root VeriRandomLauncher plus one valid app-* payload directory; do not rearrange that runtime package contract.
  • Release publication is gated on metadata.yaml: scripts/validate-release-metadata.ps1 derives the channel from the release tag (-alpha/-beta/stable) and requires that channel's tag to equal the release tag exactly, because update clients resolve channel→tag from metadata.yaml and then reject a release manifest whose tag/channel disagree, so a stale entry would publish an update nobody can reach. Bump the channel entry before pushing the release tag — the publish job checks out the tag, so the fix must live inside the tagged commit. build_publish.yml runs the gate as the validate_release preflight job before any build, and the build jobs therefore gate on success or skipped (its if keeps ordinary push builds running). The publish job creates the GitHub release as a draft and marks every hyphenated tag (alpha/beta/rc) as a prerelease, so a v3 preview never becomes the repository's “Latest” release; publishing that draft is a separate, deliberate step. This fork keeps the iOS build disabled — the build_ios job carries a false && guard and is excluded from publish's needs, so no IPA is produced and nothing waits on the macOS runner. Upstream's out-of-band path (dispatch build_ios.yml from the validate_release preflight with gh workflow run, let its backfill_release job upload the IPA with --clobber and re-sign the manifest through scripts/ReleaseManifest/Update-ReleaseManifest.ps1) is deliberately not wired here; restore it only together with the job, when iOS ships again.
  • Third-party actions are pinned to a commit SHA, never a floating major tag, because the release job's ncipollo/release-action step is what creates the GitHub release and uploads every artifact — a retargeted @v1 would be a supply-chain entry point. Use uses: <owner>/<repo>@<40-hex-sha> # <original tag> and keep the trailing comment so Dependabot still recognises and proposes version bumps. NuGet/login and maxim-lobanov/setup-xcode are pinned the same way. Pin the deref commit: an annotated tag's refs/tags/v1 is the tag object, and pinning that makes the action unresolvable, so check git ls-remote --tags https://github.com/<owner>/<repo> for a ^{} line before choosing the SHA. actions/* and github/codeql-action/* stay on major tags deliberately — they are GitHub-owned, and pinning them adds churn to the lines upstream edits most often.
  • More settings owns built-in draw page chrome options such as roll-call/lottery control panel position and control visibility; do not hard-code those controls outside the page/config binding.
  • Timer recent presets are persisted as integer seconds in MainConfigModel.RecentTimerPresetSeconds; the timer ViewModel removes duplicates, moves selected presets to the front, and caps the list at six entries.
  • MoreSettings.LotteryEnabled is the single capability switch for lottery. It must consistently gate main navigation, floating-window buttons, shortcuts, and URL/IPC routes.
  • Application-owned Fluent System Icons use the Filled variant by default across navigation, settings, buttons, menus, floating windows, and empty states. Use the closest semantic Filled icon when no same-name variant exists.
  • New application icon references must use FluentIcon, FluentIconSource, sr:Fi, or FluentIcons.*; do not add raw Fluent Unicode glyphs. When migrating a raw glyph, reverse-map its code point through SecRandom.Core/Assets/FluentSystemIcons-Resizable.json before choosing the Filled replacement.
  • Brand images, author/organization images, window icons, and taskbar icons are separate resources and are not part of Fluent glyph style migration. FluentAvalonia template glyphs remain framework-owned unless an application-owned style explicitly overrides them.
  • This repository is the VeriRandom fork of SecRandom, and that split is a durable convention. It is built to coexist with upstream SecRandom on the same machine, so product-identity identifiers are VeriRandom's own: the Android/iOS package com.yeyixiao.verirandom, the verirandom:// protocol (with an opt-in compatibility registration for secrandom://), Software\Classes\verirandom, the macOS com.yeyixiao.verirandom bundle id and launch agent, the Linux verirandom package/desktop entry and /usr/lib|/usr/bin/verirandom, VeriRandom.Desktop.exe / VeriRandomLauncher (the projects keep their SecRandom.* folder and file names and are renamed through <AssemblyName>), the VeriRandom.package.json marker with product: "VeriRandom" (compared by SecRandom.Launcher and UpdateCenterService.Product), VeriRandom-update-manifest.json / .sig, the VeriRandom_SingleInstance_* / VeriRandom_IPC_* names, .VeriRandom.safety.key, the LocalApplicationData/VeriRandom/data root, VERIRANDOM_PACKAGE_ROOT, and the installer's own AppId plus WinFunan\VeriRandom install directory. Code-level internal identifiers stay SecRandom because they are not product identity: namespaces, project/folder/file names, avares://SecRandom/... asset URIs, the SecRandomPlatform MSBuild property, the .srproof.json proof format with its SecRandomProof/... and SecRandomBeacon/v1 domain separators, and the upstream services and external contracts this fork only consumes (SecRandom Sync, the SecRandom4Ci ClassIsland plugin, the fair.sectl.cn / sectl.cn endpoints, the SECTL/SecRandom-PluginIndex market index whose product field stays SecRandom, and github.com/SECTL/SecRandom links). User-facing copy must state that this product is an open-source fork of SecRandom and must not be regarded as SecRandom itself; the About page (S_App_ForkNotice / M_App_ForkNotice) and the READMEs carry that notice.
  • Contributions are covered by the repository's Contributor License Agreement. CLA.md (Simplified Chinese) is the authoritative text; resources/CLA_EN.md and resources/CLA_JA.md are reference translations that must be kept in sync with it (all three state that the Chinese version prevails). It grants the maintainer the right to license and relicense a signed contribution and requires at least 30 days' public notice before the outbound license for that contribution changes. Its scope is deliberately limited: it binds only contributors who sign it, cannot change the license of upstream SecRandom code (which stays GPLv3), and cannot retroactively relicense contributions that predate it — so any future relicense of existing code still needs per-rightsholder consent. .github/pull_request_template.md pre-fills the bilingual sign-off statement, so a submitted PR body normally already carries the signature; keep CLA.md, the three translations, the three CONTRIBUTING guides, the PR template, and LICENSE consistent whenever any of them changes.

COMMENT STYLE

  • Keep comments that explain non-obvious project constraints, platform quirks, or AI-prone rules.
  • Do not add comments that merely restate a method name or obvious assignment.
  • Existing emphatic comments in BuildHost() about ViewModel registration reflect a real project invariant; preserve the rule even if wording is later cleaned up.

MAINTENANCE CHECKLIST

AI agents must update AGENTS files when they:

  • Ship a fork release or any user-visible fork change: add it to ChangeLog.md (append-only, by release time) in the same change, and register the divergence in DownStream_Change.md. DownStream_Change.md remains the first and mandatory check before finishing any task in this fork.
  • Make any change that diverges from upstream SecRandom — including wording, defaults, resources, CI, and governance documents. DownStream_Change.md must be updated in the same change; this is the first item to check before finishing any task in this fork.
  • Add, remove, or rename top-level folders or projects.
  • Change TargetFramework, package family, AvaloniaVersion, CI SDK versions, or publish RIDs.
  • Change Host/DI registration, navigation page registration, page IDs, or default pages.
  • Change localization folder layout, resource designer registration, or culture filename casing.
  • Move draw/config/profile services or alter Core/Shared namespace boundaries.
  • Discover a durable convention while fixing code that is not yet documented here.
  • Remove stale facts encountered during work instead of leaving them for a later cleanup.

ANTI-PATTERNS (THIS PROJECT)

  • Do not add ViewModels without Host registration in BuildHost().
  • Do not hardwire navigation menu items; register pages/groups and let registry/services build menus.
  • Do not new reusable services from pages; put reusable/singleton/testable services in Host.
  • Do not merge page localization into a shared resource bucket.
  • Do not assume dictionary mutations auto-save config; call save after collection mutations or on unload.
  • Do not use Student.Id / Prize.Id as a required identity key; use ProfileRecordIdentity / RecordId and keep legacy Id/Name history fallback ambiguity-safe.
  • Do not treat SecRandom.Desktop as the app logic layer; it is only the launcher.
  • Do not edit bin/, obj/, artifacts/, publish/, packaging scratch dirs, or generated build output.
  • Do not encode temporary local environment facts in AGENTS files; prefer durable rules that future AI agents can maintain.

UNIQUE STYLES

  • Avalonia compiled bindings are enabled in SecRandom/SecRandom.csproj.
  • SecRandom/Styles.axaml is only the app style entrypoint; shared styles live under SecRandom.Core/StylesBase.axaml and SecRandom.Core/Styles/.
  • Shared UI controls are in Core, not only app: DrawerHost, Field, IconText, AppToastAdorner, AttachedSettingsControlPresenter, FluentIcon, FluentIconSource, StickyScrollViewer, TouchDragThumb, Empty, Emptiable, DevelopmentBuildAdorner.
  • UI uses FluentAvalonia NavigationView, SettingsExpander, FluentIconSource, and custom selector classes (compact, nav-back, drawer-left/right, FullWidth).
  • Default font is MiSans from Avalonia resource URI avares://SecRandom/Assets/Fonts/MiSans/#MiSans.
  • README/docs are Chinese-first with English/ZH-TW mirrors under resources/.

COMMANDS

dotnet restore SecRandom.sln
dotnet build SecRandom.sln -c Release --no-restore
dotnet test SecRandom.sln -c Release --no-build
dotnet run --project SecRandom.Desktop/SecRandom.Desktop.csproj
dotnet run --project scripts/FairnessAudit/SecRandom.FairnessAudit.csproj -c Release
dotnet publish SecRandom.Desktop/SecRandom.Desktop.csproj -c Release -r <rid> --self-contained true -o artifacts/SecRandom-<rid> /p:PublishSingleFile=true /p:IncludeNativeLibrariesForSelfExtract=true

CI RIDs: win-x64, win-x86, win-arm64, linux-x64, linux-arm64, osx-x64, osx-arm64.

NOTES

  • Build workflow and CodeQL use .NET SDK 10.0.x, except the iOS job in .github/workflows/build_ios.yml pins SDK and workload set 10.0.401 together with Xcode 26.6 on macos-26. That workflow runs on every push and on manual dispatch, where its optional release_tag input decides whether backfill_release attaches the IPA to that release afterwards (empty means build only). Install iOS with dotnet workload install ios --version 10.0.401; an unpinned workload can require a newer Xcode than the runner provides. Update the SDK/workload/Xcode versions as a compatible set. Xcode is selected through maxim-lobanov/setup-xcode@v1; do not override that selection with DEVELOPER_DIR, and retain its xcode-select / xcrun --find strip checks to catch mixed toolchains.
  • In PowerShell release-workflow regex strings, delimit a variable immediately followed by a colon as ${variable}:; $variable: is parsed as a scoped-variable reference and makes the whole script fail to parse.
  • GlobalConstants.ReadMetadata must never adopt an implausible tag as this build's version. The GitInfo generator embeds whatever git describe printed, and a tagless checkout makes it embed the tool's stderr (fatal: No names found, cannot describe anything.), which then leaks into the crash report, producer_version, the Sentry release, and the update gate — producer_version would no longer be version-shaped and the import gate could reject this build's own archives. LooksLikeVersionTag therefore requires a numeric MAJOR.MINOR and otherwise falls back to v3.0.0-dev, whose major must stay in step with SecRandom.PluginSdk/PluginApiVersions.Current; the same fallback replaces the old v0.0.0.0 placeholder, because major 0 disagrees with every archive this build writes. The commit hash is still kept.
  • OverlayAssetLoader is the desktop's only asset path: the desktop head sets SecRandomBundleAssetsInAssembly=false (SecRandom.Desktop.csproj), so Assets ships as loose files and is deliberately not embedded — a lookup that misses has no fallback and surfaces as Could not create glyphtypeface at the first measured glyph. Its URI matching must therefore stay case-insensitive: the authority casing that reaches this loader is not guaranteed to preserve the assembly name's SecRandom, and a case-sensitive comparison turns that into a total failure. Observed: an installed layout delivered a lowercase avares://secrandom/... while a portable build of the same commit worked, because the portable path happened to keep the original casing — the same defect, exposed on one path only. When a startup failure looks like a font/image error, check this loader first, and keep the Assets-root diagnostic in Program.BindAssetLoader.
  • In PowerShell, a hash-table value holding a single-element array of arrays collapses: @{ pairs = @( @('old','new') ) } makes $job.pairs the inner pair itself, so foreach ($pair in $job.pairs) iterates the two strings and $pair[0]/$pair[1] become their first characters — an "old→new" replacement silently degrades into "O→n" character substitution and rewrites the whole file. When scripting bulk text replacements, keep at least two pairs per group, use the comma operator to force an array, or prefer an exact-match editor. Always diff-stat the result: a multi-line source file showing every line changed is a corrupted rewrite, not an edit.
  • A fork parameter added to a type's constructor silently breaks upstream's new test files. The merge produces no conflict marker for a file only one side touched, and no release build job compiles the test projects, so the first CI run can be green on the app build while six call sites are already broken. After changing any constructor signature, grep every construction site in the repository and verify the argument count by hand — the conflict list is not evidence of completeness.
  • A public member that references a type upstream made internal fails with CS0051 (Inconsistent accessibility), and the error appears only in the assembly that owns the public type. The fix that stays inside fork-owned code is to narrow the fork type (internal sealed class) so it matches its siblings, rather than widening upstream's type. When the reference points the other way — a fork internal type reaching into an upstream public member — narrowing is not available, because the consumer is upstream's public ViewModel: remove the parameter and resolve the service at the point of use instead (IAppHost.TryGetService<T>(), the shape ConfigPolicyLock already uses), which also keeps the dependency optional. Check this before running CI, not after: the compiler only reports it in the assembly that owns the public member.
  • App.IsStopping is an instance member while the quick-draw window helpers and their Closed handler are static, so it must be read as Current is { } app && !app.IsStopping. Referencing instance state from a static helper is CS0120 — the same family as the bullet above: a boundary the compiler enforces and reports only in this assembly, so it is worth checking by hand rather than discovering it in a CI round trip.
  • Written summaries meant for the maintainer belong in Chinese; internal reasoning stays English. This covers session summaries, handover notes, and review lists: the maintainer uses the language to tell a report apart from the agent's own thinking. It does not relax the script rule below — a .ps1/shell body stays ASCII, so carry Chinese as data read from a UTF-8 file rather than as a literal in the script. When you must check Chinese content, read the file through the editor/reader tool instead of probing from a script: a probe that builds the expected string from [char] codes is silently wrong ([char]0x6211 + [char]0x65B9 is integer addition, not 我方), and the console code page mangles whatever it prints, so a probe reporting 0 matches is far more likely broken than the file is.
  • Every Get Latest Tag / Get Version step in build_publish.yml must tolerate a repository with no tags: a fork starts tagless, git describe --tags --abbrev=0 then exits non-zero, and the runner's native-command error preference turns that into a terminating error that fails the whole build. Resolve the tag behind try/catch (or || true in bash), fall back to git tag --sort=-v:refname, and only then to a dev version whose major is parsed from PluginApiVersions.Current (v<major>.0.0-dev / <major>.0.0-dev) with a ::warning::. The fallback major must come from that file rather than a hardcoded 0.0.0-dev: build_pluginsdk enforces that the packed major equals the Plugin SDK API major, and the app/API majors must stay in step. Tag-push and manual-dispatch releases still take precedence over the fallback.
  • The desktop Publish Desktop and UIAccess publish steps must run dotnet publish with -p:BuildInParallel=false -p:UseSharedCompilation=false and must check $LASTEXITCODE after every publish. The two full/light publishes share the same obj/ tree, so parallel builds and the shared VBCSCompiler race intermittently with CS2012 file-lock failures on Windows; a masked (unchecked) failure leaves publish/full incomplete and breaks downstream portable/installer packaging with confusing "file does not exist" errors. Keep the Verify bundled audio runtime step enabled so an incomplete self-contained publish is caught at its source instead of silently producing broken artifacts.
  • The mobile Build Android APK and Publish unsigned iOS arm64 IPA steps must also pass -p:BuildInParallel=false -p:UseSharedCompilation=false (and the iOS step runs with set -euo pipefail). The shared SecRandom.Core/app graph is otherwise scheduled twice in parallel within a single build, racing on obj/Release/net10.0 resource outputs with MSB3554/CS1566/MSB4018 "being used by another process" failures on any platform. The iOS publish is the release critical path: the Mono/LLVM AOT step is ~95% of that job, and the SDK parallelizes it only to Environment.ProcessorCount / 2, which is integer 1 on the 3 vCPU macOS runner; the step therefore passes -p:AotCompileMaxDegreeOfParallelism=2 to keep two cores busy inside the runner's 7 GB. MtouchUseLlvm stays at the SDK's Release default (true) — clearing it would trade runtime code quality for build time.
  • Android release signing decodes ANDROID_SIGNING_KEYSTORE_BASE64 by trimming whitespace and translating URL-safe base64 (-→+, _→/) before Convert.FromBase64String, then validates the bytes with keytool -list before invoking dotnet build. Do not strip non-base64 characters blindly: a junk character (beyond whitespace/URL-safe chars) means the secret value itself is malformed, and deleting it silently corrupts the keystore so apksigner later fails with the opaque MSB6006: "java" exited with code 2. A decode failure must report the offending character's code point and require re-provisioning the secret, never a lossy strip. keytool -J-* options must be passed through array splatting (& keytool @args) — as bare -J-Duser.language=en tokens PowerShell splits them into -J-Duser + .language=en. For this fork the four Android signing secrets are optional: when any is missing the Build Android APK step emits a ::warning::, builds with debug signing, and reports ANDROID_SIGNED=false, so Prepare Android APK uploads the result as ...-android-<arch>-unsigned.apk and skips the keystore/certificate checks; Combine Artifacts filters that -unsigned suffix out so a debug-signed APK never reaches the release output or manifest. When all four secrets are present the strict path is unchanged and must stay strict — keystore base64/size validation, keytool verification, and the apksigner fingerprint comparison against the release keystore. scripts/generate-android-keystore.ps1 generates a fork-owned keystore and prints the secret values; a lost release keystore permanently breaks update compatibility for existing installs. The Android head derives ApplicationDisplayVersion from $(Version) first and only falls back to $(GitTag) when that is unset (mirroring iOS), so a tagless fork still produces a valid versionName; ApplicationVersion (versionCode) stays the workflow's monotonic github.run_number.
  • build_publish.yml triggers on any branch push (push.branches: '**') and on manual dispatch with a release_tag; it has no tag-push trigger, so pushing a tag alone does not start a build (the next branch push resolves that tag through git describe). Release publication itself only runs in the manual-dispatch path, where validate_release checks metadata.yaml before any build.
  • Test project currently includes UnitTest1.cs coverage for legacy privacy/telemetry migration behavior.
  • vendors/EdgeTtsSharp/ is the Edge TTS synthesis submodule. Its embedded voice list and synthesis source are compiled by the app; Services/Voice/EdgeTtsSharpCompatibility.cs supplies the cross-platform transport seam, and all playback remains in ClassIsland's SoundFlow MiniAudio package from the repository-wide MyGet source.
  • README mentions vendors/pythonnet-stub-generator/; treat it as third-party if present in future snapshots.
  • CodeQL workflow (codeQL.yml) runs on push, PR, and weekly schedule; C# scans use manual build mode with .NET SDK 10.0.x.
  • Keep SecRandom.Core public contracts stable.