From 1fee7f1127fcafe255719d9a619a8ffe8d39435c Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 30 Oct 2026 08:39:51 +0100 Subject: [PATCH 1/6] Label apps that ask for a newer Tizen than this TV, and stop their install before the push (#47) The catalog now says what Tizen each file asks for (required_version, from tizen-community-packages and the Apps2Samsung snapshot). The page reads this TV's platform version and: - badges a build the TV is too old for with "Needs Tizen X" on cards, the selected card, pinned cards and variant rows, dims the card, and disables Install with the reason in the sheet; an app with a build for older sets lands on that build; - sends the TV's version with every install, so the service checks the package's own manifest (required_version / api-version) before signing and pushing, for catalog installs and uploaded files alike, and answers NEEDS_NEWER_TIZEN with the two versions; - when the TV still refuses with [118, -19] (118019 on a 2018 set) or [118, -4], the message says what the package asks for instead of the bare code. Settings shows this TV's Tizen version. Refs #44: IPTV-Manager asks for Tizen 6.0 and the reporter's 2018 set runs 4.0, which the TV reported as install failed[118019]. --- README.md | 6 ++-- test/manifest.test.js | 15 ++++++--- tizen-app/config.xml | 2 +- tizen-app/js/app.js | 63 ++++++++++++++++++++++++----------- tizen-app/service/index.js | 40 ++++++++++++++++++++-- tizen-app/service/manifest.js | 25 +++++++++++--- 6 files changed, 118 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 1eaa1fc..32ee635 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ colours, big tiles for a couch. release that means the [3.0 beta](https://github.com/Apps2Samsung/Apps2Samsung/releases); 2.x has no Arm TV-Store button. - A Samsung TV in Developer Mode. Tested on Tizen 5.0 (2019) and Tizen 10 (2025); TV-Store asks for Tizen 3.0 - (2017) or newer. + (2017) or newer. Every app in the catalog asks for its own Tizen version; one this TV is too old for + shows a "Needs Tizen X" badge and does not install from here (the TV would refuse it with + `install failed[118, -19]`, #44). Where the app has a build for older sets, the sheet lands on that one. ## Setting it up @@ -123,7 +125,7 @@ The header counts what the switches hide ("12 apps · 3 hidden"). - `tizen-app/service/signer.js`: the package signer - `tizen-app/service/catalog.js` + `catalog-key.js`: the signed catalog check and its public key - `tizen-app/service/compat.js`: buffers that work on the TV's Node 4.4.3 -- `tizen-app/service/manifest.js`: the ids an uploaded package carries in its own manifest +- `tizen-app/service/manifest.js`: the ids and the required Tizen version a package carries in its own manifest - `tizen-app/js/app.js`: the UI, and `window.A2SStore`, which Apps2Samsung calls to arm the TV - `tizen-app/js/qrcode.js`: QR codes for the log address and the Ko-fi link ([qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) 1.4.4, MIT) - `test/`: a fake `sdbd` plus signer and transport tests diff --git a/test/manifest.test.js b/test/manifest.test.js index a8a59a4..274d3b8 100644 --- a/test/manifest.test.js +++ b/test/manifest.test.js @@ -1,5 +1,6 @@ /* manifest.js reads the ids of a .wgt (config.xml) and a .tpk (tizen-manifest.xml) from the - * package on disk, and says what is wrong with anything else. */ + * package on disk, with the Tizen version each asks for (#47), and says what is wrong with + * anything else. needsNewer is the version check the service gates an install on. */ 'use strict'; var fs = require('fs'), os = require('os'), path = require('path'); var zip = require('../tizen-app/service/zip.js'); @@ -23,16 +24,22 @@ var notZip = path.join(dir, 'd.wgt'); fs.writeFileSync(notZip, 'hello'); manifest.read(wgt, function (err, ids) { if (err) fail('wgt: ' + err.message); - if (ids.kind !== 'wgt' || ids.appId !== 'NuvioTV002.Nuvio' || ids.packageId !== 'NuvioTV002' || ids.version !== '1.2.3' || ids.name !== 'Nuvio Legacy') fail('wgt ids ' + JSON.stringify(ids)); + if (ids.kind !== 'wgt' || ids.appId !== 'NuvioTV002.Nuvio' || ids.packageId !== 'NuvioTV002' || ids.version !== '1.2.3' || ids.name !== 'Nuvio Legacy' || ids.requiredVersion !== '4.0') fail('wgt ids ' + JSON.stringify(ids)); manifest.read(tpk, function (err, ids) { if (err) fail('tpk: ' + err.message); - if (ids.kind !== 'tpk' || ids.appId !== 'org.apps2samsung.overscan.app' || ids.packageId !== 'org.apps2samsung.overscan' || ids.version !== '0.2.0' || ids.name !== 'Overscan') fail('tpk ids ' + JSON.stringify(ids)); + if (ids.kind !== 'tpk' || ids.appId !== 'org.apps2samsung.overscan.app' || ids.packageId !== 'org.apps2samsung.overscan' || ids.version !== '0.2.0' || ids.name !== 'Overscan' || ids.requiredVersion !== '4.0') fail('tpk ids ' + JSON.stringify(ids)); + var noReq = manifest.parse('wgt', ''); + if (noReq.requiredVersion !== '') fail('a manifest without required_version must give \'\', got ' + JSON.stringify(noReq.requiredVersion)); + // The gate: 6.0 on a 4.0 set is refused, 2.3 on 4.0 and 4.0 on 4.0 are not, unknown on either side is not (#44). + [['6.0', '4.0', true], ['6.0', '4.0.0', true], ['5.5', '5.0', true], ['2.3', '4.0', false], ['4.0', '4.0', false], ['4.0', '10.0', false], ['', '4.0', false], ['6.0', '', false], ['6.0', 'x', false]].forEach(function (c) { + if (manifest.needsNewer(c[0], c[1]) !== c[2]) fail('needsNewer(' + JSON.stringify(c[0]) + ', ' + JSON.stringify(c[1]) + ') must be ' + c[2]); + }); manifest.read(noManifest, function (err) { if (!err || err.message.indexOf('no config.xml') < 0) fail('a package without a manifest must be refused: ' + (err && err.message)); manifest.read(notZip, function (err) { if (!err || err.message.indexOf('not a Tizen package') < 0) fail('a non-zip must be refused: ' + (err && err.message)); try { fs.readdirSync(dir).forEach(function (f) { fs.unlinkSync(path.join(dir, f)); }); fs.rmdirSync(dir); } catch (e) {} - console.log('PASS: manifest ids read from a .wgt and a .tpk; no manifest and not-a-zip refused'); + console.log('PASS: manifest ids and required Tizen read from a .wgt and a .tpk; needsNewer gate; no manifest and not-a-zip refused'); }); }); }); diff --git a/tizen-app/config.xml b/tizen-app/config.xml index 6c5cddc..88e82ec 100644 --- a/tizen-app/config.xml +++ b/tizen-app/config.xml @@ -1,6 +1,6 @@ + id="https://github.com/Apps2Samsung/TV-Store" version="0.3.12" viewmodes="maximized"> Apps2Samsung TV-Store diff --git a/tizen-app/js/app.js b/tizen-app/js/app.js index 45f9d43..50f289c 100644 --- a/tizen-app/js/app.js +++ b/tizen-app/js/app.js @@ -17,6 +17,11 @@ * the heads' Packaging/ folder), root packages (`flags: ["root"]`, which afterwards need Host * PC IP set to 127.0.0.1) and files without a hash in the catalog (the TV cannot check those). * + * Tizen version (#47): the catalog says what Tizen each file asks for (required_version). A build + * this TV is too old for carries a "Needs Tizen X" badge, its card is dimmed, and its Install button + * is off: the TV would refuse it with install failed[118, -19] (#44). The service checks the same + * from the package itself before pushing, so a file sent in by hand is caught too. + * * Pins (#29): the app sheet pins one build of an app to the top of the store, remembered on the TV. * A pinned card shows that build's own state (installed version, update waiting). For apps whose * builds share one package id the pin also tells TV-Store which build the TV has. @@ -39,7 +44,7 @@ // TV's current IP and loopback too, and keeps whichever answers. var BASE = 'http://127.0.0.1:8077'; var KOFI = 'https://ko-fi.com/patrickst'; - var VERSION = '0.3.11'; + var VERSION = '0.3.12'; var KEY = { LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40, ENTER: 13, RETURN: 10009, ESC: 27, BACK: 8 }; // What the manifest providers are called; resolved.json carries only their ids. @@ -101,6 +106,7 @@ return out; } var ready = null; // the service's /ping + var tvTizen = ''; // the Tizen version this TV runs ("4.0"), from the platform; '' off a TV var problem = null; // { title, text } when the service or the catalog failed var loading = true; var view = 'install'; // 'install' | 'tv' @@ -281,6 +287,8 @@ } function readDevice() { + // "4.0", "6.5", "10.0": what the apps' required_version is held against (#47). + try { var pv = /^\s*(\d+(?:\.\d+)?)/.exec(String(tizen.systeminfo.getCapability('http://tizen.org/feature/platform.version') || '')); if (pv) tvTizen = pv[1]; } catch (e) {} ['ETHERNET_NETWORK', 'WIFI_NETWORK'].forEach(function (t) { try { tizen.systeminfo.getPropertyValue(t, function (n) { if (n && n.ipAddress && !tvIp) tvIp = n.ipAddress; }, function () {}); } catch (e) {} }); @@ -293,9 +301,16 @@ } // ---- the catalog ------------------------------------------------------------------------------ - function variant(label, file, url, version, appId, packageId) { - return { label: label || file.replace(/\.(wgt|tpk)$/i, ''), file: file, url: url, version: version || '', appId: appId || '', packageId: packageId || '' }; - } + function variant(label, file, url, version, appId, packageId, tizen) { + return { label: label || file.replace(/\.(wgt|tpk)$/i, ''), file: file, url: url, version: version || '', appId: appId || '', packageId: packageId || '', tizen: tizen || '' }; + } + // Whether this TV can run a build that asks for Tizen v.tizen ("6.0"): yes when either side is + // unknown; the service checks the package itself and the TV has the last word (#47). + function fits(v) { return !tvTizen || !v.tizen || !newer(v.tizen, tvTizen); } + // The lowest Tizen any build of the app asks for, '' when no build says. + function lowestTizen(x) { var low = ''; x.variants.forEach(function (v) { if (v.tizen && (!low || newer(low, v.tizen))) low = v.tizen; }); return low; } + // No build of the app runs on this TV. + function tooNew(x) { return !!tvTizen && x.variants.length > 0 && !x.variants.some(fits); } function ownerOf(repoOrUrl) { var m = /(?:repos\/)?([^\/]+)\/[^\/]+(?:\/releases)?$/.exec(String(repoOrUrl || '').replace(/^https?:\/\/[^\/]+\//, '')); @@ -322,7 +337,7 @@ // The community bundle: the catalog is the truth, the release gives the download. p.catalog.apps.forEach(function (e) { var a = byFile[e.file]; if (!a) return; - var v = variant(e.variant, e.file, a.browser_download_url, e.version || a.package_version, e.app_id || a.app_id, e.package_id || a.package_id); + var v = variant(e.variant, e.file, a.browser_download_url, e.version || a.package_version, e.app_id || a.app_id, e.package_id || a.package_id, e.required_version || a.required_version); var x = app('c:' + (e.group || e.name), e.group || e.name, e.category, e.description, ownerOf(e.repo)); x.variants.push(v); if (e.adult) x.adult = true; @@ -339,7 +354,7 @@ var info = PROVIDERS[p.id] || { name: p.id, category: 'other', description: '' }; var x = app('p:' + p.id, info.name, info.category, info.description, ownerOf(p.url)); assets.forEach(function (a) { - var v = variant(null, a.name, a.browser_download_url, a.package_version, a.app_id, a.package_id); + var v = variant(null, a.name, a.browser_download_url, a.package_version, a.app_id, a.package_id, a.required_version); if (!a.digest) v.needsHead = NO_HASH; x.variants.push(v); }); @@ -485,6 +500,7 @@ function cardBadges(x) { var st = state(x), b = ''; if (st.installed) b += st.update ? badge('b-warn', 'Update') : badge('b-ok', 'Installed'); + if (tooNew(x)) b += badge('b-warn', 'Needs Tizen ' + lowestTizen(x)); if (x.adult) b += badge('b-warn', '18+'); if (x.root) b += badge('b-warn', 'root'); if (x.needsHead) b += badge('b-mute', 'Phone or PC'); @@ -495,6 +511,7 @@ if (vs.installed) b += vs.update ? badge('b-warn', 'Update to v' + vs.latest) : badge('b-ok', 'Installed' + (vs.version ? ' · v' + vs.version : '')); else if (vs.other) b += badge('b-mute', 'Other build on the TV'); else if (v.version) b += badge('b-mute', 'v' + v.version); + if (!fits(v)) b += badge('b-warn', 'Needs Tizen ' + v.tizen); if (x.adult) b += badge('b-warn', '18+'); if (x.root) b += badge('b-warn', 'root'); if (v.needsHead) b += badge('b-mute', 'Phone or PC'); @@ -534,6 +551,7 @@ if (st.installed) b += st.update ? badge('b-warn', 'Update to v' + st.latest) : badge('b-ok', 'Installed' + (st.version ? ' · v' + st.version : '')); if (x.variants.length > 1) b += badge('b-mute', x.variants.length + ' variants'); else if (x.variants[0].version && !st.installed) b += badge('b-mute', 'v' + x.variants[0].version); + if (tooNew(x)) b += badge('b-warn', 'Needs Tizen ' + lowestTizen(x)); if (x.adult) b += badge('b-warn', '18+'); if (x.needsHead) b += badge('b-mute', 'Phone or PC'); return '
' + tile(x, 110) @@ -543,13 +561,13 @@ } function card(x, where) { - return '
' + return '
' + tile(x, 68) + '
' + esc(x.name) + '
' + esc(subline(x)) + '
' + cardBadges(x) + '
'; } // A pinned build's card (#29): the build's own label and state, and OK opens the sheet on it. function pinCard(x, i) { var v = x.variants[i]; - return '
' + return '
' + tile(x, 68) + '
' + esc(x.name) + '
' + esc(x.variants.length > 1 ? v.label : (v.version ? 'v' + v.version : v.file)) + '
' + pinBadges(x, i) + '
'; } @@ -662,7 +680,7 @@ + '
In the catalogv' + esc(st.latest || v.version) + '
' + (x.variants.length > 1 ? '
Variant' + esc(st.known ? v.label + (st.byPin ? ' · pinned' : '') : 'Not known: the builds share one package') + '
' : '') + '
' + '
'; - if (st.update && st.known && !v.needsHead && !isSelf(x) && armed()) h += ''; + if (st.update && st.known && !v.needsHead && fits(v) && !isSelf(x) && armed()) h += ''; else h += ''; if (v.appId) h += ''; h += '
'; @@ -720,6 +738,7 @@ if (st.update) b += badge('b-warn', 'Update to v' + st.latest); if (x.adult) b += badge('b-warn', 'Adults only (18+)'); if (x.root) b += badge('b-warn', 'Gains root on the TV'); + if (v.tizen) b += badge(fits(v) ? 'b-mute' : 'b-warn', 'Tizen ' + v.tizen + ' or newer'); if (b) h += '
' + b + '
'; if (x.variants.length > 1) { @@ -730,7 +749,7 @@ var vv = x.variants[i], inst = st.installed && st.known && st.variant === i; return '
' + '' + esc(vv.label) + '' - + (inst ? badge('b-ok', 'on the TV') : '') + (isPinned(x, i) ? badge('b-pin', 'Pinned') : '') + (vv.needsHead ? badge('b-mute', 'Phone or PC') : '') + + (inst ? badge('b-ok', 'on the TV') : '') + (isPinned(x, i) ? badge('b-pin', 'Pinned') : '') + (vv.needsHead ? badge('b-mute', 'Phone or PC') : '') + (fits(vv) ? '' : badge('b-warn', 'Needs Tizen ' + vv.tizen)) + '' + esc(vv.version ? 'v' + vv.version : '') + '
'; }).join('') + '
'; if (!sheet.all && x.variants.length > 4) h += 'Show all ' + x.variants.length + ' variants ›'; @@ -739,11 +758,12 @@ var label = st.installed && st.known && st.variant === sheet.v ? (st.update ? 'Update to v' + v.version : 'Reinstall') : (st.installed ? 'Install this variant' : 'Install on this TV'); var me = isSelf(x); if (me) h += '
' + SELF_HOW + '
'; + else if (!fits(v)) h += '
' + esc(x.name + (x.variants.length > 1 ? ' (' + v.label + ')' : '') + ' asks for Tizen ' + v.tizen + ' or newer, and this TV runs Tizen ' + tvTizen + '. The TV would refuse it' + (x.variants.some(fits) ? ', so pick a build above that fits this set.' : '.')) + '
'; else if (v.needsHead) h += '
' + esc(x.name + ' ' + v.needsHead + ', which this TV cannot do yet. Install it with Apps2Samsung on a phone or PC.') + '
'; else if (!armed()) h += '
Not armed yet. Open this TV’s Toolbox in Apps2Samsung 3.0 or newer on your phone or PC and press Arm TV-Store once; after that every install runs here.
'; - var can = !v.needsHead && !me && armed() && !(job && job.running); + var can = !v.needsHead && fits(v) && !me && armed() && !(job && job.running); h += '
'; + + esc(me ? (st.update ? 'Update' : 'Reinstall') + ' from a phone or PC' : !fits(v) ? 'Needs Tizen ' + v.tizen + ' or newer' : v.needsHead ? 'Install from a phone or PC' : job && job.running ? 'Another install is running' : label) + ''; if (st.installed && x.variants[st.variant].appId) h += ''; // Pin this build to the top of the store (#29); for a one-build app the button pins the app. h += ''; @@ -754,7 +774,7 @@ // The file a phone or PC sent (#30), as the app the install screens show: one build, no catalog. function uploadApp(up) { - var ids = up.ids || {}, v = variant(up.name, up.name, '', ids.version, ids.appId, ids.packageId); + var ids = up.ids || {}, v = variant(up.name, up.name, '', ids.version, ids.appId, ids.packageId, ids.requiredVersion); v.upload = up.id; return { key: 'upload:' + up.id, name: ids.name || up.name.replace(/\.(wgt|tpk)$/i, ''), category: 'other', description: '', author: '', variants: [v], needsHead: null, adult: false, root: false }; } @@ -770,11 +790,13 @@ if (b) h += '
' + b + '
'; h += '
File' + esc(up.name) + '
' + '
Application' + esc((ids.appId || ids.packageId || 'unknown') + (ids.version ? ' · v' + ids.version : '')) + '
' + + (ids.requiredVersion ? '
Asks for' + esc('Tizen ' + ids.requiredVersion + ' or newer' + (tvTizen ? ' · this TV runs Tizen ' + tvTizen : '')) + '
' : '') + '
SHA-256' + esc(String(up.sha256).slice(0, 20)) + '…
'; if (!armed()) h += '
Not armed yet. Open this TV’s Toolbox in Apps2Samsung on your phone or PC and press Arm TV-Store once; the file keeps waiting.
'; else if (uploadTokenError) h += '
TV-Store could not read its upload key on this TV (' + esc(uploadTokenError) + '), so it cannot confirm uploads here. Install the file with Apps2Samsung on a phone or PC instead.
'; - var can = armed() && !uploadTokenError && !(job && job.running); - h += '
' + else if (!fits(x.variants[0])) h += '
' + esc('This file asks for Tizen ' + ids.requiredVersion + ' or newer, and this TV runs Tizen ' + tvTizen + '. The TV would refuse it.') + '
'; + var can = armed() && !uploadTokenError && fits(x.variants[0]) && !(job && job.running); + h += '
' + '
' + '
Signed with your certificate, like every install. RETURN keeps the file waiting.
'; return h; @@ -839,7 +861,8 @@ + '
'; h += '
Signing certificate' + (armed() ? 'handed over' : 'not yet') + '
' + '
Catalog' + esc(catalogApps.length + ' apps, signature checked') + '
' - + '
TV-Storev' + VERSION + (ready && ready.node ? ' · Node ' + esc(ready.node) : '') + '
'; + + '
TV-Storev' + VERSION + (ready && ready.node ? ' · Node ' + esc(ready.node) : '') + '
' + + '
This TV' + esc((device ? device + ' · ' : '') + (tvTizen ? 'Tizen ' + tvTizen : 'Tizen version not known')) + '
'; if (confirmingAdult) return h; // the question needs the room the QR codes take // Your own file (#30): the upload page's address, as a QR for a phone. if (ready && ready.ownIp) { @@ -851,8 +874,10 @@ // ---- actions ---------------------------------------------------------------------------------- function openApp(x, vIdx) { - var st = state(x); - openSheet({ mode: 'app', app: x, v: vIdx != null ? vIdx : (st.installed ? st.variant : 0), all: false }); + var st = state(x), v = vIdx != null ? vIdx : (st.installed ? st.variant : 0); + // Not a build this TV cannot run when another can (#47): the first that fits is the one to land on. + if (vIdx == null && !fits(x.variants[v])) for (var i = 0; i < x.variants.length; i++) if (fits(x.variants[i])) { v = i; break; } + openSheet({ mode: 'app', app: x, v: v, all: false }); } function startInstall(x, vIdx) { @@ -863,7 +888,7 @@ renderHeader(); var since = job.started - 2000, me = job; var timer = setInterval(function () { pollLog(me, since); }, 700); - var req = v.upload ? { upload: v.upload, token: uploadToken } : { url: v.url, file: v.file, appId: v.appId, packageId: v.packageId }; + var req = v.upload ? { upload: v.upload, token: uploadToken, tizen: tvTizen } : { url: v.url, file: v.file, appId: v.appId, packageId: v.packageId, tizen: tvTizen }; xhr('POST', '/install', req, function (err, res) { clearInterval(timer); pollLog(me, since, function () { diff --git a/tizen-app/service/index.js b/tizen-app/service/index.js index 2a9b912..a664de0 100644 --- a/tizen-app/service/index.js +++ b/tizen-app/service/index.js @@ -147,7 +147,10 @@ function installUpload(b, cb) { var up = UPLOAD, outFile = path.join(DATA_DIR, 'job-' + Date.now() + '.signed.wgt'); UPLOAD_BUSY = true; log('install: uploaded ' + up.size + ' bytes, ' + up.name + ' from ' + up.from + ', SHA-256 ' + up.sha256 + ', confirmed on the TV; signing'); - signAndInstall({ appId: up.ids.appId, packageId: up.ids.packageId, file: up.name }, up.name.replace(/[^A-Za-z0-9._-]/g, '_'), UPLOAD_FILE, outFile, function (err, r) { + var entry = { appId: up.ids.appId, packageId: up.ids.packageId, file: up.name, requiredVersion: up.ids.requiredVersion, tizen: b.tizen }; + var gate = tizenGate(entry); + if (gate) { UPLOAD_BUSY = false; return cb(gate); } + signAndInstall(entry, up.name.replace(/[^A-Za-z0-9._-]/g, '_'), UPLOAD_FILE, outFile, function (err, r) { UPLOAD_BUSY = false; unlink(outFile); // The file is gone once signed (signAndInstall drops it); a failure before that (no @@ -273,13 +276,44 @@ function install(entry, cb) { if (err) return clean(err); if (got.sha256 !== want) { log('install: hash mismatch, got ' + got.sha256 + ' want ' + want); return clean(coded('HASH_MISMATCH', 'the download does not match the catalog (SHA-256 differs); not installing it')); } log('install: downloaded ' + got.size + ' bytes, SHA-256 matches the catalog; signing'); - signAndInstall(entry, remoteName, inFile, outFile, clean); + // The Tizen version the package asks for, against the one the page reports for this TV + // (#47): a package the TV would refuse with [118, -19] is turned away before the push. + manifest.read(inFile, function (err, ids) { + if (err) logv('install: could not read the manifest (' + err.message + '); the TV decides'); + else entry.requiredVersion = ids.requiredVersion; + var gate = tizenGate(entry); + if (gate) return clean(gate); + signAndInstall(entry, remoteName, inFile, outFile, clean); + }); }); }); } function coded(code, message) { var e = new Error(message); e.code = code; return e; } +// The page sends the TV's platform version with every install (entry.tizen, "4.0"); the package's +// own manifest says what it asks for (entry.requiredVersion). A mismatch is refused here with the +// reason, instead of by the TV with a bare [118, -19] (#44, #47). Either side unknown: no gate. +function tizenVersionOf(entry) { var m = /^\s*(\d+(?:\.\d+)?)/.exec(String(entry.tizen || '')); return m ? m[1] : ''; } +function tizenGate(entry) { + var tv = tizenVersionOf(entry), req = entry.requiredVersion || ''; + if (!manifest.needsNewer(req, tv)) return null; + log('install: ' + (entry.file || 'the package') + ' asks for Tizen ' + req + ', this TV runs Tizen ' + tv + '; not pushing it'); + return coded('NEEDS_NEWER_TIZEN', 'This app asks for Tizen ' + req + ' or newer, and this TV runs Tizen ' + tv + '. The TV would refuse it. If the catalog has a build for older sets, pick that one.'); +} + +// What the TV's refusal means where that is known. [118, -19] (118019 on a 2018 set) is the +// installer's "parsing error" on the manifest, in practice a package that asks for a newer Tizen +// than the set runs (#44); [118, -4] is the newer sets' spelling of that mismatch, next to "load +// archive info fail" for a bad package id. The manifest's own number goes in when it is known. +function refusal(code, reason, entry) { + var m = 'The TV refused the package [' + code + ']' + (reason ? ': ' + reason.trim() : ''); + var c = String(code).replace(/\s/g, ''), tv = tizenVersionOf(entry), req = entry.requiredVersion || ''; + if (!/^(118,-19|118019|118,-4|118004)$/.test(c)) return m; + if (req) return m + '. It asks for Tizen ' + req + ' or newer' + (tv ? ', and this TV runs Tizen ' + tv : '') + '.'; + return m + '. The TV could not read the package, which usually means it asks for a newer Tizen than this set runs.'; +} + // sdbd on this TV, at the first address that completes the handshake. The LAN IP is what Developer // Mode's Host PC IP lets in; loopback is a fallback for a Host PC IP of 127.0.0.1. // When none gets in, the error says what to change on the TV instead of the socket error (#26). @@ -336,7 +370,7 @@ function signAndInstall(entry, remoteName, inFile, outFile, cb) { // sdbd answers the shell call either way; the installer's own words say whether it // worked ("install failed[118, -12], reason: ..." on a refused package). var failed = /install failed\[([^\]]*)\](?:,\s*reason:\s*([^\n]*))?/i.exec(out); - if (failed) return cb(coded('INSTALL_FAILED', 'The TV refused the package [' + failed[1] + ']' + (failed[2] ? ': ' + failed[2].trim() : ''))); + if (failed) return cb(coded('INSTALL_FAILED', refusal(failed[1], failed[2], entry))); cb(null, { reply: out }); }); }); diff --git a/tizen-app/service/manifest.js b/tizen-app/service/manifest.js index 102ceb4..1a72b6a 100644 --- a/tizen-app/service/manifest.js +++ b/tizen-app/service/manifest.js @@ -1,6 +1,9 @@ /* What a package calls itself (#30): the ids vd_appinstall and the screens need, read from the * package's own manifest. A .wgt carries config.xml at its root (), * a .tpk carries tizen-manifest.xml ( with ). + * Also the Tizen version the package asks for (#47): required_version on , + * api-version on . A TV below it refuses the package with install failed[118, -19] + * (118019 on a 2018 set, #44), so the service checks before the push. * The catalog gives these for its own files; an uploaded file has only itself. Pure, file-based, * on the zip reader the signer uses. ES5 for the TV's Node 4.4.3. */ 'use strict'; @@ -17,15 +20,29 @@ function text(xml, tag) { return m ? m[1].trim() : ''; } -// The ids in a manifest's XML: { kind, appId, packageId, version, name }. Missing ones are ''. +// The ids in a manifest's XML: { kind, appId, packageId, version, name, requiredVersion }. Missing ones are ''. function parse(kind, xml) { if (kind === 'wgt') { var pkg = attr(xml, 'tizen:application', 'package'); - return { kind: kind, appId: attr(xml, 'tizen:application', 'id'), packageId: pkg, version: attr(xml, 'widget', 'version'), name: text(xml, 'name') }; + return { kind: kind, appId: attr(xml, 'tizen:application', 'id'), packageId: pkg, version: attr(xml, 'widget', 'version'), name: text(xml, 'name'), + requiredVersion: attr(xml, 'tizen:application', 'required_version') }; } var p = attr(xml, 'manifest', 'package'); var appId = attr(xml, 'ui-application', 'appid') || attr(xml, 'service-application', 'appid') || attr(xml, 'widget-application', 'appid') || attr(xml, 'watch-application', 'appid'); - return { kind: kind, appId: appId, packageId: p, version: attr(xml, 'manifest', 'version'), name: text(xml, 'label') }; + return { kind: kind, appId: appId, packageId: p, version: attr(xml, 'manifest', 'version'), name: text(xml, 'label'), + requiredVersion: attr(xml, 'manifest', 'api-version') }; +} + +// True when a package that asks for Tizen `required` ("6.0") cannot run on a TV at `tv` ("4.0"). +// Lenient like the heads: an unknown value on either side is no refusal, the TV has the last word. +function needsNewer(required, tv) { + var x = String(required || '').match(/\d+/g), y = String(tv || '').match(/\d+/g); + if (!x || !y) return false; + for (var i = 0; i < Math.max(x.length, y.length); i++) { + var p = parseInt(x[i] || '0', 10), q = parseInt(y[i] || '0', 10); + if (p !== q) return p > q; + } + return false; } // cb(err, ids) for the package at `file`; err when it is not a zip or has no manifest at its root. @@ -51,4 +68,4 @@ function read(file, cb) { }); } -module.exports = { read: read, parse: parse }; +module.exports = { read: read, parse: parse, needsNewer: needsNewer }; From 4e238243ae55596c3da8dac3f2539b9131c0e0f5 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 30 Oct 2026 08:51:18 +0100 Subject: [PATCH 2/6] Offer to remove an app installed with another certificate before installing it again (#44) An app installed from a phone or PC carries that device's author certificate, and the TV only updates it with the same one: TV-Store's re-sign gets install failed[118, -12] (118012 on a 2018 set, HackTV in #44). Now: - service/refusal.js reads the TV's refusal: "same id and different certificate", or a bare [118, -12] on a package the page says is on the TV, is CERT_MISMATCH; other [118, -12] reasons are CERT_REJECTED with the clock / internet / re-arm advice; the Tizen-version readings moved here too; - the install screen then offers "Remove the old copy and install again" or "Keep the old copy"; the retry sends replace: true and the service runs 0 vd_appuninstall on its own connection before the push (a refusal is logged, the install goes ahead and the TV's answer is what the user sees); - the app sheet says when an app came from a phone or PC (TV-Store never installed that package) and what happens on a certificate mismatch; - README: Apps installed from a phone or PC. test/refusal.test.js covers every reading; wired into both workflows. Verified in headless Chrome against a mock service: the mismatch screen, the replace retry and the done screen. Not run on a TV. --- .github/workflows/build-wgt.yml | 5 +++- .github/workflows/release.yml | 1 + README.md | 11 ++++++++ test/refusal.test.js | 28 ++++++++++++++++++++ tizen-app/js/app.js | 31 +++++++++++++++------- tizen-app/service/index.js | 38 +++++++++++++++------------ tizen-app/service/refusal.js | 46 +++++++++++++++++++++++++++++++++ 7 files changed, 134 insertions(+), 26 deletions(-) create mode 100644 test/refusal.test.js create mode 100644 tizen-app/service/refusal.js diff --git a/.github/workflows/build-wgt.yml b/.github/workflows/build-wgt.yml index 812280d..03d471f 100644 --- a/.github/workflows/build-wgt.yml +++ b/.github/workflows/build-wgt.yml @@ -14,7 +14,7 @@ jobs: node-version: '20' - name: Syntax-check the service and UI run: | - for f in tizen-app/service/sdb.js tizen-app/service/zip.js tizen-app/service/index.js tizen-app/service/signer.js tizen-app/service/catalog.js tizen-app/service/catalog-key.js tizen-app/service/compat.js tizen-app/service/manifest.js tizen-app/js/app.js test/fake-sdbd.js test/run.js test/sign.test.js test/config.test.js test/sdb-errors.test.js test/manifest.test.js; do + for f in tizen-app/service/sdb.js tizen-app/service/zip.js tizen-app/service/index.js tizen-app/service/signer.js tizen-app/service/catalog.js tizen-app/service/catalog-key.js tizen-app/service/compat.js tizen-app/service/manifest.js tizen-app/service/refusal.js tizen-app/js/app.js test/fake-sdbd.js test/run.js test/sign.test.js test/config.test.js test/sdb-errors.test.js test/manifest.test.js test/refusal.test.js; do node --check "$f" done - name: config.xml ids (package id is 10 alphanumerics) @@ -33,6 +33,8 @@ jobs: run: node test/sdb-errors.test.js - name: Package manifest ids (.wgt config.xml, .tpk tizen-manifest.xml) run: node test/manifest.test.js + - name: Install refusals (another certificate, rejected certificate, Tizen version) + run: node test/refusal.test.js # The TV's own Node: v4.4.3 on a 2019 Tizen 5.0 set (#881). Buffer.from, Buffer.alloc, # crypto.sign and friends are newer than that, and only fail there; run every test on it too. - uses: actions/setup-node@v4 @@ -49,6 +51,7 @@ jobs: node test/catalog.test.js node test/sdb-errors.test.js node test/manifest.test.js + node test/refusal.test.js - name: Build the .wgt run: scripts/build-wgt.sh - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3578aa..8e3a05c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ jobs: node test/catalog.test.js node test/sdb-errors.test.js node test/manifest.test.js + node test/refusal.test.js - name: Version in config.xml must match the tag run: | tag="${GITHUB_REF_NAME#v}" diff --git a/README.md b/README.md index 32ee635..8ac32fb 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,16 @@ colours, big tiles for a couch. 4. Pick an app in TV-Store and press OK. It installs into the Apps row; no phone or PC needed from here on. Set Host PC IP back to your phone or PC whenever you want to use Apps2Samsung again. +## Apps installed from a phone or PC + +Every install is signed with an author certificate, and a Samsung TV only updates an app with the +same author certificate it was installed with. Arming hands this TV the certificate of the phone or +PC that pressed Arm; an app installed earlier from another device (or with a certificate that has +since been renewed) carries a different one, and the TV answers `install failed[118, -12]` (`118012` +on a 2018 set) when TV-Store tries to update it (#44). TV-Store then offers to remove the old copy +and install again. That is the only way past it; the app's settings and sign-ins on the TV go with +the old copy, which is why it asks first. + ## How it works A Samsung TV only installs sideloaded packages through its developer connection (`sdbd`), the @@ -126,6 +136,7 @@ The header counts what the switches hide ("12 apps · 3 hidden"). - `tizen-app/service/catalog.js` + `catalog-key.js`: the signed catalog check and its public key - `tizen-app/service/compat.js`: buffers that work on the TV's Node 4.4.3 - `tizen-app/service/manifest.js`: the ids and the required Tizen version a package carries in its own manifest +- `tizen-app/service/refusal.js`: what the TV's `install failed[...]` codes mean and what TV-Store offers for each - `tizen-app/js/app.js`: the UI, and `window.A2SStore`, which Apps2Samsung calls to arm the TV - `tizen-app/js/qrcode.js`: QR codes for the log address and the Ko-fi link ([qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) 1.4.4, MIT) - `test/`: a fake `sdbd` plus signer and transport tests diff --git a/test/refusal.test.js b/test/refusal.test.js new file mode 100644 index 0000000..e12314f --- /dev/null +++ b/test/refusal.test.js @@ -0,0 +1,28 @@ +/* refusal.js turns the TV's "install failed[code], reason" into what the install screen says and + * offers: another certificate on the installed copy (CERT_MISMATCH, removable), the certificate + * itself rejected (CERT_REJECTED), a package that asks for a newer Tizen (#44, #47). */ +'use strict'; +var r = require('../tizen-app/service/refusal.js'); +function fail(m) { console.error('FAIL: ' + m); process.exit(1); } +function check(code, reason, entry, wantCode, wantText) { + var got = r.refusal(code, reason, entry); + if (got.code !== wantCode) fail('[' + code + '] ' + JSON.stringify(reason) + ' ' + JSON.stringify(entry) + ': code ' + got.code + ', want ' + wantCode); + if (wantText && got.message.indexOf(wantText) < 0) fail('[' + code + ']: message "' + got.message + '" lacks "' + wantText + '"'); +} +// A newer set names the installed copy; a 2018 set gives six digits and no reason, so the page's +// word that the package is on the TV decides (#44, HackTV). +check('118, -12', 'Check certificate error : :Package with the same id and different certificate has been already installed.:<-12>', { name: 'HackTV' }, 'CERT_MISMATCH', 'HackTV is already on this TV'); +check('118012', '', { file: 'HackTV.wgt', installed: true }, 'CERT_MISMATCH', 'HackTV is already on this TV'); +check('118012', '', { file: 'HackTV.wgt', installed: false }, 'CERT_REJECTED', 'internet during the install'); +check('118, -12', 'Check certificate error : :Invalid format of certificate in signature.:<-2>', { installed: true }, 'CERT_REJECTED', 'internet during the install'); +check('118, -12', 'Check certificate error : :Certificate in signature is not valid yet.:<-7>', { installed: true }, 'CERT_REJECTED', 'clock is behind'); +check('118, -12', 'certificate has expired', {}, 'CERT_REJECTED', 'expired'); +// The Tizen version the package asks for (#47). +check('118019', '', { requiredVersion: '6.0', tizen: '4.0' }, 'INSTALL_FAILED', 'asks for Tizen 6.0 or newer, and this TV runs Tizen 4.0'); +check('118, -19', 'Parsing error', { requiredVersion: '6.0' }, 'INSTALL_FAILED', 'asks for Tizen 6.0 or newer.'); +check('118, -4', 'Operation not allowed', {}, 'INSTALL_FAILED', 'could not read the package'); +// Anything else is passed on as the TV said it. +check('118, -22', 'Security error : :Invalid function parameter was given:<2>', { installed: true }, 'INSTALL_FAILED', 'The TV refused the package [118, -22]: Security error'); +check('116', '', {}, 'INSTALL_FAILED', '[116]'); +if (r.tizenVersionOf({ tizen: '6.5.0' }) !== '6.5' || r.tizenVersionOf({ tizen: 'x' }) !== '' || r.tizenVersionOf(null) !== '') fail('tizenVersionOf'); +console.log('PASS: refusals read as another certificate (removable), a rejected certificate, a Tizen version too low, or as the TV said'); diff --git a/tizen-app/js/app.js b/tizen-app/js/app.js index 50f289c..40937b0 100644 --- a/tizen-app/js/app.js +++ b/tizen-app/js/app.js @@ -17,6 +17,11 @@ * the heads' Packaging/ folder), root packages (`flags: ["root"]`, which afterwards need Host * PC IP set to 127.0.0.1) and files without a hash in the catalog (the TV cannot check those). * + * Another certificate (#44): an app installed from a phone or PC carries that device's author + * certificate, and the TV only updates it with the same one. When the TV answers [118, -12] for an + * app that is on the TV, the install screen offers to remove the old copy and install again (the + * service runs 0 vd_appuninstall first); the app's settings on the TV go with it, so it is a choice. + * * Tizen version (#47): the catalog says what Tizen each file asks for (required_version). A build * this TV is too old for carries a "Needs Tizen X" badge, its card is dimmed, and its Install button * is off: the TV would refuse it with install failed[118, -19] (#44). The service checks the same @@ -768,7 +773,10 @@ // Pin this build to the top of the store (#29); for a one-build app the button pins the app. h += ''; if (st.installed && !st.known && x.variants.length > 1) h = h.replace('
', '
' + esc('Your TV has ' + x.name + ' v' + st.version + ', but every build of it uses the same package, so TV-Store cannot tell which one. Pick yours above to reinstall or update it, or pin it so TV-Store knows.') + '
'); - h += '
' + (can ? 'Takes about a minute. RETURN goes back to the store.' : 'RETURN goes back to the store.') + '
'; + // Came from a phone or PC (TV-Store never installed this package): the TV keeps that device's + // certificate, and an update from here may need the old copy removed first (#44). + var fromHead = can && st.installed && v.packageId && !installedFile.hasOwnProperty(v.packageId); + h += '
' + (fromHead ? esc(x.name + ' was installed from a phone or PC. If that device used another certificate, the TV refuses the update and TV-Store offers to remove the old copy first. RETURN goes back to the store.') : can ? 'Takes about a minute. RETURN goes back to the store.' : 'RETURN goes back to the store.') + '
'; return h; } @@ -818,13 +826,14 @@ if (i === 0 && v.upload) s = ['Received from your phone or PC', 'Picking up the file']; if (i === 0) side = job.bytes ? (job.bytes / 1048576).toFixed(1) + ' MB' : ''; if (i === 1) side = 'with your certificate'; - if (i === 2) side = 'the TV is doing this part'; + if (i === 2) side = job.replace ? 'the old copy goes first' : 'the TV is doing this part'; return '
' + ico + '' + (i < done ? s[0] : s[1]) + '' + side + '
'; }).join('') + '
'; if (job.error) h += '
' + esc(job.error) + '
'; else h += '
You can keep browsing with RETURN; the ' + (upd ? 'update' : 'install') + ' carries on in the background and the app shows up under On the TV when it is in.
'; h += '
'; - if (job.error) h += ''; + if (job.error && job.code === 'CERT_MISMATCH' && !job.replace) h += ''; + else if (job.error) h += ''; else h += ''; h += '
' + esc(job.line || '') + '
'; return h; @@ -880,20 +889,23 @@ openSheet({ mode: 'app', app: x, v: v, all: false }); } - function startInstall(x, vIdx) { + function startInstall(x, vIdx, opts) { if ((job && job.running) || isSelf(x)) return; - var v = x.variants[vIdx], st = state(x); - job = { app: x, v: v, vIdx: vIdx, update: st.installed && st.known && st.variant === vIdx, running: true, finished: false, ok: false, step: 0, bytes: 0, line: '', error: null, started: Date.now() }; + var v = x.variants[vIdx], st = state(x), replace = !!(opts && opts.replace); + job = { app: x, v: v, vIdx: vIdx, update: st.installed && st.known && st.variant === vIdx, replace: replace, running: true, finished: false, ok: false, step: 0, bytes: 0, line: '', error: null, code: null, started: Date.now() }; if (sheet) { sheet = { mode: 'job' }; renderSheet(true); } else openSheet({ mode: 'job' }); renderHeader(); var since = job.started - 2000, me = job; var timer = setInterval(function () { pollLog(me, since); }, 700); - var req = v.upload ? { upload: v.upload, token: uploadToken, tizen: tvTizen } : { url: v.url, file: v.file, appId: v.appId, packageId: v.packageId, tizen: tvTizen }; + // installed: whether the TV lists the package, so the service can read a bare [118, -12] from + // a 2018 set (no reason text) as "another certificate" (#44); replace: remove that copy first. + var onTv = !!(v.packageId && installed.hasOwnProperty(v.packageId)); + var req = v.upload ? { upload: v.upload, token: uploadToken, tizen: tvTizen, installed: onTv, replace: replace } : { url: v.url, file: v.file, appId: v.appId, packageId: v.packageId, name: x.name, tizen: tvTizen, installed: onTv, replace: replace }; xhr('POST', '/install', req, function (err, res) { clearInterval(timer); pollLog(me, since, function () { me.running = false; me.finished = true; me.ended = Date.now(); - if (err || !res.ok) { me.error = err ? err.message : res.error; renderHeader(); if (sheet && sheet.mode === 'job') renderSheet(true); queue = []; return; } + if (err || !res.ok) { me.error = err ? err.message : res.error; me.code = err ? null : (res.code || null); renderHeader(); if (sheet && sheet.mode === 'job') renderSheet(true); queue = []; return; } me.ok = true; me.step = 4; if (v.packageId) { installed[v.packageId] = v.version; claims = null; rememberVariant(v); } if (v.appId) appIds[v.appId] = true; @@ -917,7 +929,7 @@ me.line = l.replace(/^\S+\s+install:\s*/, ''); var m = /(?:downloaded|uploaded) (\d+) bytes/.exec(l); if (m) { me.bytes = parseInt(m[1], 10); me.step = Math.max(me.step, 1); } - if (/pushing to/.test(l)) me.step = Math.max(me.step, 2); + if (/removing the old copy|pushing to/.test(l)) me.step = Math.max(me.step, 2); if (/vd_appinstall reply/.test(l)) me.step = Math.max(me.step, 3); }); if (sheet && sheet.mode === 'job' && job === me && me.running) renderSheet(); @@ -980,6 +992,7 @@ case 'openjob': launch(job.v.appId); return; case 'close': closeSheet(); return; case 'retryjob': startInstall(job.app, job.vIdx); return; + case 'replacejob': startInstall(job.app, job.vIdx, { replace: true }); return; case 'adultyes': setSetting('showAdult', true); return; case 'adultno': confirmingAdult = false; renderSheet(); focusKey('opt:showAdult'); return; case 'tvupd': startInstall(tvSel, state(tvSel).variant); return; diff --git a/tizen-app/service/index.js b/tizen-app/service/index.js index a664de0..7a2e996 100644 --- a/tizen-app/service/index.js +++ b/tizen-app/service/index.js @@ -19,6 +19,7 @@ var zip = require('./zip.js'); var catalogTrust = require('./catalog.js'); var CATALOG_KEY = require('./catalog-key.js'); var manifest = require('./manifest.js'); +var refusals = require('./refusal.js'); var PORT = 8077; // Signed by the Apps2Samsung catalog workflow; see catalog.js for why the content is checked @@ -147,7 +148,7 @@ function installUpload(b, cb) { var up = UPLOAD, outFile = path.join(DATA_DIR, 'job-' + Date.now() + '.signed.wgt'); UPLOAD_BUSY = true; log('install: uploaded ' + up.size + ' bytes, ' + up.name + ' from ' + up.from + ', SHA-256 ' + up.sha256 + ', confirmed on the TV; signing'); - var entry = { appId: up.ids.appId, packageId: up.ids.packageId, file: up.name, requiredVersion: up.ids.requiredVersion, tizen: b.tizen }; + var entry = { appId: up.ids.appId, packageId: up.ids.packageId, file: up.name, name: up.ids.name, requiredVersion: up.ids.requiredVersion, tizen: b.tizen, installed: !!b.installed, replace: !!b.replace }; var gate = tizenGate(entry); if (gate) { UPLOAD_BUSY = false; return cb(gate); } signAndInstall(entry, up.name.replace(/[^A-Za-z0-9._-]/g, '_'), UPLOAD_FILE, outFile, function (err, r) { @@ -294,24 +295,29 @@ function coded(code, message) { var e = new Error(message); e.code = code; retur // The page sends the TV's platform version with every install (entry.tizen, "4.0"); the package's // own manifest says what it asks for (entry.requiredVersion). A mismatch is refused here with the // reason, instead of by the TV with a bare [118, -19] (#44, #47). Either side unknown: no gate. -function tizenVersionOf(entry) { var m = /^\s*(\d+(?:\.\d+)?)/.exec(String(entry.tizen || '')); return m ? m[1] : ''; } function tizenGate(entry) { - var tv = tizenVersionOf(entry), req = entry.requiredVersion || ''; + var tv = refusals.tizenVersionOf(entry), req = entry.requiredVersion || ''; if (!manifest.needsNewer(req, tv)) return null; log('install: ' + (entry.file || 'the package') + ' asks for Tizen ' + req + ', this TV runs Tizen ' + tv + '; not pushing it'); return coded('NEEDS_NEWER_TIZEN', 'This app asks for Tizen ' + req + ' or newer, and this TV runs Tizen ' + tv + '. The TV would refuse it. If the catalog has a build for older sets, pick that one.'); } -// What the TV's refusal means where that is known. [118, -19] (118019 on a 2018 set) is the -// installer's "parsing error" on the manifest, in practice a package that asks for a newer Tizen -// than the set runs (#44); [118, -4] is the newer sets' spelling of that mismatch, next to "load -// archive info fail" for a bad package id. The manifest's own number goes in when it is known. -function refusal(code, reason, entry) { - var m = 'The TV refused the package [' + code + ']' + (reason ? ': ' + reason.trim() : ''); - var c = String(code).replace(/\s/g, ''), tv = tizenVersionOf(entry), req = entry.requiredVersion || ''; - if (!/^(118,-19|118019|118,-4|118004)$/.test(c)) return m; - if (req) return m + '. It asks for Tizen ' + req + ' or newer' + (tv ? ', and this TV runs Tizen ' + tv : '') + '.'; - return m + '. The TV could not read the package, which usually means it asks for a newer Tizen than this set runs.'; +// An app installed from a phone or PC carries that device's author certificate, and the TV only +// updates it with the same one (#44). When the page asks for a replace, the old copy goes first +// (0 vd_appuninstall, the verb the heads use), on its own connection; a refusal is logged and the +// install goes ahead anyway, so the TV's own answer is what the user sees. +function removeOld(entry, cb) { + var ip = ownIp(); + if (!entry.replace || !entry.packageId || !ip) return cb(); + log('install: removing the old copy of ' + entry.packageId + ' first (installed with another certificate)'); + connectSdb([ip, '127.0.0.1'], function (err, c) { + if (err) { log('install: could not remove the old copy: ' + err.message); return cb(); } + c.shell('0 vd_appuninstall ' + entry.packageId, function (err, out) { + c.end(); + log('install: vd_appuninstall reply: ' + (err ? err.message : out.replace(/\s+/g, ' ').slice(0, 200))); + cb(); + }); + }); } // sdbd on this TV, at the first address that completes the handshake. The LAN IP is what Developer @@ -351,7 +357,7 @@ function signAndInstall(entry, remoteName, inFile, outFile, cb) { log('install: signed in ' + Math.round((Date.now() - started) / 1000) + ' s, ' + size + ' bytes'); var ip = ownIp(); if (!ip) return cb(new Error('could not determine this TV’s own IP')); - connectSdb([ip, '127.0.0.1'], function (err, c) { + removeOld(entry, function () { connectSdb([ip, '127.0.0.1'], function (err, c) { if (err) return cb(err); var remote = STAGING + '/' + remoteName, fd; try { fd = fs.openSync(outFile, 'r'); } catch (e) { c.end(); return cb(e); } @@ -370,11 +376,11 @@ function signAndInstall(entry, remoteName, inFile, outFile, cb) { // sdbd answers the shell call either way; the installer's own words say whether it // worked ("install failed[118, -12], reason: ..." on a refused package). var failed = /install failed\[([^\]]*)\](?:,\s*reason:\s*([^\n]*))?/i.exec(out); - if (failed) return cb(coded('INSTALL_FAILED', refusal(failed[1], failed[2], entry))); + if (failed) { var r = refusals.refusal(failed[1], failed[2], entry); return cb(coded(r.code, r.message)); } cb(null, { reply: out }); }); }); - }); + }); }); }); } diff --git a/tizen-app/service/refusal.js b/tizen-app/service/refusal.js new file mode 100644 index 0000000..7a14ef1 --- /dev/null +++ b/tizen-app/service/refusal.js @@ -0,0 +1,46 @@ +/* What the TV's install refusal means, and what TV-Store can do about it (#44). Pure, so + * test/refusal.test.js covers every reading. ES5 for the TV's Node 4.4.3. + * + * sdbd answers "install failed[]" and, on sets newer than Tizen 4, ", reason: "; a 2018 + * set prints the code as six digits (118012 = [118, -12]) and no reason. + * [118, -12] the certificate check: with "same id and different certificate" (or, without a + * reason, when the page says the package is on the TV) the app was installed with + * another author certificate (from a phone or PC, or one since renewed), and the TV + * only updates an app with the certificate it came with: TV-Store can remove that + * copy and install again (CERT_MISMATCH). Otherwise the TV rejects the certificate + * itself: a set without internet during the install (it verifies online), a clock + * that is off, or a certificate past its dates (CERT_REJECTED). + * [118, -19] the installer's "parsing error" on the manifest: in practice a package that asks for + * a newer Tizen than the set runs (#44); [118, -4] is the newer sets' spelling of that, + * next to "load archive info fail" for a bad package id. + */ +'use strict'; + +// "4.0" out of what the page reports for this TV, '' when it reported nothing usable. +function tizenVersionOf(entry) { var m = /^\s*(\d+(?:\.\d+)?)/.exec(String((entry && entry.tizen) || '')); return m ? m[1] : ''; } + +function appName(entry) { return (entry && (entry.name || (entry.file && String(entry.file).replace(/\.(wgt|tpk)$/i, '')))) || 'This app'; } + +// -> { code, message }. code is CERT_MISMATCH, CERT_REJECTED or INSTALL_FAILED. +function refusal(code, reason, entry) { + entry = entry || {}; + var c = String(code || '').replace(/\s/g, ''), why = String(reason || '').trim(); + var m = 'The TV refused the package [' + code + ']' + (why ? ': ' + why : ''); + var tv = tizenVersionOf(entry), req = entry.requiredVersion || ''; + if (/^(118,-19|118019|118,-4|118004)$/.test(c)) { + if (req) return { code: 'INSTALL_FAILED', message: m + '. It asks for Tizen ' + req + ' or newer' + (tv ? ', and this TV runs Tizen ' + tv : '') + '.' }; + return { code: 'INSTALL_FAILED', message: m + '. The TV could not read the package, which usually means it asks for a newer Tizen than this set runs.' }; + } + if (/^(118,-12|118012)$/.test(c)) { + if (/same id and different certificate/i.test(why) || (!why && entry.installed)) + return { code: 'CERT_MISMATCH', message: appName(entry) + ' is already on this TV, signed with another certificate: it was installed from a phone or PC, or with a certificate that has since been renewed. The TV only updates an app with the certificate it came with. TV-Store can remove that copy and install again; the app’s settings and sign-ins on the TV go with it.' }; + if (/not yet valid|not valid yet/i.test(why)) + return { code: 'CERT_REJECTED', message: m + '. The TV says the certificate is not valid yet, so its clock is behind: set the TV’s date and time (Settings, General, System Manager, Time) or wait a few minutes, then try again.' }; + if (/expired/i.test(why)) + return { code: 'CERT_REJECTED', message: m + '. The TV says the certificate has expired: either its clock is ahead, or the certificate is past its end date and the phone or PC has to arm this TV again.' }; + return { code: 'CERT_REJECTED', message: m + '. The TV rejected the signing certificate. Make sure the TV has internet during the install (it checks the certificate online) and that its date and time are right, then try again; if it keeps failing, arm this TV again from Apps2Samsung.' }; + } + return { code: 'INSTALL_FAILED', message: m }; +} + +module.exports = { refusal: refusal, tizenVersionOf: tizenVersionOf }; From 8b7e9d8c593ec9631a615fc95e82d24d6a1d7401 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 30 Oct 2026 09:07:10 +0100 Subject: [PATCH 3/6] Keep every signing profile a head hands over and update with the one the app was installed with (#44) A Samsung TV only updates an app with the author certificate it was installed with. Arming now brings every profile the phone or PC holds (Apps2Samsung branch feat/tvstore-arm-all-profiles); the service keeps them all: - service/certs.js: the store ({ primary, certificates[] }, id = SHA-256 of the author leaf), the payload shapes (certificates[] from 3.2+ heads, the four top-level fields from every head since 0.2.0), merge of a second hand-over (a second device adds its profiles), the order to try per package, and a 0.3.x one-profile file read as a one-entry store; - signAndInstall signs with the profile the package took last time (else the head's first choice), and on "same id and different certificate" signs again with the next profile from the same input file until the TV takes one; the profile that worked is remembered per package; only when none is taken is the answer CERT_MISMATCH, naming the profiles tried, and the screen offers to remove the old copy; - /ping and Settings list the profiles on the TV; the sheet hint says an app from a phone or PC updates once that device armed this TV. test/certs.test.js covers the store; test/install.test.js runs the real service against the fake sdbd (A2S_SDB_PORT): retry with the next profile, remembered profile first, all refused, replace uninstalls first. Both are in the workflows. Not run on a TV. --- .github/workflows/build-wgt.yml | 10 ++- .github/workflows/release.yml | 2 + README.md | 20 +++-- test/certs.test.js | 50 ++++++++++++ test/install.test.js | 111 +++++++++++++++++++++++++++ tizen-app/js/app.js | 14 ++-- tizen-app/service/certs.js | 74 ++++++++++++++++++ tizen-app/service/index.js | 132 +++++++++++++++++++++----------- tizen-app/service/refusal.js | 2 +- 9 files changed, 355 insertions(+), 60 deletions(-) create mode 100644 test/certs.test.js create mode 100644 test/install.test.js create mode 100644 tizen-app/service/certs.js diff --git a/.github/workflows/build-wgt.yml b/.github/workflows/build-wgt.yml index 03d471f..1678cbf 100644 --- a/.github/workflows/build-wgt.yml +++ b/.github/workflows/build-wgt.yml @@ -14,7 +14,7 @@ jobs: node-version: '20' - name: Syntax-check the service and UI run: | - for f in tizen-app/service/sdb.js tizen-app/service/zip.js tizen-app/service/index.js tizen-app/service/signer.js tizen-app/service/catalog.js tizen-app/service/catalog-key.js tizen-app/service/compat.js tizen-app/service/manifest.js tizen-app/service/refusal.js tizen-app/js/app.js test/fake-sdbd.js test/run.js test/sign.test.js test/config.test.js test/sdb-errors.test.js test/manifest.test.js test/refusal.test.js; do + for f in tizen-app/service/sdb.js tizen-app/service/zip.js tizen-app/service/index.js tizen-app/service/signer.js tizen-app/service/catalog.js tizen-app/service/catalog-key.js tizen-app/service/compat.js tizen-app/service/manifest.js tizen-app/service/refusal.js tizen-app/service/certs.js tizen-app/js/app.js test/fake-sdbd.js test/run.js test/sign.test.js test/config.test.js test/sdb-errors.test.js test/manifest.test.js test/refusal.test.js test/certs.test.js test/install.test.js; do node --check "$f" done - name: config.xml ids (package id is 10 alphanumerics) @@ -35,6 +35,12 @@ jobs: run: node test/manifest.test.js - name: Install refusals (another certificate, rejected certificate, Tizen version) run: node test/refusal.test.js + - name: Signing profiles handed over (#44) + run: node test/certs.test.js + - name: Install against a fake sdbd: retry with the next profile, replace + run: node test/install.test.js + node test/certs.test.js + node test/install.test.js # The TV's own Node: v4.4.3 on a 2019 Tizen 5.0 set (#881). Buffer.from, Buffer.alloc, # crypto.sign and friends are newer than that, and only fail there; run every test on it too. - uses: actions/setup-node@v4 @@ -52,6 +58,8 @@ jobs: node test/sdb-errors.test.js node test/manifest.test.js node test/refusal.test.js + node test/certs.test.js + node test/install.test.js - name: Build the .wgt run: scripts/build-wgt.sh - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e3a05c..1118fe8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,8 @@ jobs: node test/sdb-errors.test.js node test/manifest.test.js node test/refusal.test.js + node test/certs.test.js + node test/install.test.js - name: Version in config.xml must match the tag run: | tag="${GITHUB_REF_NAME#v}" diff --git a/README.md b/README.md index 8ac32fb..d4df3e7 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ colours, big tiles for a couch. 1. Install TV-Store from Apps2Samsung like any other app, with Developer Mode's **Host PC IP** set to your phone or PC as usual. -2. In Apps2Samsung, open the TV's **Toolbox** and press **Arm TV-Store**. That hands your signing - certificate to the TV once. The TV-Store screen turns green by itself. +2. In Apps2Samsung, open the TV's **Toolbox** and press **Arm TV-Store**. That hands every signing + certificate on that phone or PC to the TV once. The TV-Store screen turns green by itself. 3. On the TV, set Developer Mode's **Host PC IP** to the TV's own address (TV-Store shows it). 4. Pick an app in TV-Store and press OK. It installs into the Apps row; no phone or PC needed from here on. Set Host PC IP back to your phone or PC whenever you want to use Apps2Samsung again. @@ -41,12 +41,15 @@ colours, big tiles for a couch. ## Apps installed from a phone or PC Every install is signed with an author certificate, and a Samsung TV only updates an app with the -same author certificate it was installed with. Arming hands this TV the certificate of the phone or -PC that pressed Arm; an app installed earlier from another device (or with a certificate that has -since been renewed) carries a different one, and the TV answers `install failed[118, -12]` (`118012` -on a 2018 set) when TV-Store tries to update it (#44). TV-Store then offers to remove the old copy -and install again. That is the only way past it; the app's settings and sign-ins on the TV go with -the old copy, which is why it asks first. +same author certificate it was installed with. Arming hands this TV every signing profile the phone +or PC holds (Public and Partner, the one older sets get, imported ones), and arming from a second +device adds its profiles to those. TV-Store remembers which profile each package took and, when the +TV answers `install failed[118, -12]` ("same id and different certificate"; `118012` on a 2018 +set), signs again with the next profile until the TV takes one (#44). So an app installed from a +phone updates from TV-Store once that phone has armed the TV. Only when none of the profiles on the +TV matches does TV-Store offer to remove the old copy and install again; the app's settings and +sign-ins on the TV go with the old copy, which is why it asks first. The arming device's profiles +are listed under Settings. ## How it works @@ -137,6 +140,7 @@ The header counts what the switches hide ("12 apps · 3 hidden"). - `tizen-app/service/compat.js`: buffers that work on the TV's Node 4.4.3 - `tizen-app/service/manifest.js`: the ids and the required Tizen version a package carries in its own manifest - `tizen-app/service/refusal.js`: what the TV's `install failed[...]` codes mean and what TV-Store offers for each +- `tizen-app/service/certs.js`: the signing profiles handed over, which one each package took, the order to try - `tizen-app/js/app.js`: the UI, and `window.A2SStore`, which Apps2Samsung calls to arm the TV - `tizen-app/js/qrcode.js`: QR codes for the log address and the Ko-fi link ([qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) 1.4.4, MIT) - `test/`: a fake `sdbd` plus signer and transport tests diff --git a/test/certs.test.js b/test/certs.test.js new file mode 100644 index 0000000..f60542f --- /dev/null +++ b/test/certs.test.js @@ -0,0 +1,50 @@ +/* certs.js keeps every signing profile a head hands over (#44): the payload shapes, merging a + * second hand-over, the order to try for a package, and reading a 0.3.x one-profile file. */ +'use strict'; +var fs = require('fs'), path = require('path'); +var certs = require('../tizen-app/service/certs.js'); +function fail(m) { console.error('FAIL: ' + m); process.exit(1); } +var F = path.join(__dirname, 'fixtures'); +function pem(f) { return fs.readFileSync(path.join(F, f), 'utf8'); } +var A = { authorKeyPem: pem('author.key'), authorCertPem: pem('author.crt'), distributorKeyPem: pem('distributor.key'), distributorCertPem: pem('distributor.crt') }; +// A second profile: the distributor pair as an author pair, different leaf, so a different id. +var B = { authorKeyPem: pem('distributor.key'), authorCertPem: pem('distributor.crt'), distributorKeyPem: pem('distributor.key'), distributorCertPem: pem('distributor.crt') }; + +var idA = certs.fingerprint(A.authorCertPem), idB = certs.fingerprint(B.authorCertPem); +if (!/^[0-9a-f]{64}$/.test(idA) || idA === idB) fail('fingerprint: ' + idA + ' ' + idB); +if (certs.fingerprint('not a pem') !== '') fail('fingerprint of junk must be empty'); + +// 0.2.0 to 0.3.12 heads send the four fields; 3.2+ heads add certificates[], first choice first. +var legacy = certs.normalize(A); +if (legacy.length !== 1 || legacy[0].id !== idA || legacy[0].name !== 'certificate 1') fail('legacy payload: ' + JSON.stringify(legacy.map(function (c) { return c.name; }))); +var both = certs.normalize({ authorKeyPem: A.authorKeyPem, authorCertPem: A.authorCertPem, distributorKeyPem: A.distributorKeyPem, distributorCertPem: A.distributorCertPem, + certificates: [ Object.assign({ name: 'Jelly2Sams - Public' }, A), Object.assign({ name: 'Jellyfin' }, B), Object.assign({ name: 'broken' }, A, { authorKeyPem: '' }) ] }); +if (both.length !== 2 || both[0].id !== idA || both[0].name !== 'Jelly2Sams - Public' || both[1].id !== idB || both[1].name !== 'Jellyfin') fail('full payload: ' + JSON.stringify(both.map(function (c) { return c.name; }))); +// A 3.2+ head whose top-level profile is not in its list (never the case, but cheap): it goes first. +var extra = certs.normalize(Object.assign({ certificates: [Object.assign({ name: 'Jellyfin' }, B)] }, A)); +if (extra.length !== 2 || extra[0].id !== idA || extra[1].name !== 'Jellyfin') fail('top-level profile outside the list goes first'); +if (certs.normalize({}).length !== 0 || certs.normalize(null).length !== 0) fail('an empty payload has no profiles'); + +// A store: the first hand-over, then a second phone adds B and keeps A. +var store = certs.merge(null, certs.normalize(Object.assign({ name: 'Jelly2Sams - Public' }, A))); +if (store.primary !== idA || store.certificates.length !== 1) fail('first store'); +store = certs.merge(store, certs.normalize({ certificates: [Object.assign({ name: 'Phone - Public' }, B)] })); +if (store.primary !== idB || store.certificates.length !== 2 || store.certificates[1].name !== 'Jelly2Sams - Public') fail('merged store: ' + JSON.stringify(certs.names(store))); +// Handing A over again under a new name replaces the entry and makes it primary. +store = certs.merge(store, certs.normalize({ certificates: [Object.assign({ name: 'PC - Public' }, A)] })); +if (store.primary !== idA || store.certificates.length !== 2 || certs.names(store).join('|') !== 'PC - Public|Phone - Public') fail('re-handover: ' + JSON.stringify(certs.names(store))); + +// Which to try for a package: the one it took before, then the primary, then the rest. +var o = certs.order(store, idB).map(function (c) { return c.id; }); +if (o.join() !== [idB, idA].join()) fail('order with a remembered profile'); +o = certs.order(store, 'gone').map(function (c) { return c.id; }); +if (o.join() !== [idA, idB].join()) fail('order with a forgotten profile'); +if (certs.order(null, idA).length !== 0) fail('order on no store'); + +// Files on disk: a 0.3.x one-profile file and this version's. +var old = certs.fromStored(A); +if (!old || old.primary !== idA || old.certificates.length !== 1) fail('0.3.x file'); +var fresh = certs.fromStored(JSON.parse(JSON.stringify(store))); +if (!fresh || fresh.primary !== idA || fresh.certificates.length !== 2) fail('this version\'s file'); +if (certs.fromStored({ junk: 1 }) !== null || certs.fromStored(null) !== null) fail('junk file'); +console.log('PASS: certificate store: payload shapes, merge of a second hand-over, order per package, 0.3.x file'); diff --git a/test/install.test.js b/test/install.test.js new file mode 100644 index 0000000..0c135eb --- /dev/null +++ b/test/install.test.js @@ -0,0 +1,111 @@ +/* The install path against the real service and a fake sdbd (#44): two profiles handed over, the + * TV refuses the copy signed with the first ("same id and different certificate"), the service + * signs again with the second and the install goes through; the profile that worked is remembered + * for the package; when every profile is refused the answer is CERT_MISMATCH naming them; and + * replace: true removes the old copy (0 vd_appuninstall) before the push. The service runs as a + * child with its data folder in a temp dir and sdbd pointed at the fake through A2S_SDB_PORT. */ +'use strict'; +var fs = require('fs'), os = require('os'), path = require('path'), http = require('http'), cp = require('child_process'); +var zip = require('../tizen-app/service/zip.js'); +var Buf = require('../tizen-app/service/compat.js'); +var fake = require('./fake-sdbd.js'); + +function fail(m) { console.error('FAIL: ' + m); process.exit(1); } +var SDB = 26193, F = path.join(__dirname, 'fixtures'); +function pem(f) { return fs.readFileSync(path.join(F, f), 'utf8'); } +var PC = { name: 'PC - Public', authorKeyPem: pem('author.key'), authorCertPem: pem('author.crt'), distributorKeyPem: pem('distributor.key'), distributorCertPem: pem('distributor.crt') }; +var PHONE = { name: 'Phone - Public', authorKeyPem: pem('distributor.key'), authorCertPem: pem('distributor.crt'), distributorKeyPem: pem('distributor.key'), distributorCertPem: pem('distributor.crt') }; +var dir = path.join(os.tmpdir(), 'a2s-install-' + process.pid + '-' + Date.now()); fs.mkdirSync(dir); +var wgt = zip.write([{ name: 'index.html', data: Buf.from('') }, + { name: 'config.xml', data: Buf.from('Probe') }]); + +// What the fake sdbd answers: a script of replies per vd_appinstall, and a record of every shell call. +var MISMATCH = 'install A2SProbe01.Probe package_path /x was_install_app return WAS_TRUE app_id[A2SProbe01.Probe] install start installing[7] app_id[A2SProbe01.Probe] install failed[118, -12], reason: Check certificate error : :Package with the same id and different certificate has been already installed.:<-12>\n'; +var OK = 'install A2SProbe01.Probe package_path /x was_install_app return WAS_TRUE installing[100] app_id[A2SProbe01.Probe] install completed\n'; +var replies = [], shells = []; +var child = null, err = '', handle = null; +var WD = setTimeout(function () { stop(); fail('watchdog (40 s)' + (err ? '\n' + err : '')); }, 40000); +function stop() { clearTimeout(WD); if (child) child.kill(); if (handle) try { handle.server.close(); } catch (e) {} } + +fake.start(SDB, { + shell: function (cmd) { shells.push(cmd); if (cmd.indexOf('vd_appinstall') >= 0) return replies.length ? replies.shift() : OK; return 'ok\n'; } +}, function (h) { + handle = h; + child = cp.spawn(process.execPath, [path.join(__dirname, '..', 'tizen-app', 'service', 'index.js')], { env: { HOME: dir, TMPDIR: dir, TMP: dir, TEMP: dir, PATH: process.env.PATH, A2S_SDB_PORT: String(SDB) }, stdio: ['ignore', 'ignore', 'pipe'] }); + child.stderr.on('data', function (d) { err += d; }); + var tries = 0; + (function waitUp() { + call('GET', '/ping', null, function (e, code) { + if (e || code !== 200) { if (++tries > 100 || child.exitCode !== null) fail('the service never answered /ping' + (err ? '\n' + err : '')); return setTimeout(waitUp, 100); } + run(); + }); + })(); +}); + +function call(method, p, data, cb) { + var req = http.request({ host: '127.0.0.1', port: 8077, path: p, method: method, headers: data ? { 'Content-Length': data.length, 'Content-Type': typeof data === 'string' ? 'application/json' : 'application/octet-stream' } : {} }, function (res) { + var b = ''; res.on('data', function (d) { b += d; }); res.on('end', function () { var j = null; try { j = JSON.parse(b); } catch (e) {} cb(null, res.statusCode, j, b); }); + }); + req.on('error', function (e) { cb(e); }); + if (data) req.write(data); + req.end(); +} +function token() { return fs.readFileSync(path.join(dir, '.a2s-upload-token'), 'utf8').trim(); } +// Upload the probe and install it with the given extra flags; cb(answer, installShellCalls). +function installUpload(flags, cb) { + var before = shells.length; + call('PUT', '/upload?name=Probe.wgt', wgt, function (e, code, j) { + if (e || code !== 200 || !j || !j.ok) fail('upload: ' + code + ' ' + JSON.stringify(j)); + var body = { upload: j.upload.id, token: token(), installed: true }; + Object.keys(flags).forEach(function (k) { body[k] = flags[k]; }); + call('POST', '/install', JSON.stringify(body), function (e, code, j) { + if (e || code !== 200 || !j) fail('install call: ' + code + ' ' + JSON.stringify(j)); + cb(j, shells.slice(before)); + }); + }); +} +function logs(cb) { call('GET', '/logs', null, function (e, code, j) { cb((j && j.logs || []).join('\n')); }); } + +function run() { + // Hand over both profiles, PC first; the old single-profile shape must still be accepted too. + call('POST', '/cert', JSON.stringify({ authorKeyPem: PC.authorKeyPem, authorCertPem: PC.authorCertPem, distributorKeyPem: PC.distributorKeyPem, distributorCertPem: PC.distributorCertPem, certificates: [PC, PHONE] }), function (e, code, j) { + if (e || code !== 200 || !j.ok || j.certCount !== 2) fail('hand-over: ' + code + ' ' + JSON.stringify(j)); + call('GET', '/ping', null, function (e, code, j) { + if (!j.certReady || j.certCount !== 2 || j.certNames.join('|') !== 'PC - Public|Phone - Public') fail('/ping after hand-over: ' + JSON.stringify(j)); + // 1. The TV refuses the PC's signature; the Phone's goes through. + replies = [MISMATCH, OK]; + installUpload({}, function (j, calls) { + if (!j.ok || j.result.certificate !== 'Phone - Public') fail('second profile must be taken: ' + JSON.stringify(j)); + if (calls.filter(function (c) { return c.indexOf('vd_appinstall') >= 0; }).length !== 2 || calls.some(function (c) { return c.indexOf('vd_appuninstall') >= 0; })) fail('expected two installs and no uninstall: ' + JSON.stringify(calls)); + logs(function (text) { + if (!/signed with ‘PC - Public’/.test(text) || !/trying ‘Phone - Public’/.test(text) || !/signed with ‘Phone - Public’/.test(text)) fail('log must show the retry:\n' + text); + var map = JSON.parse(fs.readFileSync(path.join(dir, '.a2s-tvstore-cert-by-package.json'), 'utf8')); + if (!map.A2SProbe01) fail('the profile that worked must be remembered for the package'); + // 2. Next time the Phone's profile goes first, so one install is enough. + replies = [OK]; + installUpload({}, function (j, calls) { + if (!j.ok || j.result.certificate !== 'Phone - Public' || calls.filter(function (c) { return c.indexOf('vd_appinstall') >= 0; }).length !== 1) fail('the remembered profile must go first: ' + JSON.stringify(j) + ' ' + JSON.stringify(calls)); + // 3. Every profile refused: CERT_MISMATCH naming them, nothing removed. + replies = [MISMATCH, MISMATCH]; + installUpload({}, function (j, calls) { + if (j.ok || j.code !== 'CERT_MISMATCH' || j.error.indexOf('None of the 2 certificates') < 0 || j.error.indexOf('Phone - Public') < 0) fail('all refused: ' + JSON.stringify(j)); + if (calls.some(function (c) { return c.indexOf('vd_appuninstall') >= 0; })) fail('nothing may be removed without replace'); + // 4. replace: the old copy goes first, then the install. + replies = [OK]; + installUpload({ replace: true }, function (j, calls) { + if (!j.ok) fail('replace: ' + JSON.stringify(j)); + var un = calls.indexOf('0 vd_appuninstall A2SProbe01'), ins = -1; + calls.forEach(function (c, i) { if (ins < 0 && c.indexOf('vd_appinstall') >= 0) ins = i; }); + if (un < 0 || ins < 0 || un > ins) fail('replace must uninstall before installing: ' + JSON.stringify(calls)); + stop(); + try { fs.readdirSync(dir).forEach(function (f) { fs.unlinkSync(path.join(dir, f)); }); fs.rmdirSync(dir); } catch (e) {} + console.log('PASS: a refused signature is retried with the next profile, the one taken is remembered, all refused is CERT_MISMATCH, replace uninstalls first'); + process.exit(0); + }); + }); + }); + }); + }); + }); + }); +} diff --git a/tizen-app/js/app.js b/tizen-app/js/app.js index 40937b0..50d3e1e 100644 --- a/tizen-app/js/app.js +++ b/tizen-app/js/app.js @@ -18,9 +18,10 @@ * PC IP set to 127.0.0.1) and files without a hash in the catalog (the TV cannot check those). * * Another certificate (#44): an app installed from a phone or PC carries that device's author - * certificate, and the TV only updates it with the same one. When the TV answers [118, -12] for an - * app that is on the TV, the install screen offers to remove the old copy and install again (the - * service runs 0 vd_appuninstall first); the app's settings on the TV go with it, so it is a choice. + * certificate, and the TV only updates it with the same one. Arming hands over every profile the + * phone or PC holds, and the service tries them in turn on a [118, -12]. Only when none is taken + * does the install screen offer to remove the old copy and install again (0 vd_appuninstall first); + * the app's settings on the TV go with it, so it is a choice. * * Tizen version (#47): the catalog says what Tizen each file asks for (required_version). A build * this TV is too old for carries a "Needs Tizen X" badge, its card is dimmed, and its Install button @@ -776,7 +777,7 @@ // Came from a phone or PC (TV-Store never installed this package): the TV keeps that device's // certificate, and an update from here may need the old copy removed first (#44). var fromHead = can && st.installed && v.packageId && !installedFile.hasOwnProperty(v.packageId); - h += '
' + (fromHead ? esc(x.name + ' was installed from a phone or PC. If that device used another certificate, the TV refuses the update and TV-Store offers to remove the old copy first. RETURN goes back to the store.') : can ? 'Takes about a minute. RETURN goes back to the store.' : 'RETURN goes back to the store.') + '
'; + h += '
' + (fromHead ? esc(x.name + ' was installed from a phone or PC. TV-Store signs with the certificate that installed it when that phone or PC armed this TV; otherwise it offers to remove the old copy first. RETURN goes back to the store.') : can ? 'Takes about a minute. RETURN goes back to the store.' : 'RETURN goes back to the store.') + '
'; return h; } @@ -868,7 +869,8 @@ + opt('verboseLog', 'Verbose logging', 'Writes much more detail to the TV-Store log. Turn it on when a bug report asks for it, and off again afterwards.') + ''; if (confirmingAdult) h += '
These apps are meant for adults only. Turn them on only if you are 18 or older.
' + '
'; - h += '
Signing certificate' + (armed() ? 'handed over' : 'not yet') + '
' + var certText = !armed() ? 'not yet' : ready.certCount > 1 ? ready.certCount + ' handed over: ' + (ready.certNames || []).join(', ') : 'handed over' + (ready.certNames && ready.certNames[0] ? ': ' + ready.certNames[0] : ''); + h += '
Signing certificate' + (ready && ready.certCount > 1 ? 's' : '') + '' + esc(certText) + '
' + '
Catalog' + esc(catalogApps.length + ' apps, signature checked') + '
' + '
TV-Storev' + VERSION + (ready && ready.node ? ' · Node ' + esc(ready.node) : '') + '
' + '
This TV' + esc((device ? device + ' · ' : '') + (tvTizen ? 'Tizen ' + tvTizen : 'Tizen version not known')) + '
'; @@ -1125,7 +1127,7 @@ xhr('POST', '/cert', certs, function (err, res) { if (err && ++tries < 30) return setTimeout(post, 500); if (err) return resolve({ ok: false, error: err.message }); - if (res && res.ok) setReady({ ok: true, certReady: true, ownIp: ready && ready.ownIp, node: ready && ready.node }); + if (res && res.ok) setReady({ ok: true, certReady: true, certCount: res.certCount || 1, certNames: certs && certs.certificates ? certs.certificates.map(function (c) { return c.name; }) : [], ownIp: ready && ready.ownIp, node: ready && ready.node }); resolve(res || { ok: false, error: 'no answer from the service' }); }); })(); diff --git a/tizen-app/service/certs.js b/tizen-app/service/certs.js new file mode 100644 index 0000000..1049e63 --- /dev/null +++ b/tizen-app/service/certs.js @@ -0,0 +1,74 @@ +/* The signing certificates a head hands over (#844), all of them (#44): a phone or PC holds more + * than one profile (Public and Partner, a bundled one for older sets, imported ones), and an app + * installed from it was signed with one of them. A Samsung TV only updates an app with the author + * certificate it was installed with, so TV-Store keeps every profile it is given, remembers which + * one each package took, and on a mismatch tries the next before it ever offers to remove the app. + * + * Stored shape (CERT_NAME in the data folder): + * { primary: , certificates: [ { id, name, authorKeyPem, authorCertPem, distributorKeyPem, distributorCertPem } ] } + * id is the SHA-256 of the author leaf certificate (DER), so the same profile handed over twice, + * or by two heads, is one entry. A 0.3.x file holding one bare profile is read as a one-entry store. + * Pure, ES5, Node 4: test/certs.test.js. */ +'use strict'; +var crypto = require('crypto'); +var Buf = require('./compat.js'); + +var FIELDS = ['authorKeyPem', 'authorCertPem', 'distributorKeyPem', 'distributorCertPem']; + +function complete(c) { return !!c && FIELDS.every(function (f) { return typeof c[f] === 'string' && c[f].length > 0; }); } + +// The author leaf's SHA-256 (hex), the id a profile is known by; '' when the PEM holds no certificate. +function fingerprint(authorCertPem) { + var m = /-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/.exec(String(authorCertPem || '')); + if (!m) return ''; + return crypto.createHash('sha256').update(Buf.from(m[1].replace(/\s+/g, ''), 'base64')).digest('hex'); +} + +function entry(c, i) { + var id = fingerprint(c.authorCertPem); + if (!id) return null; + return { id: id, name: String(c.name || ('certificate ' + (i + 1))).slice(0, 80), + authorKeyPem: c.authorKeyPem, authorCertPem: c.authorCertPem, distributorKeyPem: c.distributorKeyPem, distributorCertPem: c.distributorCertPem }; +} + +// What POST /cert carries, as a list, the head's first choice first: `certificates: [...]` (3.2+), +// and / or the four fields at the top level (every head since 0.2.0; a 3.2+ head sends both, and +// the named entry in the list is the one kept). [] when nothing is usable. +function normalize(body) { + var out = [], seen = {}; + function add(c, i) { var e = complete(c) ? entry(c, i) : null; if (e && !seen[e.id]) { seen[e.id] = true; out.push(e); } } + if (body && Array.isArray(body.certificates)) body.certificates.forEach(add); + if (body && complete(body)) { var top = entry(body, out.length); if (top && !seen[top.id]) out.unshift(top); } + return out; +} + +// The store after a hand-over: incoming profiles replace the stored ones with the same id and +// come first, the rest stays (a second phone or PC adds its profiles instead of wiping the first's). +function merge(stored, incoming) { + var list = incoming.slice(), seen = {}; + list.forEach(function (c) { seen[c.id] = true; }); + ((stored && stored.certificates) || []).forEach(function (c) { if (c && c.id && !seen[c.id]) { seen[c.id] = true; list.push(c); } }); + return { primary: list.length ? list[0].id : null, certificates: list }; +} + +// A file written by 0.3.x (one bare profile) or by this version. +function fromStored(obj) { + if (!obj) return null; + if (Array.isArray(obj.certificates)) return merge(null, normalize(obj)); + return complete(obj) ? merge(null, normalize(obj)) : null; +} + +// The profiles to try for a package, in order: the one it took last time (when still here), the +// head's first choice, then the rest. [] when the TV has none. +function order(store, preferredId) { + var list = (store && store.certificates) || [], out = []; + function push(id) { list.forEach(function (c) { if (c.id === id && out.indexOf(c) < 0) out.push(c); }); } + if (preferredId) push(preferredId); + if (store && store.primary) push(store.primary); + list.forEach(function (c) { if (out.indexOf(c) < 0) out.push(c); }); + return out; +} + +function names(store) { return ((store && store.certificates) || []).map(function (c) { return c.name; }); } + +module.exports = { normalize: normalize, merge: merge, fromStored: fromStored, order: order, names: names, fingerprint: fingerprint, complete: complete }; diff --git a/tizen-app/service/index.js b/tizen-app/service/index.js index 7a2e996..2a6a290 100644 --- a/tizen-app/service/index.js +++ b/tizen-app/service/index.js @@ -20,8 +20,11 @@ var catalogTrust = require('./catalog.js'); var CATALOG_KEY = require('./catalog-key.js'); var manifest = require('./manifest.js'); var refusals = require('./refusal.js'); +var certs = require('./certs.js'); var PORT = 8077; +// sdbd's port; a test points the service at a fake sdbd (test/install.test.js). +var SDB_PORT = parseInt(process.env.A2S_SDB_PORT || '', 10) || 26101; // Signed by the Apps2Samsung catalog workflow; see catalog.js for why the content is checked // instead of the connection. var CATALOG_URL = 'https://apps2samsung.com/catalog/resolved.json.signed'; @@ -325,7 +328,7 @@ function removeOld(entry, cb) { // When none gets in, the error says what to change on the TV instead of the socket error (#26). function connectSdb(hosts, cb, i, seen) { i = i || 0; seen = seen || []; - var c = new sdb.SdbClient(hosts[i]); + var c = new sdb.SdbClient(hosts[i], SDB_PORT); c.trace = VERBOSE ? function (m) { logv('sdb ' + hosts[i] + ': ' + m); } : null; c.connect(function (err) { if (!err) { log('install: sdb connected to ' + hosts[i] + ', first frame ' + c.firstFrame); return cb(null, c); } @@ -348,40 +351,59 @@ function sdbAdvice(err, seen, ip) { return err; } +// Signs with the profile the package took last time (else the head's first choice), pushes and +// installs. A TV only updates an app with the author certificate it was installed with (#44): when +// it answers "same id and different certificate", the next profile on this TV is tried, from the +// same input file, until one is taken or none is left. inFile is gone once an attempt was signed; +// a failure before that (no certificate yet) keeps it, so an upload stays waiting. function signAndInstall(entry, remoteName, inFile, outFile, cb) { - var started = Date.now(); - signer.signFile(inFile, outFile, loadCerts(), function (err) { - if (err) { log('install: ' + err.message); return cb(err); } // NO_CERT surfaces here - try { fs.unlinkSync(inFile); } catch (e) {} - var size = fs.statSync(outFile).size; - log('install: signed in ' + Math.round((Date.now() - started) / 1000) + ' s, ' + size + ' bytes'); - var ip = ownIp(); - if (!ip) return cb(new Error('could not determine this TV’s own IP')); - removeOld(entry, function () { connectSdb([ip, '127.0.0.1'], function (err, c) { - if (err) return cb(err); - var remote = STAGING + '/' + remoteName, fd; - try { fd = fs.openSync(outFile, 'r'); } catch (e) { c.end(); return cb(e); } - log('install: pushing to ' + remote); - var pushStarted = Date.now(); - c.push(remote, { size: size, read: function (off, len) { return zip.readAt(fd, off, len); } }, function (err) { - try { fs.closeSync(fd); } catch (e) {} - if (err) { c.end(); return cb(err); } - logv('timing: push took ' + (Date.now() - pushStarted) + ' ms'); - var installStarted = Date.now(); - c.shell('0 vd_appinstall ' + (entry.appId || entry.packageId || '') + ' ' + remote, function (err, out) { - c.end(); - if (err) return cb(err); - logv('timing: vd_appinstall took ' + (Date.now() - installStarted) + ' ms'); - log('install: vd_appinstall reply: ' + out.replace(/\s+/g, ' ').slice(0, 200)); - // sdbd answers the shell call either way; the installer's own words say whether it - // worked ("install failed[118, -12], reason: ..." on a refused package). - var failed = /install failed\[([^\]]*)\](?:,\s*reason:\s*([^\n]*))?/i.exec(out); - if (failed) { var r = refusals.refusal(failed[1], failed[2], entry); return cb(coded(r.code, r.message)); } - cb(null, { reply: out }); + var tries = certs.order(loadCerts(), entry.packageId ? certByPackage()[entry.packageId] : null), tried = [], i = 0; + if (!tries.length) { var none = coded('NO_CERT', 'No signing certificate available on this TV yet (#844 cert hand-over).'); log('install: ' + none.message); return cb(none); } + var ip = ownIp(); + if (!ip) return cb(new Error('could not determine this TV’s own IP')); + function finish(err, r) { try { fs.unlinkSync(inFile); } catch (e) {} try { fs.unlinkSync(outFile); } catch (e) {} cb(err, r); } + removeOld(entry, function () { (function attempt() { + var cert = tries[i++], started = Date.now(); + tried.push(cert); + signer.signFile(inFile, outFile, cert, function (err) { + if (err) { log('install: ' + err.message); return tried.length > 1 ? finish(err) : cb(err); } + var size = fs.statSync(outFile).size; + log('install: signed with ' + certLabel(cert) + ' in ' + Math.round((Date.now() - started) / 1000) + ' s, ' + size + ' bytes'); + connectSdb([ip, '127.0.0.1'], function (err, c) { + if (err) return finish(err); + var remote = STAGING + '/' + remoteName, fd; + try { fd = fs.openSync(outFile, 'r'); } catch (e) { c.end(); return finish(e); } + log('install: pushing to ' + remote); + var pushStarted = Date.now(); + c.push(remote, { size: size, read: function (off, len) { return zip.readAt(fd, off, len); } }, function (err) { + try { fs.closeSync(fd); } catch (e) {} + if (err) { c.end(); return finish(err); } + logv('timing: push took ' + (Date.now() - pushStarted) + ' ms'); + var installStarted = Date.now(); + c.shell('0 vd_appinstall ' + (entry.appId || entry.packageId || '') + ' ' + remote, function (err, out) { + c.end(); + if (err) return finish(err); + logv('timing: vd_appinstall took ' + (Date.now() - installStarted) + ' ms'); + log('install: vd_appinstall reply: ' + out.replace(/\s+/g, ' ').slice(0, 200)); + // sdbd answers the shell call either way; the installer's own words say whether it + // worked ("install failed[118, -12], reason: ..." on a refused package). + var failed = /install failed\[([^\]]*)\](?:,\s*reason:\s*([^\n]*))?/i.exec(out); + if (failed) { + var r = refusals.refusal(failed[1], failed[2], entry); + if (r.code === 'CERT_MISMATCH' && i < tries.length) { + log('install: the TV refused the copy signed with ' + certLabel(cert) + ' (another certificate installed it); trying ' + certLabel(tries[i])); + return attempt(); + } + if (r.code === 'CERT_MISMATCH') r.message += ' None of the ' + tried.length + ' certificate' + (tried.length === 1 ? '' : 's') + ' on this TV (' + tried.map(certLabel).join(', ') + ') is the one it was installed with: arm this TV from the phone or PC that installed it, or let TV-Store remove the old copy.'; + return finish(coded(r.code, r.message)); + } + rememberCert(entry.packageId, cert.id); + finish(null, { reply: out, certificate: cert.name }); + }); }); }); - }); }); - }); + }); + })(); }); } // The signed catalog, fetched and verified, cached for 10 minutes (the workflow publishes every @@ -404,19 +426,36 @@ function withCatalog(cb, fresh) { }); } -// The signing certificate a head hands over once (see #844): author/distributor key+cert as PEM -// (not a .p12: Node has no PKCS#12 parser). Kept in memory and persisted best-effort. +// The signing certificates a head hands over (see #844): author/distributor key+cert as PEM (not +// a .p12: Node has no PKCS#12 parser), every profile the head holds since #44 (certs.js has the +// shape). Kept in memory and persisted best-effort. function loadCerts() { if (CERTS) return CERTS; for (var i = 0; i < CERT_DIRS.length; i++) { - var f = path.join(CERT_DIRS[i], CERT_NAME); - try { CERTS = JSON.parse(fs.readFileSync(f, 'utf8')); CERT_FILE = f; } catch (e) { continue; } - // Found in a worse place (0.3.3 kept it in $HOME or /tmp): move it up where it persists. - if (i > 0) saveCerts(CERTS); + var f = path.join(CERT_DIRS[i], CERT_NAME), raw; + try { raw = JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { continue; } + var store = certs.fromStored(raw); + if (!store) continue; + CERTS = store; CERT_FILE = f; + // Found in a worse place (0.3.3 kept it in $HOME or /tmp), or in the one-profile shape: write it back. + if (i > 0 || !Array.isArray(raw.certificates)) saveCerts(CERTS); return CERTS; } return null; } +// Which profile each package was last signed with here, so an update takes the same one first. +var CERT_BY_PKG = null, CERT_BY_PKG_NAME = '.a2s-tvstore-cert-by-package.json'; +function certByPackage() { + if (CERT_BY_PKG) return CERT_BY_PKG; + try { CERT_BY_PKG = JSON.parse(fs.readFileSync(path.join(DATA_DIR, CERT_BY_PKG_NAME), 'utf8')) || {}; } catch (e) { CERT_BY_PKG = {}; } + return CERT_BY_PKG; +} +function rememberCert(packageId, certId) { + if (!packageId || !certId || !DATA_DIR) return; + var map = certByPackage(); if (map[packageId] === certId) return; + map[packageId] = certId; + try { fs.writeFileSync(path.join(DATA_DIR, CERT_BY_PKG_NAME), JSON.stringify(map)); } catch (e) {} +} function saveCerts(b) { CERTS = b; CERT_ERROR = null; for (var i = 0; i < CERT_DIRS.length; i++) { @@ -425,7 +464,8 @@ function saveCerts(b) { catch (e) { CERT_ERROR = f + ': ' + e.message; log('cert: cannot write ' + CERT_ERROR); } } } -function haveCerts() { var c = loadCerts(); return !!(c && c.authorKeyPem && c.distributorKeyPem); } +function haveCerts() { var c = loadCerts(); return !!(c && c.certificates && c.certificates.length); } +function certLabel(c) { return '\u2018' + (c.name || c.id.slice(0, 12)) + '\u2019'; } http.createServer(function (req, res) { if (req.method === 'OPTIONS') { cors(res, 204, 'text/plain'); return res.end(); } @@ -442,6 +482,7 @@ http.createServer(function (req, res) { // certificate or start an install. if (!fromThisTv(req)) { cors(res, 403, 'text/plain'); return res.end('only this TV may use TV-Store'); } if (route === '/ping') return json(res, 200, { ok: true, service: 'tv-store', ownIp: ownIp(), logUrl: 'http://' + ownIp() + ':' + PORT + '/log.txt', signerReady: signer.ready, certReady: haveCerts(), + certCount: haveCerts() ? loadCerts().certificates.length : 0, certNames: certs.names(loadCerts()), certFile: CERT_FILE, certError: CERT_ERROR, node: process.version, verbose: VERBOSE }); if (route === '/settings' && req.method === 'POST') return body(req, function (err, b) { if (err || !b || typeof b.verbose !== 'boolean') return json(res, 400, { ok: false, error: 'need { verbose: true|false }' }); @@ -458,12 +499,15 @@ http.createServer(function (req, res) { }); if (route === '/catalog') return withCatalog(function (err, cat) { if (err) { log('catalog: failed: ' + err.message); return json(res, 502, { ok: false, error: err.message }); } cors(res, 200, 'application/json'); res.end(cat.payload); }); if (route === '/cert' && req.method === 'POST') return body(req, function (err, b) { - if (err || !b || !b.authorKeyPem || !b.authorCertPem || !b.distributorKeyPem || !b.distributorCertPem) { - log('cert: refused a handover that is not { authorKeyPem, authorCertPem, distributorKeyPem, distributorCertPem }'); - return json(res, 400, { ok: false, error: 'need { authorKeyPem, authorCertPem, distributorKeyPem, distributorCertPem }' }); + var incoming = err ? [] : certs.normalize(b); + if (!incoming.length) { + log('cert: refused a handover that is not { authorKeyPem, authorCertPem, distributorKeyPem, distributorCertPem } or { certificates: [...] }'); + return json(res, 400, { ok: false, error: 'need { authorKeyPem, authorCertPem, distributorKeyPem, distributorCertPem } or { certificates: [ ... ] }' }); } - log('cert: received signing certificate from a head'); saveCerts(b); - json(res, 200, { ok: true, certFile: CERT_FILE, certError: CERT_FILE ? null : CERT_ERROR }); + var store = certs.merge(loadCerts(), incoming); + log('cert: received ' + incoming.length + ' signing certificate(s) from a head (' + incoming.map(certLabel).join(', ') + '); ' + store.certificates.length + ' on this TV now'); + saveCerts(store); + json(res, 200, { ok: true, certFile: CERT_FILE, certError: CERT_FILE ? null : CERT_ERROR, certCount: store.certificates.length }); }); if (route === '/install' && req.method === 'POST') return body(req, function (err, b) { if (err || !b || !(b.url || b.upload)) return json(res, 400, { ok: false, error: 'need { url, file, appId } or { upload, token }' }); diff --git a/tizen-app/service/refusal.js b/tizen-app/service/refusal.js index 7a14ef1..2830cbb 100644 --- a/tizen-app/service/refusal.js +++ b/tizen-app/service/refusal.js @@ -33,7 +33,7 @@ function refusal(code, reason, entry) { } if (/^(118,-12|118012)$/.test(c)) { if (/same id and different certificate/i.test(why) || (!why && entry.installed)) - return { code: 'CERT_MISMATCH', message: appName(entry) + ' is already on this TV, signed with another certificate: it was installed from a phone or PC, or with a certificate that has since been renewed. The TV only updates an app with the certificate it came with. TV-Store can remove that copy and install again; the app’s settings and sign-ins on the TV go with it.' }; + return { code: 'CERT_MISMATCH', message: appName(entry) + ' is already on this TV, signed with another certificate: it was installed from a phone or PC, or with a certificate that has since been renewed. The TV only updates an app with the certificate it came with.' }; if (/not yet valid|not valid yet/i.test(why)) return { code: 'CERT_REJECTED', message: m + '. The TV says the certificate is not valid yet, so its clock is behind: set the TV’s date and time (Settings, General, System Manager, Time) or wait a few minutes, then try again.' }; if (/expired/i.test(why)) From aa7b2a6041ae23bb89528b003b1926ade115b8f7 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 30 Oct 2026 09:16:31 +0100 Subject: [PATCH 4/6] Step over any certificate refusal while profiles remain (#44) A profile whose distributor does not cover this set (a PC's bundled one on a newer TV) gets a [118, -12] with another reason than the mismatch; the loop now moves to the next profile on any certificate refusal and answers the mismatch when it saw one, else the last refusal. install.test.js covers the chain refusal, mismatch then chain, and chain only. --- test/install.test.js | 19 +++++++++++++++++++ tizen-app/service/index.js | 17 ++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/test/install.test.js b/test/install.test.js index 0c135eb..eb38dfe 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -22,6 +22,8 @@ var wgt = zip.write([{ name: 'index.html', data: Buf.from('') }, // What the fake sdbd answers: a script of replies per vd_appinstall, and a record of every shell call. var MISMATCH = 'install A2SProbe01.Probe package_path /x was_install_app return WAS_TRUE app_id[A2SProbe01.Probe] install start installing[7] app_id[A2SProbe01.Probe] install failed[118, -12], reason: Check certificate error : :Package with the same id and different certificate has been already installed.:<-12>\n'; var OK = 'install A2SProbe01.Probe package_path /x was_install_app return WAS_TRUE installing[100] app_id[A2SProbe01.Probe] install completed\n'; +// A profile whose distributor does not cover this set (a PC's bundled one on a 2024 TV): a certificate refusal with another reason. +var CHAIN = 'install A2SProbe01.Probe package_path /x was_install_app return WAS_TRUE app_id[A2SProbe01.Probe] install failed[118, -12], reason: Check certificate error : :Invalid certificate chain.:<-3>\n'; var replies = [], shells = []; var child = null, err = '', handle = null; var WD = setTimeout(function () { stop(); fail('watchdog (40 s)' + (err ? '\n' + err : '')); }, 40000); @@ -90,6 +92,22 @@ function run() { installUpload({}, function (j, calls) { if (j.ok || j.code !== 'CERT_MISMATCH' || j.error.indexOf('None of the 2 certificates') < 0 || j.error.indexOf('Phone - Public') < 0) fail('all refused: ' + JSON.stringify(j)); if (calls.some(function (c) { return c.indexOf('vd_appuninstall') >= 0; })) fail('nothing may be removed without replace'); + // 3b. A profile the TV rejects for another reason (its distributor does not cover this set) is + // stepped over too; a mismatch seen on the way is what the answer says when all fail. + replies = [CHAIN, OK]; + installUpload({}, function (j, calls) { + if (!j.ok || j.result.certificate !== 'PC - Public') fail('a chain refusal must move to the next profile: ' + JSON.stringify(j)); + replies = [MISMATCH, CHAIN]; + installUpload({}, function (j) { + if (j.ok || j.code !== 'CERT_MISMATCH' || j.error.indexOf('None of the 2 certificates') < 0) fail('mismatch then chain must answer the mismatch: ' + JSON.stringify(j)); + replies = [CHAIN, CHAIN]; + installUpload({}, function (j) { + if (j.ok || j.code !== 'CERT_REJECTED') fail('only chain refusals must answer the rejection: ' + JSON.stringify(j)); + step4(); + }); + }); + }); + function step4() { // 4. replace: the old copy goes first, then the install. replies = [OK]; installUpload({ replace: true }, function (j, calls) { @@ -102,6 +120,7 @@ function run() { console.log('PASS: a refused signature is retried with the next profile, the one taken is remembered, all refused is CERT_MISMATCH, replace uninstalls first'); process.exit(0); }); + } }); }); }); diff --git a/tizen-app/service/index.js b/tizen-app/service/index.js index 2a6a290..ee77edf 100644 --- a/tizen-app/service/index.js +++ b/tizen-app/service/index.js @@ -353,11 +353,13 @@ function sdbAdvice(err, seen, ip) { // Signs with the profile the package took last time (else the head's first choice), pushes and // installs. A TV only updates an app with the author certificate it was installed with (#44): when -// it answers "same id and different certificate", the next profile on this TV is tried, from the -// same input file, until one is taken or none is left. inFile is gone once an attempt was signed; -// a failure before that (no certificate yet) keeps it, so an upload stays waiting. +// it refuses the certificate ("same id and different certificate", or any other [118, -12]: a +// distributor that does not cover this set, a chain the TV will not take), the next profile on +// this TV is tried, from the same input file, until one is taken or none is left. The answer is +// then the mismatch when any attempt was one, else the last refusal. inFile is gone once an +// attempt was signed; a failure before that (no certificate yet) keeps it, so an upload stays waiting. function signAndInstall(entry, remoteName, inFile, outFile, cb) { - var tries = certs.order(loadCerts(), entry.packageId ? certByPackage()[entry.packageId] : null), tried = [], i = 0; + var tries = certs.order(loadCerts(), entry.packageId ? certByPackage()[entry.packageId] : null), tried = [], i = 0, mismatch = null; if (!tries.length) { var none = coded('NO_CERT', 'No signing certificate available on this TV yet (#844 cert hand-over).'); log('install: ' + none.message); return cb(none); } var ip = ownIp(); if (!ip) return cb(new Error('could not determine this TV’s own IP')); @@ -390,11 +392,12 @@ function signAndInstall(entry, remoteName, inFile, outFile, cb) { var failed = /install failed\[([^\]]*)\](?:,\s*reason:\s*([^\n]*))?/i.exec(out); if (failed) { var r = refusals.refusal(failed[1], failed[2], entry); - if (r.code === 'CERT_MISMATCH' && i < tries.length) { - log('install: the TV refused the copy signed with ' + certLabel(cert) + ' (another certificate installed it); trying ' + certLabel(tries[i])); + if (r.code === 'CERT_MISMATCH') mismatch = r; + if ((r.code === 'CERT_MISMATCH' || r.code === 'CERT_REJECTED') && i < tries.length) { + log('install: the TV refused the copy signed with ' + certLabel(cert) + (r.code === 'CERT_MISMATCH' ? ' (another certificate installed it)' : ' (' + (failed[2] || failed[1]).trim() + ')') + '; trying ' + certLabel(tries[i])); return attempt(); } - if (r.code === 'CERT_MISMATCH') r.message += ' None of the ' + tried.length + ' certificate' + (tried.length === 1 ? '' : 's') + ' on this TV (' + tried.map(certLabel).join(', ') + ') is the one it was installed with: arm this TV from the phone or PC that installed it, or let TV-Store remove the old copy.'; + if (mismatch) r = { code: 'CERT_MISMATCH', message: mismatch.message + ' None of the ' + tried.length + ' certificate' + (tried.length === 1 ? '' : 's') + ' on this TV (' + tried.map(certLabel).join(', ') + ') is the one it was installed with: arm this TV from the phone or PC that installed it, or let TV-Store remove the old copy.' }; return finish(coded(r.code, r.message)); } rememberCert(entry.packageId, cert.id); From d277623720869f8dc2455bc7346889f1686129b0 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 30 Oct 2026 09:30:08 +0100 Subject: [PATCH 5/6] Got it on the "TV-Store vX is out" banner, remembered per version (#43) The banner cannot be acted on from the TV, so it gets a Got it that hides it for that catalog version (settings.hideSelfNotice in localStorage); the next release brings it back. Both the update banner and the renamed-copy one go through it. The On the TV row and the sheet keep saying the newer version. Checked in headless Chrome: banner, dismissed, still hidden after a reload, row still shows the update. --- tizen-app/index.html | 3 +++ tizen-app/js/app.js | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tizen-app/index.html b/tizen-app/index.html index f42d8eb..b1aaf8a 100644 --- a/tizen-app/index.html +++ b/tizen-app/index.html @@ -179,6 +179,9 @@ .notice{flex:none;font-size:18px;line-height:1.5;border-radius:14px;padding:14px 18px;margin-top:16px;background:rgba(240,160,32,.14);color:#F0A020} .notice.info{background:rgba(244,247,251,.06);color:#9FB0C6} .notice.bad{background:rgba(255,107,94,.14);color:#FF6B5E} + .notice.row{display:flex;align-items:center} + .notice.row>span{flex:1} + .notice.row .btn{flex:none;margin-left:18px;padding:8px 18px;font-size:16px} .sfoot{margin-top:auto;padding-top:18px;flex:none} .btns{display:flex} .btn{display:flex;align-items:center;justify-content:center;font:inherit;font-size:22px;font-weight:600;min-height:72px;padding:0 28px;border:0;border-radius:16px;background:rgba(244,247,251,.14);color:#F4F7FB;cursor:pointer;margin-right:14px;white-space:nowrap} diff --git a/tizen-app/js/app.js b/tizen-app/js/app.js index 50d3e1e..9c9aa4e 100644 --- a/tizen-app/js/app.js +++ b/tizen-app/js/app.js @@ -135,12 +135,15 @@ // ---- settings, remembered on the TV ------------------------------------------------------------- var SETTINGS_KEY = 'a2s.store.settings'; var settings = (function () { - var s = { showAdult: false, showNeedsHead: true, verboseLog: false }; + // hideSelfNotice: the TV-Store version whose "vX is out" banner was dismissed (#43); the + // banner comes back for the next release. + var s = { showAdult: false, showNeedsHead: true, verboseLog: false, hideSelfNotice: '' }; try { var saved = JSON.parse(window.localStorage.getItem(SETTINGS_KEY) || '{}'); if (typeof saved.showAdult === 'boolean') s.showAdult = saved.showAdult; if (typeof saved.showNeedsHead === 'boolean') s.showNeedsHead = saved.showNeedsHead; if (typeof saved.verboseLog === 'boolean') s.verboseLog = saved.verboseLog; + if (typeof saved.hideSelfNotice === 'string') s.hideSelfNotice = saved.hideSelfNotice; } catch (e) {} return s; })(); @@ -612,17 +615,20 @@ // TV-Store is in its own catalog: say so when a newer one is there (like Moonfin's update // popup, but from the signed catalog instead of the GitHub API). Not installable from here - // (#41): the installer would stop the service doing it. - var me = self(), ms = me ? state(me) : null; - if (ms && ms.update) h += '
TV-Store v' + esc(ms.latest) + ' is out (this is v' + esc(ms.version) + '). ' + SELF_HOW + '
'; + // (#41): the installer would stop the service doing it. Nothing to do on the TV, so the banner + // has a Got it that hides it for that version (#43); the On the TV row and the sheet keep + // saying it. It comes back for the next release. + var me = self(), ms = me ? state(me) : null, selfLatest = '', selfText = ''; + if (ms && ms.update) { selfLatest = ms.latest; selfText = 'TV-Store v' + ms.latest + ' is out (this is v' + ms.version + '). ' + SELF_HOW; } else if (me && selfRenamed()) { // Installed under another package id: the platform lists this copy under that id, so // state() cannot see it. Compare the catalog's version with our own and send the user to // the head that renamed it rather than install a second TV-Store from here. var own = (selfInfo() || {}).version || '', latest = me.variants[0] ? me.variants[0].version : ''; - if (own && latest && newer(latest, own)) h += '
TV-Store v' + esc(latest) + ' is out (this is v' + esc(own) - + '). This copy was installed under another package id by Apps2Samsung, so update it from Apps2Samsung (On the TV, Update) rather than from here.
'; + if (own && latest && newer(latest, own)) { selfLatest = latest; selfText = 'TV-Store v' + latest + ' is out (this is v' + own + '). This copy was installed under another package id by Apps2Samsung, so update it from Apps2Samsung (On the TV, Update) rather than from here.'; } } + if (selfLatest && settings.hideSelfNotice !== selfLatest) + h += '
' + esc(selfText) + '
'; if (!hover || apps.indexOf(hover) < 0) hover = apps[0] || null; if (!query) { @@ -988,6 +994,7 @@ case 'author': var by = sheet.app.author; closeSheet(function () { searchFor(by); }); return; case 'pin': togglePin(sheet.app, sheet.v); renderPage(); renderSheet(); focusKey('pin'); return; case 'retry': location.reload(); return; + case 'dismissself': settings.hideSelfNotice = el.getAttribute('data-v') || ''; saveSettings(); renderPage(); focusFirst(); return; case 'allvars': sheet.all = true; renderSheet(); focusKey('var:' + sheet.v); return; case 'install': if (!el.disabled) startInstall(sheet.app, sheet.v); return; case 'open': var s = state(sheet.app); launch(sheet.app.variants[s.variant].appId); return; From c13cf408590bb97883fd54955adfbe2ebf28aff3 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 30 Oct 2026 09:42:42 +0100 Subject: [PATCH 6/6] build-wgt.yml: no colon in a step name (the workflow stopped parsing) --- .github/workflows/build-wgt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-wgt.yml b/.github/workflows/build-wgt.yml index 1678cbf..59fa330 100644 --- a/.github/workflows/build-wgt.yml +++ b/.github/workflows/build-wgt.yml @@ -37,7 +37,7 @@ jobs: run: node test/refusal.test.js - name: Signing profiles handed over (#44) run: node test/certs.test.js - - name: Install against a fake sdbd: retry with the next profile, replace + - name: Install against a fake sdbd (retry with the next profile, replace) run: node test/install.test.js node test/certs.test.js node test/install.test.js