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
136 changes: 104 additions & 32 deletions builds/installer.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ SetDatablockOptimize on
; Owner: "i saw message like pos installed on the end while uninstalled."
!ifdef BUILD_UNINSTALLER
!define MUI_WELCOMEPAGE_TITLE "Remove Posnic"
!define MUI_WELCOMEPAGE_TEXT "This removes the Posnic application from this computer.$\r$\n$\r$\nYour sales, stock and customers stay in the data folder; they are not deleted.$\r$\n$\r$\nClose any running copy of Posnic before continuing."
!define MUI_WELCOMEPAGE_TEXT "This removes the Posnic application from this computer.$\r$\n$\r$\nChoose which business data, hardware settings and backups to keep on the next screen.$\r$\n$\r$\nClose any running copy of Posnic before continuing."
!define MUI_FINISHPAGE_TITLE "Posnic is removed"
!define MUI_FINISHPAGE_TEXT "The application has been removed from this computer.$\r$\n$\r$\nYour data folder was kept, so installing Posnic again picks up where you left off."
!define MUI_FINISHPAGE_TEXT "$POSNIC_REMOVAL_SUMMARY"
!else
!define MUI_WELCOMEPAGE_TITLE "Welcome to Posnic"
!define MUI_WELCOMEPAGE_TEXT "Posnic is a point of sale that works without internet. Your sales, stock and customers stay on this computer.$\r$\n$\r$\nSetup installs the application and its database. The first launch takes a few minutes while the database is prepared; later launches take seconds.$\r$\n$\r$\nClose any running copy of Posnic before continuing."
Expand All @@ -51,7 +51,18 @@ ShowUninstDetails show

Var POSNIC_REPAIR_MODE
Var POSNIC_START_MENU_FOLDER
Var POSNIC_DELETE_DATA
Var POSNIC_KEEP_BUSINESS
Var POSNIC_KEEP_HARDWARE
Var POSNIC_KEEP_BACKUPS
Var POSNIC_BUSINESS_CHECK
Var POSNIC_HARDWARE_CHECK
Var POSNIC_BACKUPS_CHECK
Var POSNIC_REMOVAL_SUMMARY
!include nsDialogs.nsh
!include LogicLib.nsh
; electron-builder loads custom includes before its assisted UI template.
; Our page functions use MUI_HEADER_TEXT while this file is parsed.
!include MUI2.nsh

; Posnic stores its runtime and user-specific data under the current profile.
; Force per-user installation and skip the redundant all-users/current-user page.
Expand Down Expand Up @@ -103,6 +114,12 @@ Var POSNIC_DELETE_DATA
normal_install_mode:
DetailPrint "INSTALL/UPDATE MODE"
install_mode_done:
IfSilent setup_review_done
CreateDirectory "$APPDATA\posnic"
FileOpen $0 "$APPDATA\posnic\.setup-review" w
FileWrite $0 "manual-install"
FileClose $0
setup_review_done:

; ── Close any running instance of Posnic before installing ──
DetailPrint "[TIME CHECK] Step 1 START: Process termination"
Expand Down Expand Up @@ -206,37 +223,97 @@ Var POSNIC_DELETE_DATA
DetailPrint "[TIME CHECK] Installation process finished"
!macroend

; Let the user decide about their data; keeping it is the safe default.
; Deleting requires a second, explicit confirmation with a backup warning -
; for a local-only shop this data is the ONLY copy of their sales history.
; Preserve everything during silent upgrades. The normal uninstall has three
; independent keep choices, matching the desktop setup design.
!macro customUnInit
StrCpy $POSNIC_DELETE_DATA "0"
StrCpy $POSNIC_KEEP_BUSINESS "1"
StrCpy $POSNIC_KEEP_HARDWARE "1"
StrCpy $POSNIC_KEEP_BACKUPS "1"
StrCpy $POSNIC_REMOVAL_SUMMARY "The application was removed. Business data, hardware settings and backups were kept."
!macroend

