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
5 changes: 4 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/js/app.js test/fake-sdbd.js test/run.js test/sign.test.js test/config.test.js test/sdb-errors.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/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
node --check "$f"
done
- name: config.xml ids (package id is 10 alphanumerics)
Expand All @@ -31,6 +31,8 @@ jobs:
run: node test/catalog.test.js
- name: sdb connect errors (refused, dropped without a reply)
run: node test/sdb-errors.test.js
- name: Package manifest ids (.wgt config.xml, .tpk tizen-manifest.xml)
run: node test/manifest.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 @@ -46,6 +48,7 @@ jobs:
node test/bundle.test.js
node test/catalog.test.js
node test/sdb-errors.test.js
node test/manifest.test.js
- name: Build the .wgt
run: scripts/build-wgt.sh
- uses: actions/upload-artifact@v4
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jobs:
node test/bundle.test.js
node test/catalog.test.js
node test/sdb-errors.test.js
node test/manifest.test.js
- name: Version in config.xml must match the tag
run: |
tag="${GITHUB_REF_NAME#v}"
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ an API key). The TV cannot do that yet, so those are shown greyed with a "Phone
install from Apps2Samsung on a phone or PC instead. The same goes for apps that need root, and for
any file the catalog has no hash for.

## Your own app (.wgt or .tpk)

Open `http://<TV IP>:8077/upload` on a phone or PC in the same network (Settings shows it as a QR
code), pick a `.wgt` or `.tpk` and send it. The TV keeps the file and TV-Store asks on screen:
who sent it, what the package calls itself, its hash, then **Install on this TV**, **Not now** or
**Discard**. Nothing installs until OK is pressed on the remote. The file is signed with your
certificate and installed like a catalog app, without the catalog's hash check (the file is yours).

One file waits at a time (a new one replaces it), at most 400 MB, and it must be a Tizen package
(a zip with `config.xml` or `tizen-manifest.xml` at its root). The upload page is open to the
network like the log; the install is not: only the TV-Store screen can start it, with a key the
service writes into the app's private data folder at every start, where no other app on the TV
can read it. The TV must be armed first.

## Settings

