Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/desktop-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
platform: win32-x64
browsers: 'C:\ExamifyBrowserFixture'
runs-on: ${{ matrix.os }}
timeout-minutes: 30
timeout-minutes: 45
env:
NEXT_TELEMETRY_DISABLED: '1'
PLAYWRIGHT_BROWSERS_PATH: ${{ matrix.browsers }}
Expand All @@ -45,7 +45,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Linux launcher security and packaging tests
if: runner.os == 'Linux'
run: node --test tests/desktop/launcher.test.mjs tests/desktop/windows-inventory.test.mjs tests/desktop/fixture-http-provider.test.mjs
run: node --test tests/desktop/launcher.test.mjs tests/desktop/windows-inventory.test.mjs tests/desktop/fixture-http-provider.test.mjs tests/desktop/upgrades.test.mjs tests/desktop/worker-lock.test.mjs tests/desktop/relocated-helpers.test.mjs
- name: Windows launcher security and packaging tests under a standard user
if: runner.os == 'Windows'
shell: powershell
Expand All @@ -64,6 +64,9 @@ jobs:
- name: Linux installer and packaged app acceptance
if: runner.os == 'Linux'
run: node tests/desktop/acceptance.mjs build/desktop/linux-x64
- name: Linux native cross-version upgrade and crash acceptance
if: runner.os == 'Linux'
run: node tests/desktop/upgrade-acceptance.mjs build/desktop/linux-x64
- name: Windows Command Prompt installer and packaged app under a standard user
if: runner.os == 'Windows'
shell: powershell
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
Applies to this repository. Examify is a small, mobile-first, self-hosted exam-practice
app for personal study, with optional household sharing: Next.js, TypeScript and SQLite.
Keep changes focused and portable. Windows/Linux solo packaging is a preview;
see docs/solo-installation.md before making installation claims.
see docs/solo-installation.md before making installation claims. Protocol-1
portable upgrades use immutable app/state generations and one activation pointer;
never run inactive code against current study data or weaken worker lifetime locks.

## Start here

Expand Down
30 changes: 30 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1351,3 +1351,33 @@ A marker PID is diagnostic metadata, never proof of liveness: after a crash or
PID reuse, an unheld instance lock permits safe marker recovery without a
manual file deletion. These coordination files are outside learner data/config
and must not be unlinked while an operation or instance is active.

### Portable activation protocol 1

Protocol-1 installations select an immutable release slot and optional private
state generation through one `installation.json` pointer. Stable bootstrap v1
selects the release runtime; the launcher revalidates the selected slot under the
operation lock before either reopening a live instance or reading its state.
Missing/corrupt selected state fails closed. Same-version repair creates another
immutable slot rather than renaming the active release away.

The installer owns operation, instance and worker locks while copying. Each real
migration/server worker holds a separate OS-released lease and waits for its
parent’s READY/GO exchange before importing application code or reading settings.
An orphan worker prevents copying even if its parent has died. Migration timeouts
wait for worker exit before releasing launcher protection.

Complete offline data/config copies retain SQLite sidecars. Copy manifests check
all bytes; links, special files and foreign-owned entries are refused. The old
state stays untouched; only the candidate is migrated and health-tested. After
all probe workers stop, candidate files are flushed and one pointer replacement
activates app and state together. No automatic post-commit fallback is allowed.
Old generations and failed candidates are retained rather than silently deleted.

Only protocol-1 higher stable numeric versions can cross-upgrade. Legacy preview
repairs remain legacy; no household conversion, prerelease ordering, silent
downgrade or automatic old-code/new-schema combination is supported. Archive
SHA-256 pins authenticate bytes relative to the trusted installer distribution;
they are not a publisher-signature system. Windows directory durability cannot
be established using these ordinary Node APIs; process-crash acceptance must
not be reported as physical-power-loss proof.
95 changes: 73 additions & 22 deletions docs/solo-installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ not run the unsubstituted installer templates directly from a source checkout.
Keep the launcher terminal open while practicing. Ctrl+C stops the local service;
closing the browser alone does not. Reopening the shortcut uses the existing
running instance and opens a fresh browser session safely. There is no automatic
background service or silent updater in this preview. The installer supports a
fresh install and repair of the same pinned version, replacing app files only
from the newly verified archive. Stop the launcher before repairing. Automatic
cross-version upgrades are refused until their backup/rollback flow is validated.
background service or silent updater in this preview. The installer supports fresh installs and same-version repairs. Upgrade-capable
packages also support an explicit upgrade to a higher stable numeric version
(for example, `0.1.0` to `0.2.0`) using that version’s verified installer. Stop
the launcher first. The original preview format cannot be upgraded in place;
keep that installation and use a separate empty folder. A same-version preview
repair does not convert it into an upgrade-capable installation.
Existing household upgrades still use `install.sh --upgrade`.