; An upgrade runs the OLD uninstaller first, silently, purely to clear out the
; previous files - the installer's own log shows it as
; old-uninstaller.exe /S /KEEP_APP_DATA
; Asking about data there is worse than pointless. The answer is always
; "keep", the person is in the middle of an install they think is routine, and
; a mis-click on "No" erases the only copy of their sales history. A silent
; run must keep the data and say nothing.
;
; NSIS does not skip a MessageBox in silent mode on its own: without /SD it
; still draws the dialog and waits, which is why this appeared mid-install and
; stalled there until it was answered.
IfSilent unposnic_done
!macro customUnWelcomePage
!insertmacro MUI_UNPAGE_WELCOME
UninstPage custom un.PosnicDataPage un.PosnicDataLeave
!macroend

MessageBox MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON1 "Keep your business data and hardware settings?$\r$\n$\r$\nYES - Keep my data (recommended for updates and test reinstalls). Keeps sales, backups, printer selections, paper sizes and other hardware settings. Reinstall using the same Windows account.$\r$\n$\r$\nNO - I want to permanently delete everything from this computer." /SD IDYES IDYES unposnic_done
!ifdef BUILD_UNINSTALLER
Function un.PosnicDataPage
IfSilent 0 +2
Abort
!insertmacro MUI_HEADER_TEXT "Remove Posnic?" "The application will be removed. Choose what to keep."
nsDialogs::Create 1018
Pop $0
${NSD_CreateCheckbox} 0 12u 100% 20u "Keep business data"
Pop $POSNIC_BUSINESS_CHECK
${NSD_SetState} $POSNIC_BUSINESS_CHECK $POSNIC_KEEP_BUSINESS
${NSD_CreateLabel} 14u 34u 95% 22u "Sales, stock, customers and pending changes."
Pop $0
${NSD_CreateCheckbox} 0 62u 100% 20u "Keep hardware settings"
Pop $POSNIC_HARDWARE_CHECK
${NSD_SetState} $POSNIC_HARDWARE_CHECK $POSNIC_KEEP_HARDWARE
${NSD_CreateLabel} 14u 84u 95% 22u "Scales, printers and kitchen screens."
Pop $0
${NSD_CreateCheckbox} 0 112u 100% 20u "Keep saved backups"
Pop $POSNIC_BACKUPS_CHECK
${NSD_SetState} $POSNIC_BACKUPS_CHECK $POSNIC_KEEP_BACKUPS
${NSD_CreateLabel} 0 145u 100% 36u "Cloud data will not be deleted. Before removing business data, a verified recovery copy is saved separately in Documents."
Pop $0
nsDialogs::Show
FunctionEnd

MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "WARNING - PERMANENT DELETION$\r$\n$\r$\nThis will erase ALL sales history, inventory, customers and settings from this computer. If you do not use Posnic Cloud, this is the ONLY copy of your business data - it CANNOT be recovered.$\r$\n$\r$\nYour backup folder (Documents\Posnic-Backups) will also be deleted. If you may ever need this data, click NO now and first copy that folder to a pen drive or another computer.$\r$\n$\r$\nPermanently delete everything?" /SD IDNO IDYES unposnic_wipe
Goto unposnic_done
Function un.PosnicDataLeave
${NSD_GetState} $POSNIC_BUSINESS_CHECK $POSNIC_KEEP_BUSINESS
${NSD_GetState} $POSNIC_HARDWARE_CHECK $POSNIC_KEEP_HARDWARE
${NSD_GetState} $POSNIC_BACKUPS_CHECK $POSNIC_KEEP_BACKUPS
StrCpy $POSNIC_REMOVAL_SUMMARY "Cloud data unchanged.$\r$\n"
${If} $POSNIC_KEEP_BUSINESS == 1
StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nBusiness data: kept"
${Else}
StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nBusiness data: removed locally; recovery copy kept in Documents\Posnic-Recovery"
${EndIf}
${If} $POSNIC_KEEP_HARDWARE == 1
StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nHardware settings: kept"
${Else}
StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nHardware settings: deleted"
${EndIf}
${If} $POSNIC_KEEP_BACKUPS == 1
StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nSaved backups: kept"
${Else}
StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nSaved backups: deleted"
${EndIf}
${If} $POSNIC_KEEP_BUSINESS == 1
${AndIf} $POSNIC_KEEP_HARDWARE == 1
${AndIf} $POSNIC_KEEP_BACKUPS == 1
Return
${EndIf}
MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "Apply these choices?$\r$\n$POSNIC_REMOVAL_SUMMARY$\r$\n$\r$\nSelected local data will be removed. Continue?" /SD IDNO IDYES +2
Abort
FunctionEnd

