From eb1926558634cc6850e67529e39aa682dae18eff Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Thu, 11 Jun 2026 05:46:12 +0000 Subject: [PATCH] Kiosk: auto-discover on setup, LAN OTA, English-only GitHub triage. Auto-run LAN discovery on server step; serve kiosk updates from releases/ via kiosk_update API; check LAN before GitHub for OTA in-place upgrades. Docker CI retries hub timeouts. Remove non-English feature issue comments; triage script English-only. Co-authored-by: Cursor --- .github/workflows/build-kiosk.yml | 16 +- .github/workflows/ci.yml | 13 +- api/index.php | 34 ++++ evershelf-kiosk/app/build.gradle.kts | 4 +- .../dadaloop/evershelf/kiosk/KioskActivity.kt | 154 +++++++++++------- .../dadaloop/evershelf/kiosk/SetupActivity.kt | 5 + releases/kiosk-version.json | 4 + scripts/delete-feature-issue-comments.php | 79 +++++++++ scripts/triage-open-issues.php | 70 ++------ 9 files changed, 259 insertions(+), 120 deletions(-) create mode 100644 releases/kiosk-version.json create mode 100644 scripts/delete-feature-issue-comments.php diff --git a/.github/workflows/build-kiosk.yml b/.github/workflows/build-kiosk.yml index 28adb70..10ab749 100644 --- a/.github/workflows/build-kiosk.yml +++ b/.github/workflows/build-kiosk.yml @@ -77,7 +77,21 @@ jobs: sleep 3 gh release create kiosk-latest \ --title "EverShelf Kiosk Latest" \ - --notes "Alias automatico → kiosk-${{ steps.version.outputs.name }} (versionCode ${{ steps.version.outputs.code }})" \ + --notes "Auto alias → kiosk-${{ steps.version.outputs.name }} (versionCode ${{ steps.version.outputs.code }})" \ --prerelease \ artifacts/evershelf-kiosk.apk + - name: Publish APK to releases/ for LAN OTA + env: + GH_TOKEN: ${{ secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }} + run: | + cp artifacts/evershelf-kiosk.apk releases/evershelf-kiosk.apk + printf '{"version":"%s","version_code":%s}\n' \ + "${{ steps.version.outputs.name }}" "${{ steps.version.outputs.code }}" \ + > releases/kiosk-version.json + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add releases/evershelf-kiosk.apk releases/kiosk-version.json + git diff --staged --quiet || git commit -m "chore(kiosk): publish APK v${{ steps.version.outputs.name }} for LAN OTA" + git push origin HEAD:${{ github.ref_name }} + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5d2418..8e0ba94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,18 @@ jobs: - uses: actions/checkout@v6 - name: Build Docker image - run: docker build -t evershelf-test . + run: | + set -e + for attempt in 1 2 3; do + echo "Docker build attempt $attempt/3..." + if docker build -t evershelf-test .; then + exit 0 + fi + echo "Attempt $attempt failed — retrying in 20s..." + sleep 20 + done + echo "Docker build failed after 3 attempts" + exit 1 - name: Test container starts run: | diff --git a/api/index.php b/api/index.php index 488ada4..3a0ec63 100644 --- a/api/index.php +++ b/api/index.php @@ -55,6 +55,12 @@ if (($_GET['action'] ?? '') === 'ping') { exit; } +// ── Kiosk OTA metadata (LAN self-host; no DB required) ─────────────────────── +if (($_GET['action'] ?? '') === 'kiosk_update') { + getKioskUpdate(); + exit; +} + // ── App bootstrap — same-origin browsers receive API token automatically ─────── if (($_GET['action'] ?? '') === 'app_bootstrap') { $required = evershelfApiTokenRequired(); @@ -4923,6 +4929,34 @@ function getConsumptionPredictions(PDO $db): void { // ===== SETTINGS ===== +function getKioskUpdate(): void { + $root = dirname(__DIR__); + $jsonPath = $root . '/releases/kiosk-version.json'; + $apkPath = $root . '/releases/evershelf-kiosk.apk'; + if (!is_file($jsonPath) || !is_file($apkPath)) { + echo json_encode(['success' => false, 'error' => 'not_available']); + return; + } + $meta = json_decode((string)file_get_contents($jsonPath), true); + if (!is_array($meta)) { + echo json_encode(['success' => false, 'error' => 'invalid_metadata']); + return; + } + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https' + ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php'; + $basePath = preg_replace('#/api/index\.php$#', '', $script) ?: ''; + $defaultApkUrl = $scheme . '://' . $host . $basePath . '/releases/evershelf-kiosk.apk'; + echo json_encode([ + 'success' => true, + 'version' => (string)($meta['version'] ?? ''), + 'version_code' => (int)($meta['version_code'] ?? 0), + 'apk_url' => (string)($meta['apk_url'] ?? $defaultApkUrl), + ], JSON_UNESCAPED_UNICODE); +} + function getServerSettings(): void { EverLog::debug('getServerSettings'); $geminiKey = env('GEMINI_API_KEY'); diff --git a/evershelf-kiosk/app/build.gradle.kts b/evershelf-kiosk/app/build.gradle.kts index 9234355..d8187f3 100644 --- a/evershelf-kiosk/app/build.gradle.kts +++ b/evershelf-kiosk/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "it.dadaloop.evershelf.kiosk" minSdk = 24 targetSdk = 35 - versionCode = 19 - versionName = "1.7.18" + versionCode = 20 + versionName = "1.7.19" } signingConfigs { diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt index b0e90ad..ffdfa56 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt @@ -643,6 +643,79 @@ class KioskActivity : AppCompatActivity() { webView.evaluateJavascript("$jsCallback($escaped)", null) } } + + val currentKiosk = try { + packageManager.getPackageInfo(packageName, 0).versionName ?: "" + } catch (_: Exception) { "" } + + val installedVc: Long = try { + val pi = packageManager.getPackageInfo(packageName, 0) + if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode + else @Suppress("DEPRECATION") pi.versionCode.toLong() + } catch (_: Exception) { -1L } + + fun semverNewer(remote: String, local: String): Boolean { + val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 } + val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 } + for (i in 0 until maxOf(r.size, l.size)) { + val rv = r.getOrElse(i) { 0 } + val lv = l.getOrElse(i) { 0 } + if (rv != lv) return rv > lv + } + return false + } + + fun needsUpdate(remoteVersion: String, remoteVc: Long): Boolean = when { + remoteVc > 0 && installedVc >= 0 -> remoteVc > installedVc + currentKiosk.isNotEmpty() && remoteVersion.matches(Regex("\\d+\\.\\d+.*")) -> + semverNewer(remoteVersion, currentKiosk) + else -> false + } + + fun applyUpdate(remoteVersion: String, apkUrl: String) { + val result = JSONObject() + .put("has_update", true) + .put("current", currentKiosk) + .put("latest", remoteVersion) + .put("apk_url", apkUrl) + notifyJs(result) + prefs.edit() + .putString(KEY_PENDING_UPDATE_VERSION, remoteVersion) + .putString(KEY_PENDING_UPDATE_URL, apkUrl) + .apply() + runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk → $remoteVersion", apkUrl) } + } + + // 1) Prefer LAN/self-hosted update (no GitHub required) + val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trim().trimEnd('/') + if (baseUrl.isNotEmpty()) { + try { + val localApi = "$baseUrl/api/index.php?action=kiosk_update" + val conn = openTrustedConnection(localApi) + conn.connectTimeout = 5000 + conn.readTimeout = 5000 + if (conn.responseCode == 200) { + val localJson = JSONObject(conn.inputStream.bufferedReader().readText()) + conn.disconnect() + if (localJson.optBoolean("success")) { + val remoteVersion = localJson.optString("version", "") + val remoteVc = localJson.optLong("version_code", -1L) + val apkUrl = localJson.optString("apk_url", "") + if (apkUrl.isNotEmpty() && needsUpdate(remoteVersion, remoteVc)) { + applyUpdate(remoteVersion, apkUrl) + return@Thread + } + if (!needsUpdate(remoteVersion, remoteVc)) { + notifyJs(JSONObject().put("has_update", false).put("source", "local")) + prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply() + return@Thread + } + } + } else conn.disconnect() + } catch (_: Exception) { /* fall through to GitHub */ } + } + + // 2) GitHub release fallback (requires internet) try { val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection conn.setRequestProperty("Accept", "application/vnd.github+json") @@ -657,51 +730,16 @@ class KioskActivity : AppCompatActivity() { val body = conn.inputStream.bufferedReader().readText() conn.disconnect() val json = JSONObject(body) - val latestTag = json.optString("tag_name", "") - if (latestTag.isEmpty()) { - notifyJs(JSONObject().put("has_update", false).put("error", "no tag")) - return@Thread - } - - val currentKiosk = try { - packageManager.getPackageInfo(packageName, 0).versionName ?: "" - } catch (_: Exception) { "" } - - val installedVc: Long = try { - val pi = packageManager.getPackageInfo(packageName, 0) - if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode - else @Suppress("DEPRECATION") pi.versionCode.toLong() - } catch (_: Exception) { -1L } - - // The kiosk-latest release uses a non-semver tag ("kiosk-latest"). - // Extract the actual kiosk version from the release body text. - // Body format: "Alias automatico → kiosk-X.Y.Z (versionCode N)". val bodyText = json.optString("body", "") val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") } val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""") .find(bodyText)?.groupValues?.get(1) ?.takeIf { it.isNotEmpty() } - ?: norm(latestTag) + ?: norm(json.optString("tag_name", "")) val remoteVc = Regex("""versionCode[=:\s(]+(\d+)""", RegexOption.IGNORE_CASE) .find(bodyText)?.groupValues?.get(1)?.toLongOrNull() ?: -1L - // Compare semver: returns true if `remote` is strictly greater than `local` - fun semverNewer(remote: String, local: String): Boolean { - val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 } - val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 } - val len = maxOf(r.size, l.size) - for (i in 0 until len) { - val rv = r.getOrElse(i) { 0 } - val lv = l.getOrElse(i) { 0 } - if (rv != lv) return rv > lv - } - return false - } - - val isSemver = remoteKioskVersion.matches(Regex("\\d+\\.\\d+.*")) - - // Get APK URL from assets; fall back to the hardcoded KIOSK_DOWNLOAD_URL val assets = json.optJSONArray("assets") var kioskApkUrl = "" if (assets != null) { @@ -715,39 +753,35 @@ class KioskActivity : AppCompatActivity() { } if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL - val kioskNeedsUpdate = when { - remoteVc > 0 && installedVc >= 0 -> remoteVc > installedVc - currentKiosk.isNotEmpty() && isSemver -> semverNewer(remoteKioskVersion, currentKiosk) - else -> false - } - - val result = JSONObject() - .put("has_update", kioskNeedsUpdate) - .put("current", currentKiosk) - .put("latest", remoteKioskVersion) - .put("apk_url", kioskApkUrl) - - notifyJs(result) - - if (!kioskNeedsUpdate) { - // Clear any stale pending update if the current version is now up to date + if (!needsUpdate(remoteKioskVersion, remoteVc)) { + notifyJs(JSONObject().put("has_update", false)) prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply() return@Thread } - - // Persist the pending update so the banner reappears after a crash/restart - prefs.edit() - .putString(KEY_PENDING_UPDATE_VERSION, remoteKioskVersion) - .putString(KEY_PENDING_UPDATE_URL, kioskApkUrl) - .apply() - - runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk → $remoteKioskVersion", kioskApkUrl) } + applyUpdate(remoteKioskVersion, kioskApkUrl) } catch (e: Exception) { notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error")) } }.start() } + /** HTTPS with self-signed cert support (LAN servers). */ + private fun openTrustedConnection(urlStr: String): java.net.HttpURLConnection { + val conn = URL(urlStr).openConnection() + if (conn is javax.net.ssl.HttpsURLConnection) { + val trustAll = arrayOf(object : javax.net.ssl.X509TrustManager { + override fun checkClientTrusted(c: Array?, t: String?) {} + override fun checkServerTrusted(c: Array?, t: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + }) + val sc = javax.net.ssl.SSLContext.getInstance("TLS") + sc.init(null, trustAll, java.security.SecureRandom()) + conn.sslSocketFactory = sc.socketFactory + conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } + } + return conn as java.net.HttpURLConnection + } + /** * On resume: if a previous session detected an available update and saved it to prefs, * restore the update banner immediately without a network round-trip. diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SetupActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SetupActivity.kt index 6ce3444..2a4eac5 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SetupActivity.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SetupActivity.kt @@ -540,6 +540,11 @@ class SetupActivity : AppCompatActivity() { // Cancel auto-discover when leaving server step if (step != 3) discoverCancelled.set(true) + // Auto-discover when entering server step (empty URL only) + if (step == 3 && urlEdit.text.toString().trim().isEmpty()) { + autoDiscover() + } + // Scroll to top try { findViewById(R.id.setupScrollView).scrollTo(0, 0) } catch (_: Exception) {} } diff --git a/releases/kiosk-version.json b/releases/kiosk-version.json new file mode 100644 index 0000000..1a5133c --- /dev/null +++ b/releases/kiosk-version.json @@ -0,0 +1,4 @@ +{ + "version": "1.7.19", + "version_code": 20 +} diff --git a/scripts/delete-feature-issue-comments.php b/scripts/delete-feature-issue-comments.php new file mode 100644 index 0000000..4c44710 --- /dev/null +++ b/scripts/delete-feature-issue-comments.php @@ -0,0 +1,79 @@ +#!/usr/bin/env php + true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 30, + ]); + if ($method === 'DELETE') { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + } elseif ($method === 'GET') { + // default + } + if ($body !== null) { + $headers[] = 'Content-Type: application/json'; + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + } + $raw = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return ['code' => $code, 'body' => $raw]; +} + +$issues = [122, 121, 120, 119, 118, 117, 116, 115, 114, 106, 105, 104, 103, 102, 101, 97, 93, 81, 80, 79, 69, 67, 65]; +$deleted = 0; + +foreach ($issues as $num) { + $page = 1; + while (true) { + $url = 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments?per_page=100&page=$page"; + $r = ghRequest($token, 'GET', $url); + if ($r['code'] !== 200) { + fwrite(STDERR, "#$num list comments HTTP {$r['code']}\n"); + break; + } + $comments = json_decode($r['body'], true); + if (!is_array($comments) || empty($comments)) { + break; + } + foreach ($comments as $c) { + $id = (int)($c['id'] ?? 0); + if ($id <= 0) continue; + $dr = ghRequest($token, 'DELETE', 'https://api.github.com/repos/' . GH_REPO . "/issues/comments/$id"); + if ($dr['code'] === 204) { + $deleted++; + echo "deleted comment $id on #$num\n"; + } else { + fwrite(STDERR, "FAIL delete comment $id on #$num HTTP {$dr['code']}\n"); + } + usleep(200000); + } + if (count($comments) < 100) break; + $page++; + } +} + +echo "Done. Deleted $deleted comments.\n"; diff --git a/scripts/triage-open-issues.php b/scripts/triage-open-issues.php index 82d60d2..720b27c 100644 --- a/scripts/triage-open-issues.php +++ b/scripts/triage-open-issues.php @@ -1,7 +1,8 @@ #!/usr/bin/env php "Risolto in develop: `PRAGMA busy_timeout` portato a 10s e `dbWithRetry()` su `updateInventory` per ritentare su SQLITE_BUSY quando cron smart-shopping e PWA scrivono in parallelo.", - 199 => "Duplicato di #198 — stesso evento (`inventory_update` → database locked). Fix: retry + busy_timeout aumentato.", - 196 => "Risolto in v1.7.38+: `saveProduct` intercetta `UNIQUE constraint failed: products.barcode`, fa merge sul prodotto esistente o risponde 409 JSON (`barcode_already_used`) invece di HTTP 500.", - 197 => "Conseguenza lato PWA del crash PHP #196 — risolto con gestione barcode duplicato in `saveProduct`.", - 195 => "Risolto: `EverLog::request()` ora riceve sempre stringhe — `\$method = (string)(\$_SERVER['REQUEST_METHOD'] ?? 'GET')` (fix CLI/cron che passavano null).", - 193 => "Stesso root cause di #195 (fatal TypeError su `EverLog::request` con method null da CLI). Fix già in develop.", - 194 => "Risolto: `_applySpesaScanUI` usava `currentPage` (inesistente) → corretto in `_currentPageId`.", - 192 => "Risolto: in `renderShoppingItems` la variabile `enriched` veniva referenziata prima della dichiarazione (TDZ). Ora `enrichedRaw` → `_dedupeShoppingByGeneric` → `enriched`.", - 191 => "Risolto: in `_runStartupCheck` `setProgress` è dichiarata prima delle chiamate e `barEl` inizializzato prima dell'uso (niente più TDZ).", - 134 => "Segnalazione auto-report su volume Docker non scrivibile. Mitigazioni: `_ensureDataDir()`, `_ensureDbWritable()`, Dockerfile `chown www-data`. Su Swarm: `chown -R www-data:www-data data` al primo boot.", - 184 => "Correlato a #134: SQLite readonly quando `data/` o `evershelf.db` non sono scrivibili. Fix operativo + chmod WAL/SHM sidecar in `_ensureDbWritable()`.", + 198 => 'Fixed in develop: `PRAGMA busy_timeout` raised to 10s and `dbWithRetry()` on `updateInventory` retries SQLITE_BUSY when cron and PWA write in parallel.', + 199 => 'Duplicate of #198 — same event (`inventory_update` → database locked). Fix: retry + longer busy_timeout.', + 196 => 'Fixed in v1.7.38+: `saveProduct` handles duplicate barcodes (merge or 409 JSON) instead of HTTP 500.', + 197 => 'PWA side-effect of PHP crash #196 — fixed with duplicate barcode handling in `saveProduct`.', + 195 => 'Fixed: `EverLog::request()` always receives strings — `(string)($_SERVER[\'REQUEST_METHOD\'] ?? \'GET\')`.', + 193 => 'Same root cause as #195 (TypeError when method was null from CLI).', + 194 => 'Fixed: `_applySpesaScanUI` referenced `currentPage` → corrected to `_currentPageId`.', + 192 => 'Fixed: TDZ on `enriched` in `renderShoppingItems`.', + 191 => 'Fixed: TDZ on `setProgress` / `barEl` in `_runStartupCheck`.', + 134 => 'Auto-report for non-writable Docker volume. Mitigations: `_ensureDataDir()`, `_ensureDbWritable()`, Dockerfile chown.', + 184 => 'Related to #134: SQLite readonly when `data/` is not writable.', ]; foreach ($bugs as $num => $msg) { - commentIssue($token, $repo, $num, $msg . "\n\n_Chiuso dopo triage — fix in develop._", $dryRun); + commentIssue($token, $repo, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._", $dryRun); closeIssue($token, $repo, $num, $dryRun); } -// Feature/enhancement issues stay OPEN — do not bulk-close backlog items here. - echo "Done.\n";