Press **▲** at the top of the list to reach **Settings**. The switches are remembered on the TV:
Expand Down Expand Up @@ -109,6 +123,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/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 All @@ -134,6 +149,8 @@ node test/sign.test.js # the signer, with a certificate chain
node --expose-gc test/stream-sign.test.js # the file-to-file signer on a 24 MB package
node test/bundle.test.js # the bundled service starts however the platform loads it
node test/catalog.test.js # the signed catalog: signature, age, hashes
node test/manifest.test.js # the ids of a .wgt / .tpk from its manifest
node test/upload.test.js # send a file to the service, see it wait, token gate, discard
```

Install the `.wgt` with Apps2Samsung like any package. A `vX.Y.Z` tag (matching the version in
Expand Down
39 changes: 39 additions & 0 deletions test/manifest.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* 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. */
'use strict';
var fs = require('fs'), os = require('os'), path = require('path');
var zip = require('../tizen-app/service/zip.js');
var manifest = require('../tizen-app/service/manifest.js');
var Buf = require('../tizen-app/service/compat.js');

function fail(m) { console.error('FAIL: ' + m); process.exit(1); }
var dir = path.join(os.tmpdir(), 'a2s-manifest-' + process.pid + '-' + Date.now()); fs.mkdirSync(dir); // no mkdtempSync on the TV's Node 4
function pkg(name, entries) { var f = path.join(dir, name); fs.writeFileSync(f, zip.write(entries)); return f; }

var wgt = pkg('a.wgt', [
{ name: 'index.html', data: Buf.from('<html></html>') },
{ name: 'config.xml', data: Buf.from('<?xml version="1.0"?><widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets" id="https://x" version="1.2.3"><name>Nuvio Legacy</name>\n <tizen:application id="NuvioTV002.Nuvio" package="NuvioTV002" required_version="4.0"/></widget>') }
]);
var tpk = pkg('b.tpk', [
{ name: 'bin/app', data: Buf.from('x') },
{ name: 'tizen-manifest.xml', data: Buf.from('<?xml version="1.0" encoding="utf-8"?><manifest xmlns="http://tizen.org/ns/packages" api-version="4.0" package="org.apps2samsung.overscan" version="0.2.0"><ui-application appid="org.apps2samsung.overscan.app" exec="app" type="capp"><label>Overscan</label></ui-application></manifest>') }
]);
var noManifest = pkg('c.wgt', [{ name: 'index.html', data: Buf.from('<html></html>') }]);
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));
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));
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');
});
});
});
});
75 changes: 75 additions & 0 deletions test/upload.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* The upload path (#30) end to end against the real service: PUT /upload stores a Tizen package
* and names it under /uploads; junk is refused; POST /install { upload } without the token is
* refused and with the token reaches the signer (which has no certificate here); discard clears it.
* The service is started as a child, with its data folder pointed at a temp dir through TMPDIR (and HOME for the certificate). */
'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');

function fail(m) { console.error('FAIL: ' + m); process.exit(1); }
var dir = path.join(os.tmpdir(), 'a2s-upload-' + process.pid + '-' + Date.now()); fs.mkdirSync(dir);
var wgt = zip.write([{ name: 'index.html', data: Buf.from('<html></html>') },
{ name: 'config.xml', data: Buf.from('<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets" version="1.0.0"><name>Probe</name><tizen:application id="A2SProbe01.Probe" package="A2SProbe01"/></widget>') }]);
var 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 }, stdio: ['ignore', 'ignore', 'pipe'] }), err = '';
child.stderr.on('data', function (d) { err += d; });
var WD = setTimeout(function () { child.kill(); fail('watchdog (15 s)' + (err ? '\n' + err : '')); }, 15000);

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 done(ok) { clearTimeout(WD); child.kill(); try { fs.readdirSync(dir).forEach(function (f) { fs.unlinkSync(path.join(dir, f)); }); fs.rmdirSync(dir); } catch (e) {} if (!ok) process.exit(1); console.log('PASS: upload stored and named, junk refused, install needs the token, discard clears it'); process.exit(0); }

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 run() {
var token = fs.readFileSync(path.join(dir, '.a2s-upload-token'), 'utf8').trim();
if (!/^[0-9a-f]{48}$/.test(token)) fail('no token file in the data folder: ' + JSON.stringify(token));
call('GET', '/upload', null, function (e, code, j, body) {
if (e || code !== 200 || body.indexOf('Send an app to this TV') < 0) fail('GET /upload is not the upload page (' + code + ')');
call('PUT', '/upload?name=junk.wgt', Buf.from('this is not a zip'), function (e, code, j) {
if (e || code !== 400 || !j || j.ok || !/not a Tizen package/.test(j.error)) fail('junk must be refused: ' + code + ' ' + JSON.stringify(j));
call('PUT', '/upload?name=notes.txt', wgt, function (e, code, j) {
if (e || code !== 400 || !j || j.ok) fail('a .txt must be refused: ' + code + ' ' + JSON.stringify(j));
call('PUT', '/upload?name=..%2F..%2FProbe%20App.wgt', wgt, function (e, code, j) {
if (e || code !== 200 || !j || !j.ok) fail('a .wgt must be taken: ' + code + ' ' + JSON.stringify(j));
var up = j.upload;
if (up.name !== 'Probe App.wgt' || up.size !== wgt.length || !/^[0-9a-f]{64}$/.test(up.sha256) || up.ids.appId !== 'A2SProbe01.Probe' || up.ids.packageId !== 'A2SProbe01' || up.ids.version !== '1.0.0' || up.ids.name !== 'Probe') fail('odd upload record ' + JSON.stringify(up));
if (!fs.existsSync(path.join(dir, 'upload.pkg'))) fail('the file is not in the data folder');
call('GET', '/uploads', null, function (e, code, j) {
if (e || code !== 200 || !j.pending || j.pending.id !== up.id) fail('/uploads does not list it: ' + JSON.stringify(j));
call('POST', '/install', JSON.stringify({ upload: up.id, token: 'wrong' }), function (e, code, j) {
if (e || code !== 200 || !j || j.ok || j.code !== 'NO_TOKEN') fail('install without the token must be refused: ' + JSON.stringify(j));
call('POST', '/install', JSON.stringify({ upload: up.id, token: token }), function (e, code, j) {
if (e || code !== 200 || !j || j.ok || j.code === 'NO_TOKEN' || j.code === 'NO_UPLOAD') fail('install with the token must reach the signer: ' + JSON.stringify(j));
// No certificate here, so the file stays and a discard clears it.
call('GET', '/uploads', null, function (e, code, j) {
if (e || !j.pending) fail('the upload must still wait after a failed signing: ' + JSON.stringify(j));
call('POST', '/upload/discard', '{}', function (e, code, j) {
if (e || code !== 200 || !j.ok) fail('discard failed: ' + JSON.stringify(j));
call('GET', '/uploads', null, function (e, code, j) {
if (e || j.pending) fail('still pending after discard');
if (fs.existsSync(path.join(dir, 'upload.pkg'))) fail('the file is still there after discard');
done(true);
});
});
});
});
});
});
});
});
});
});
}
Loading
Loading