unposnic_wipe:
StrCpy $POSNIC_DELETE_DATA "1"
unposnic_done:
!macroend
Function un.PosnicApplyData
IfSilent 0 +2
Return
${If} $POSNIC_KEEP_BUSINESS == 1
${AndIf} $POSNIC_KEEP_HARDWARE == 1
${AndIf} $POSNIC_KEEP_BACKUPS == 1
Return
${EndIf}
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\setup-data.ps1" -DataRoot "$APPDATA\posnic" -BackupsRoot "$DOCUMENTS\Posnic-Backups" -RecoveryRoot "$DOCUMENTS\Posnic-Recovery" -AppExecutable "$INSTDIR\${APP_EXECUTABLE_FILENAME}" -HardwareManifest "$INSTDIR\resources\setup-hardware-files.json" -KeepBusiness $POSNIC_KEEP_BUSINESS -KeepHardware $POSNIC_KEEP_HARDWARE -KeepBackups $POSNIC_KEEP_BACKUPS'
Pop $0
Pop $1
${If} $0 != 0
MessageBox MB_OK|MB_ICONSTOP "Removal stopped.$\r$\n$1" /SD IDOK
Abort
${EndIf}
FunctionEnd
!endif

; Custom uninstallation steps
!macro customUnInstall
; Apply choices only when uninstall starts, so Back/Cancel on earlier pages
; cannot change data. This hook runs before electron-builder removes files.
Call un.PosnicApplyData
DetailPrint "Removing Posnic..."
DetailPrint "Removing startup launcher..."

Expand All @@ -250,10 +327,5 @@ Var POSNIC_DELETE_DATA
DeleteRegValue HKCU "Software\Posnic" "Start Menu Folder"
DeleteRegKey /ifempty HKCU "Software\Posnic"

DetailPrint "Cleaning up application data..."
StrCmp $POSNIC_DELETE_DATA "1" 0 unskipdata
DetailPrint "Deleting database and backups (user choice)..."
RMDir /r "$APPDATA\posnic"
RMDir /r "$DOCUMENTS\Posnic-Backups"
unskipdata:
; Data choices were applied and verified before application removal.
!macroend
99 changes: 99 additions & 0 deletions builds/setup-data.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
param(
[string]$DataRoot, [string]$BackupsRoot, [string]$RecoveryRoot,
[string]$HardwareManifest, [string]$AppExecutable,
[ValidateSet('0','1')][string]$KeepBusiness = '1',
[ValidateSet('0','1')][string]$KeepHardware = '1',
[ValidateSet('0','1')][string]$KeepBackups = '1',
[switch]$LibraryOnly
)
$ErrorActionPreference = 'Stop'