## What the launcher does
Expand Down Expand Up @@ -137,21 +139,70 @@ On clean Windows and Linux machines, verify the actual distributed package:

No paid AI calls or real credentials are needed for this acceptance checklist.

## Back up a solo installation

Stop the launcher cleanly, then copy both the stable `data` and `config` folders
to a private backup location. Keep them together; `config` contains authentication
secrets and any saved provider keys. Do not post or share the backup. Copying a
live SQLite database without its journal is not a reliable backup.

The small `.examify-operations` directory at the install root coordinates
launch/repair operations and tracks the running instance with OS-released locks;
it is outside learner data and configuration. Stale process-ID markers after a
crash do not require manual cleanup or prevent relaunch/repair. It is not
part of your study backup and must not be removed while an operation is running.

The existing `examify:data` backup command was designed for household checkouts;
it does not yet include the solo launcher's separate `config/secrets.json` and
provider-settings directory. A data-only backup is not a full solo-installation
backup. Platform installers must retain these stable folders when replacing app
versions; do not uninstall/delete them if you want to keep your progress.
## Upgrade safely

Run the new version-pinned installer against the same installation folder, with
Examify stopped. There is no automatic download or silent downgrade. CI preview
labels and prereleases are not ordered as upgrade versions. The first public
upgrade-capable package must have a version never used by a legacy preview.

An upgrade or upgrade-capable same-version repair:

1. Acquires OS-released installation, launcher and database-worker locks. PID
numbers alone are never evidence that an installation is stopped
2. Preserves the entire previous data/config generation, including SQLite WAL,
material, question banks, results, provider settings and authentication secrets
3. Makes a private, hash-checked candidate copy and requires free space for twice
the copied bytes plus a 128 MiB migration reserve, after package extraction
4. Migrates and starts the actual new application against the candidate, without
opening a browser. It stops all verification workers before activation
5. Replaces one installation pointer, selecting the complete matching app and
study-state generation together. The ordinary shortcut resolves that pointer

Migration, startup or preactivation interruption leaves the previous pair
selected. Orphan candidate folders may remain; they are never automatically
adopted or deleted. Re-running the verified installer prepares a fresh candidate.
After activation, no automatic rollback occurs: newer work might already exist.
Directly running an inactive supported version refuses to open study data.

Use a local filesystem owned by your ordinary OS account. Network shares,
cloud-synced installation folders, links/junctions, hard-linked study files and
shared/foreign-owned state are unsupported. Keep adequate disk space; the reserve
is a safety check, not a bound on every future migration’s requirements. If a
fresh install is interrupted before it creates its installation marker, use a
new empty folder. A damaged immutable bootstrap fails closed and needs recovery
into a separate folder; repair does not overwrite its running Node executable.

The automated checks cover process interruption. They do not simulate physical
power failure or failing storage. In particular, Windows ordinary-user Node APIs
do not provide a proven durable directory-commit guarantee; keep an independent
backup before upgrading. This feature does not claim guaranteed recovery from
sudden power loss on Windows.

## Back up or recover a solo installation

Stop Examify and copy the **whole installation folder** to a private location.
This retains the installation pointer, matching application/runtime, all state
generations, configuration and recovery metadata. After an upgrade, `data` and
`config` at the root can be an older snapshot: copying only those folders can
miss current work. Keep backup copies private; they include authentication
secrets and saved provider keys. Never publish or share them.

To recover, retain the damaged installation unchanged, restore the complete
known-good backup into a separate private folder, and launch its `Examify` or
`Examify.cmd` entrypoint. Do not overlay old app files onto a newer database or
manually change just the version field. Recovery to a pre-upgrade backup loses
changes made after that snapshot; preserve the newer installation for possible
recovery of that work. Never run two copies against the same data directory.

The retained previous generation and its matching release provide a recovery
source, not a second automatically active installation. The previous pointer is
recorded in `installation.json`, and candidate `backup-source.json` records the
source snapshot’s hashes. Advanced selective recovery requires keeping that
matched pair together in a separate folder; there is no one-click rollback or
automatic old-generation cleanup in this release.

