diff --git a/builds/installer.nsh b/builds/installer.nsh index b906c43cd..bb8b51510 100644 --- a/builds/installer.nsh +++ b/builds/installer.nsh @@ -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." @@ -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. @@ -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" @@ -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..." @@ -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 diff --git a/builds/setup-data.ps1 b/builds/setup-data.ps1 new file mode 100644 index 000000000..b74578bc8 --- /dev/null +++ b/builds/setup-data.ps1 @@ -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() } } diff --git a/docs/simple-desktop-setup.md b/docs/simple-desktop-setup.md new file mode 100644 index 000000000..3d98f10b3 --- /dev/null +++ b/docs/simple-desktop-setup.md @@ -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. diff --git a/package.json b/package.json index 64fead0d2..f8cab4103 100644 --- a/package.json +++ b/package.json @@ -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": [ { @@ -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": { diff --git a/src/browser-cloud-auth.js b/src/browser-cloud-auth.js index 5c3840fb7..52a2bda4a 100644 --- a/src/browser-cloud-auth.js +++ b/src/browser-cloud-auth.js @@ -23,7 +23,8 @@ class BrowserCloudAuth { await this.openExternal(this.active.url); } - authorize({ intent = 'login', machineId, deviceName }) { + authorize({ intent = 'login', machineId, deviceName, website = this.website }) { + const authorizationWebsite = cloudServerUrl(website); if (this.active) return Promise.reject(new Error('Browser authorization is already in progress.')); if (!['login', 'signup'].includes(intent)) return Promise.reject(new Error('Invalid authorization choice.')); return new Promise((resolve, reject) => { @@ -54,7 +55,7 @@ class BrowserCloudAuth { if (!opaque(code)) { res.writeHead(400); res.end('Invalid authorization code.'); return; } exchanging = true; try { - const response = await this.request(`${this.website}/api/desktop/token`, { + const response = await this.request(`${authorizationWebsite}/api/desktop/token`, { method: 'POST', redirect: 'error', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code, codeVerifier, redirectUri }), signal: AbortSignal.any([controller.signal, AbortSignal.timeout(20_000)]), @@ -99,7 +100,7 @@ body{min-height:100vh;margin:0;display:grid;place-items:center}main{box-sizing:b if (settled) { server.close(); return; } redirectUri = `http://127.0.0.1:${server.address().port}/posnic-authorized`; try { - const response = await this.request(`${this.website}/api/desktop/requests`, { + const response = await this.request(`${authorizationWebsite}/api/desktop/requests`, { method: 'POST', redirect: 'error', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ intent, machineId, deviceName, redirectUri, state, codeChallenge }), signal: AbortSignal.any([controller.signal, AbortSignal.timeout(20_000)]), @@ -107,7 +108,7 @@ body{min-height:100vh;margin:0;display:grid;place-items:center}main{box-sizing:b if (!response.ok) throw new Error('Browser sign-in is unavailable. Check your internet connection or use a pairing code.'); const result = await response.json(); const url = new URL(result.authorizationUrl); - if (url.origin !== new URL(this.website).origin || url.pathname !== '/api/desktop/authorize' + if (url.origin !== new URL(authorizationWebsite).origin || url.pathname !== '/api/desktop/authorize' || url.username || url.password || url.hash || !opaque(url.searchParams.get('request')) || [...url.searchParams.keys()].length !== 1) throw new Error('The authorization address could not be verified.'); if (settled) return; diff --git a/src/cloud-reset-ui.js b/src/cloud-reset-ui.js index 65449f549..ccc83b0a5 100644 --- a/src/cloud-reset-ui.js +++ b/src/cloud-reset-ui.js @@ -23,7 +23,7 @@ } catch (error) { status.textContent = error.message; } finally { remove.disabled = keep.disabled = false; - remove.textContent = 'Delete local data and restart'; + remove.textContent = 'Save recovery copy & restart'; } }; })(); diff --git a/src/cloud-setup.html b/src/cloud-setup.html index 69d1e097a..c4d01c9a7 100644 --- a/src/cloud-setup.html +++ b/src/cloud-setup.html @@ -110,9 +110,9 @@

Connect this machine to Posnic Cloud

diff --git a/src/install-wizard.html b/src/install-wizard.html index 82e266df5..b666b2b03 100644 --- a/src/install-wizard.html +++ b/src/install-wizard.html @@ -724,6 +724,7 @@ +
@@ -791,63 +792,37 @@

🎉 Installation Complete!

+ + +
-

Your shop, ready anywhere

Start with Posnic Cloud, or keep your shop on this computer.

-
-
- Recommended -

Posnic Cloud

14-day free trial
-

One shop across your desktop and the cloud.

-

Connect securely in your browser. Your till keeps working when the internet drops.

-
  • Sync sales, stock and customers across your branches
  • View your business from a browser, anywhere
  • Use local staff login after the first download
- - Sign in to an existing cloud account - Have a pairing code? -
-
-

Community Edition

Free
-

Keep your shop on this computer.

-

Set up and sign in without an internet connection or cloud account.

-
  • Run your till fully offline
  • Connect counters and phones over your shop Wi-Fi
  • Manage your own local backups
- -
+

Let’s set up your shop

+

Choose how you want to get started.

+
+ +
-

Cloud needs internet for authorization and the first download. After that, staff sign in locally and changes sync when you reconnect.

+