function Assert-DirectChild([string]$Parent, [string]$Child) {
$resolvedParent = [IO.Path]::GetFullPath($Parent).TrimEnd('\')
$resolvedChild = [IO.Path]::GetFullPath($Child).TrimEnd('\')
if ([IO.Path]::GetDirectoryName($resolvedChild) -ne $resolvedParent) { throw 'Unexpected data folder. No data was removed.' }
if (Test-Path -LiteralPath $resolvedChild) {
if ((Get-Item -LiteralPath $resolvedChild -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Linked data folders cannot be removed here.' }
}
return $resolvedChild
}
function Get-RecoveryHash([string]$File) {
$stream = [IO.File]::OpenRead($File)
$digest = [Security.Cryptography.SHA256]::Create()
try { return [BitConverter]::ToString($digest.ComputeHash($stream)) }
finally { $stream.Dispose(); $digest.Dispose() }
}
function Copy-VerifiedTree([string]$Source, [string]$Destination) {
[IO.Directory]::CreateDirectory($Destination) | Out-Null
foreach ($entry in Get-ChildItem -LiteralPath $Source -Force) {
if ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Recovery cannot follow linked folders.' }
# These database lock handles are held exclusively by this helper.
if ($entry.Name -in @('mongod.lock', 'WiredTiger.lock')) { continue }
$target = Join-Path $Destination $entry.Name
if ($entry.PSIsContainer) { Copy-VerifiedTree $entry.FullName $target }
else {
[IO.File]::Copy($entry.FullName, $target, $false)
if ((Get-RecoveryHash $entry.FullName) -ne (Get-RecoveryHash $target)) {
throw 'Recovery verification failed. Existing data was not removed.'
}
}
}
}
function Remove-CheckedTree([string]$Root) {
# Check every descendant before Remove-Item can traverse it.
if (!(Test-Path -LiteralPath $Root)) { return }
$linked = Get-ChildItem -LiteralPath $Root -Recurse -Force | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }
if ($linked) { throw 'Linked folders cannot be deleted here.' }
Remove-Item -LiteralPath $Root -Recurse -Force
}
if ($LibraryOnly) { return }
$locks = @()
try {
$documents = [Environment]::GetFolderPath('MyDocuments')
$DataRoot = Assert-DirectChild $env:APPDATA $DataRoot
$BackupsRoot = Assert-DirectChild $documents $BackupsRoot
$RecoveryRoot = Assert-DirectChild $documents $RecoveryRoot
foreach ($running in Get-Process) {
try { $runningPath = $running.Path } catch { continue }
if ($AppExecutable -and $runningPath -and $runningPath -eq $AppExecutable) {
throw 'The application is still running. Close it before removing data.'
}
}
if ($RecoveryRoot -eq $BackupsRoot -or $DataRoot -eq $BackupsRoot) { throw 'Recovery must be separate from deleted folders.' }
$hardwareFiles = Get-Content -LiteralPath $HardwareManifest -Raw | ConvertFrom-Json
foreach ($name in $hardwareFiles) {
if ([IO.Path]::GetFileName($name) -ne $name) { throw 'Invalid hardware manifest.' }
}
if ($KeepBusiness -eq '0' -and (Test-Path -LiteralPath $DataRoot)) {
foreach ($name in @('mongodb\data\mongod.lock', 'mongodb\data\WiredTiger.lock')) {
$lockFile = Join-Path $DataRoot $name
if (Test-Path -LiteralPath $lockFile) { $locks += [IO.File]::Open($lockFile, 'Open', 'ReadWrite', 'None') }
}
$copyRoot = Join-Path $RecoveryRoot ('recovery-' + [Guid]::NewGuid().ToString('N'))
Copy-VerifiedTree $DataRoot $copyRoot
[IO.File]::WriteAllText((Join-Path $copyRoot 'recovery-info.txt'), 'Verified local recovery copy. Not restored or uploaded automatically. Restore only with the original Windows account and compatible application version.')
foreach ($lock in $locks) { $lock.Dispose() }; $locks = @()
# Leave selected hardware files in place; remove all other entries only
# after the entire recovery copy has passed hash verification.
foreach ($entry in Get-ChildItem -LiteralPath $DataRoot -Force) {
if ($KeepHardware -eq '1' -and $entry.Name -in $hardwareFiles) { continue }
if ($KeepBackups -eq '1' -and $entry.Name -eq 'backups') { continue }
if ($entry.PSIsContainer) { Remove-CheckedTree $entry.FullName }
else { Remove-Item -LiteralPath $entry.FullName -Force }
}
} elseif ($KeepHardware -eq '0') {
foreach ($name in $hardwareFiles) {
$target = Join-Path $DataRoot $name
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Force }
}
}
if ($KeepBackups -eq '0') {
Remove-CheckedTree $BackupsRoot
Remove-CheckedTree (Join-Path $DataRoot 'backups')
}
Write-Output 'Selected local data choices applied. Cloud data unchanged.'
exit 0
} catch {
Write-Output ('Could not finish safely. Close the application and retry. ' + $_.Exception.Message)
exit 1
} finally { foreach ($lock in $locks) { $lock.Dispose() } }
64 changes: 64 additions & 0 deletions docs/simple-desktop-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Desktop setup and local data choices

Manual Windows installs mark the next launch for setup review. Existing shops
can continue with their data, or start fresh while retaining hardware settings
by default. Ordinary launches and silent updates skip this review.

Cloud setup offers a primary branded browser sign-in, a collapsed pairing-code
section, and a separate collapsed custom-server section. The custom endpoint is
used only for that browser authorization attempt; it never changes the default
sign-in or pairing destination. Custom servers must implement the existing
desktop authorization and sync APIs. Community offline setup remains available.

The Windows uninstaller defaults to keeping business data, hardware settings,
and saved backups. Unchecking a choice requires confirmation. Recovery copies
remain even when saved backups are removed; the screen explicitly explains this.
Custom backup locations are not deleted by the uninstaller.

## Recovery and ownership

Fresh setup verifies ownership of the bundled database, stops sync, connectors,
the API, and the database, then saves and hashes the database and its credential
files before removing active data. A database that is still running, a foreign
database, or a failed copy stops replacement. Recovery copies are stored outside
the active database under Documents in the application's Recovery folder.
The uninstaller similarly refuses a running application or locked database and
verifies a full data-folder snapshot before removing business data.

Recovery copies contain unsynced business records and private credentials. They
are not ordinary export files. Recovery requires the original OS account and a
compatible database/application version; automatic restore or upload is not
implemented. Back up the recovery folder securely. Files outside the application
data folder, including custom attachment storage, require their own backup.

Hardware preferences, scales, cash drawers, kitchen settings, setup markers and
recovery copies are local to this computer. There are no cloud device profiles.
Fresh setup removes connector configuration and pending print state so they do
not reconnect to or replay work from the previous shop.

## Synchronization decision

No shared business fields or collection lanes are added. Existing business data
retains its current tenant and branch scope. While the manual reinstall review
is open, sync and connectors do not start. Continuing resumes normal sync after
a graceful restart. Starting fresh disconnects the old identity and retains its
unsynced work only in recovery; it does not upload that work into a new shop.
Deleting local data never emits cloud deletion requests. Web and other tills
therefore remain unchanged by these local choices.

The existing sync agent remains responsible for authenticated identity checks,
checkpoints, record conflict handling and explicit deletion events. Absence of
a cloud record is not newly interpreted as deletion. This implementation does
not repair legacy missing tombstones or decide to upload orphaned local records.
No Gateway or agent changes are required for these setup controls.

## Release verification

Regression coverage includes all eight uninstall keep combinations on Windows,
copy failure without deletion, preserved hardware, database shutdown ordering,
live-process refusal, existing-shop UI and custom/default authentication
isolation. The NSIS custom pages have also been compiled in a standalone harness.
Before a client release, build the branded installer and verify its substituted
data paths, fresh/offline setup, reinstall, uninstall, and cloud reconnection on
an isolated Windows profile. End-to-end live cloud/till delivery is not proven
by these local regression checks.
14 changes: 13 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,11 @@
"src/fonts/NotoSansSC.ttf",
"src/fonts/NotoSansSC-OFL.txt",
"src/fonts/NotoSansTC.ttf",
"src/fonts/NotoSansTC-OFL.txt"
"src/fonts/NotoSansTC-OFL.txt",
"src/simple-setup.css",
"src/simple-setup.js",
"src/setup-recovery.js",
"src/setup-hardware-files.json"
],
"extraResources": [
{
Expand Down Expand Up @@ -464,6 +468,14 @@
"!worker.json",
"!*.json"
]
},
{
"from": "builds/setup-data.ps1",
"to": "setup-data.ps1"
},
{
"from": "src/setup-hardware-files.json",
"to": "setup-hardware-files.json"
}
],
"win": {
Expand Down
Loading
Loading