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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/build-wgt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/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)
Expand All @@ -33,6 +33,14 @@ 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
- 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
Expand All @@ -49,6 +57,9 @@ jobs:
node test/catalog.test.js
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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ jobs:
node test/catalog.test.js
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}"
Expand Down
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,33 @@ 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

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.

## 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 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

A Samsung TV only installs sideloaded packages through its developer connection (`sdbd`), the
Expand Down Expand Up @@ -123,7 +138,9 @@ 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/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
Expand Down
50 changes: 50 additions & 0 deletions test/certs.test.js
Original file line number Diff line number Diff line change
@@ -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');
Loading
Loading