The `.examify-operations` databases are coordination files, outside study state.
Do not remove them while an operation is active. Stale process markers after a
crash do not require manual deletion. The household `examify:data` backup command
does not capture this complete solo layout; use the full-folder backup above.
10 changes: 2 additions & 8 deletions install-solo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ if [ -d "$ROOT" ]; then
fail 'The destination contains other files. Choose an empty dedicated Examify folder.'
fi
fi
for ENTRY in installation.json Examify releases config data "releases/$VERSION"; do
for ENTRY in installation.json Examify bootstrap releases config data "releases/$VERSION"; do
[ ! -L "$ROOT/$ENTRY" ] || fail 'Existing installation paths must not be symbolic links.'
done
umask 077
Expand Down Expand Up @@ -76,13 +76,7 @@ NODE="$STAGE/app/runtime/bin/node"
cp -- "$NODE" "$STAGE/install-node"
chmod 700 "$STAGE/install-node"
"$STAGE/install-node" "$STAGE/app/scripts/desktop/install-release.mjs" "$ROOT" "$STAGE/app" "$VERSION"
cat > "$ROOT/Examify" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
SH
printf 'exec "$ROOT/releases/%s/runtime/bin/node" "$ROOT/releases/%s/scripts/launcher.mjs" --root "$ROOT" "$@"\n' "$VERSION" "$VERSION" >> "$ROOT/Examify"
chmod 700 "$ROOT/Examify"
# The locked release coordinator installed the stable entrypoint before activation.
if [ "$SHORTCUT" = 1 ]; then
DESKTOP="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
mkdir -p "$DESKTOP"
Expand Down
9 changes: 2 additions & 7 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ if (Test-Path -LiteralPath $InstallRoot) {
throw 'The destination contains other files. Choose an empty dedicated Examify folder.'
}
}
foreach ($Relative in @('installation.json', 'Examify.cmd', 'releases', 'config', 'data', "releases/$Version")) {
foreach ($Relative in @('installation.json', 'Examify.cmd', 'bootstrap', 'releases', 'config', 'data', "releases/$Version")) {
$Candidate = Join-Path $InstallRoot $Relative
if ((Test-Path -LiteralPath $Candidate) -and ((Get-Item -LiteralPath $Candidate -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw 'Existing installation paths must not be links or junctions.'
Expand Down Expand Up @@ -87,12 +87,7 @@ try {
& $InstallerNode (Join-Path $App 'scripts/desktop/install-release.mjs') $InstallRoot $App $Version
if ($LASTEXITCODE -ne 0) { throw 'Could not safely install this release. Existing learner data was preserved.' }
$Launcher = Join-Path $InstallRoot 'Examify.cmd'
@"
@echo off
setlocal
"%~dp0releases\$Version\runtime\node.exe" "%~dp0releases\$Version\scripts\launcher.mjs" --root "%~dp0." %*
if errorlevel 1 pause
"@ | Set-Content -Encoding ASCII -LiteralPath $Launcher
# The locked coordinator installed this stable entrypoint before activation.
if (-not $NoShortcut) {
$Shell = New-Object -ComObject WScript.Shell
$Link = $Shell.CreateShortcut((Join-Path ([Environment]::GetFolderPath('Programs')) 'Examify.lnk'))
Expand Down
12 changes: 11 additions & 1 deletion scripts/desktop/acceptance-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ try {
$Result = Join-Path $Root 'result.txt'
$Exit = Join-Path $Root 'exit.txt'
$UnitTests = Join-Path $Repository 'tests/desktop/launcher.test.mjs'
$UpgradeTests = Join-Path $Repository 'tests/desktop/upgrades.test.mjs'
$WorkerTests = Join-Path $Repository 'tests/desktop/worker-lock.test.mjs'
$RelocatedTests = Join-Path $Repository 'tests/desktop/relocated-helpers.test.mjs'
$UpgradeAcceptance = Join-Path $Repository 'tests/desktop/upgrade-acceptance.mjs'
$InventoryTests = Join-Path $Repository 'tests/desktop/windows-inventory.test.mjs'
$UserTemp = Join-Path $Root 'UserTemp'
$RunAcceptance = if ($TestsOnly) { '$false' } else { '$true' }
Expand All @@ -52,7 +56,7 @@ try {
`$env:TEMP = $(Quote $UserTemp)
`$env:TMP = $(Quote $UserTemp)
`$ErrorActionPreference = 'Continue'
& $(Quote $Node) --test $(Quote $UnitTests) $(Quote $InventoryTests) *> $(Quote $Result)
& $(Quote $Node) --test $(Quote $UnitTests) $(Quote $InventoryTests) $(Quote $UpgradeTests) $(Quote $WorkerTests) $(Quote $RelocatedTests) *> $(Quote $Result)
`$Code = `$LASTEXITCODE
`$ErrorActionPreference = 'Stop'
if (`$Code -eq 0 -and $RunAcceptance) {
Expand All @@ -61,6 +65,12 @@ try {
`$Code = `$LASTEXITCODE
`$ErrorActionPreference = 'Stop'
}
if (`$Code -eq 0 -and $RunAcceptance) {
`$ErrorActionPreference = 'Continue'
& $(Quote $Node) $(Quote $UpgradeAcceptance) $(Quote $Artifact) *>> $(Quote $Result)
`$Code = `$LASTEXITCODE
`$ErrorActionPreference = 'Stop'
}
`$Code | Set-Content -LiteralPath $(Quote $Exit)
exit `$Code
} catch {
Expand Down
Loading
Loading