@mahmoud_helmy/telecall 0.0.0-stage → 0.3.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -2
- package/THIRD-PARTY-NOTICES.md +29 -0
- package/app/Telecall.apk +0 -0
- package/app/app.json +9 -0
- package/app/page.html +38 -0
- package/app/page.js +81 -0
- package/app/pair.css +1 -0
- package/app/pair.html +6 -0
- package/app/pairing.mjs +116 -0
- package/app/server.mjs +138 -0
- package/app/style.css +1 -0
- package/app/vendor/qrcode/DIJKSTRA-LICENSE +19 -0
- package/app/vendor/qrcode/LICENSE +10 -0
- package/app/vendor/qrcode/dijkstrajs.cjs +165 -0
- package/app/vendor/qrcode/lib/core/alignment-pattern.js +83 -0
- package/app/vendor/qrcode/lib/core/alphanumeric-data.js +59 -0
- package/app/vendor/qrcode/lib/core/bit-buffer.js +37 -0
- package/app/vendor/qrcode/lib/core/bit-matrix.js +65 -0
- package/app/vendor/qrcode/lib/core/byte-data.js +30 -0
- package/app/vendor/qrcode/lib/core/error-correction-code.js +135 -0
- package/app/vendor/qrcode/lib/core/error-correction-level.js +50 -0
- package/app/vendor/qrcode/lib/core/finder-pattern.js +22 -0
- package/app/vendor/qrcode/lib/core/format-info.js +29 -0
- package/app/vendor/qrcode/lib/core/galois-field.js +69 -0
- package/app/vendor/qrcode/lib/core/kanji-data.js +54 -0
- package/app/vendor/qrcode/lib/core/mask-pattern.js +234 -0
- package/app/vendor/qrcode/lib/core/mode.js +167 -0
- package/app/vendor/qrcode/lib/core/numeric-data.js +43 -0
- package/app/vendor/qrcode/lib/core/polynomial.js +62 -0
- package/app/vendor/qrcode/lib/core/qrcode.js +495 -0
- package/app/vendor/qrcode/lib/core/reed-solomon-encoder.js +56 -0
- package/app/vendor/qrcode/lib/core/regex.js +31 -0
- package/app/vendor/qrcode/lib/core/segments.js +330 -0
- package/app/vendor/qrcode/lib/core/utils.js +63 -0
- package/app/vendor/qrcode/lib/core/version-check.js +9 -0
- package/app/vendor/qrcode/lib/core/version.js +163 -0
- package/app/vendor/qrcode/lib/renderer/svg-tag.js +81 -0
- package/app/vendor/qrcode/lib/renderer/utils.js +99 -0
- package/app/vendor/qrcode/package.json +4 -0
- package/bin/telecall.mjs +108 -0
- package/licenses/APACHE-2.0.txt +202 -0
- package/licenses/ASPNETCORE-LICENSE.txt +23 -0
- package/licenses/ASPNETCORE-THIRD-PARTY-NOTICES.txt +1110 -0
- package/licenses/CSWINRT-LICENSE.txt +21 -0
- package/licenses/DOTNET-LICENSE.txt +23 -0
- package/licenses/DOTNET-THIRD-PARTY-NOTICES.txt +1418 -0
- package/licenses/WINDOWS-SDK-LICENSE.html +1138 -0
- package/package.json +14 -4
package/README.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Telecall beta
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install Telecall on Android and pair it with a Windows computer using QR codes on your local network.
|
|
4
|
+
|
|
5
|
+
**This beta tests setup, simulated call screens, and encrypted call-status pairing. Desktop SIM-call audio is not yet verified.** No recording, cloud relay, SMS or contact sync is included.
|
|
6
|
+
|
|
7
|
+
## Test it
|
|
8
|
+
|
|
9
|
+
You need **Windows 11 x64**, **Node.js 22 or later**, and an **Android 10+** phone. Put the phone and PC on the same Wi-Fi or PC hotspot.
|
|
10
|
+
|
|
11
|
+
Run once on the PC:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx @mahmoud_helmy/telecall@beta
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The setup page opens in your browser. The phone app and Windows companion are included; Android Studio, .NET and APK_Bridge are not required.
|
|
18
|
+
|
|
19
|
+
1. Scan **Get the app** with your phone camera. Download **Telecall.apk**, open it, and allow your browser to install apps if Android asks.
|
|
20
|
+
2. On the PC page, open **Pair phone**, select the phone's network, and click **Create pairing QR**.
|
|
21
|
+
3. Open **Telecall Beta** on the phone: **Connect → Scan pairing QR**. Allow the camera, scan the PC screen, check the computer name/address and tap **Connect securely**.
|
|
22
|
+
|
|
23
|
+
You can try **Home → Try a call preview** without a SIM or calling permissions. The preview never places a real call. Becoming the default phone app is optional and is only needed for separate cellular-control tests.
|
|
24
|
+
|
|
25
|
+
The beta installs alongside the earlier diagnostic app. It is non-debuggable and signed with a dedicated beta key for compatible updates. Its build currently uses API 36, explicitly labelled in the app version; Android 17-specific permission behavior is unverified.
|
|
26
|
+
|
|
27
|
+
## Keep it installed on the PC
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npm install -g @mahmoud_helmy/telecall@beta
|
|
31
|
+
telecall
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Next time, run `telecall`. Stop it with **Stop sharing** on the PC page or `telecall stop`.
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
telecall --help
|
|
38
|
+
telecall doctor
|
|
39
|
+
telecall status
|
|
40
|
+
telecall serve --no-open
|
|
41
|
+
telecall --port 8771
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Before updating, stop sharing, then run the global install command again. Download the new APK from the refreshed PC page and tap **Update** on the phone. Do not uninstall a beta to work around a signing conflict; ask for a correctly signed update.
|
|
45
|
+
|
|
46
|
+
## Connection and privacy
|
|
47
|
+
|
|
48
|
+
Pairing uses a certificate-pinned encrypted link, a 256-bit single-use credential and a five-minute QR expiry. Confirm pairing on the phone. The credential is shown only on the PC's local page. Camera images are neither sent nor saved. This beta pairs for one session; create a new QR after disconnecting.
|
|
49
|
+
|
|
50
|
+
No Windows network-profile, firewall, administrator or router change is performed. Guest Wi-Fi isolation, VPNs or a firewall can still block device-to-device traffic. Telecall reports failures; it cannot override an organization's network policy. The PC page uses 8770 by default; the encrypted channel uses 8768 on your selected adapter and a loopback-only helper on 8769. Do not forward these ports to the internet.
|
|
51
|
+
|
|
52
|
+
The APK is publicly downloadable on the selected local network while sharing runs. Pairing controls and the pairing QR are computer-only. Stop sharing when finished. Optional logs contain companion startup errors and local addresses, not pairing secrets or phone numbers; set `TELECALL_HOME` to choose their folder.
|
|
53
|
+
|
|
54
|
+
## Report beta findings
|
|
55
|
+
|
|
56
|
+
Send the beta coordinator your app version, phone model/Android version, Windows version, network type and which step failed. Describe what you expected and what happened. Do not share live pairing QR codes, credentials, phone numbers or contact data.
|
|
57
|
+
|
|
58
|
+
Phone-side controls remain usable if the PC disconnects. No real SIM calls are placed automatically. Call-audio feasibility and broader device compatibility remain release gates.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
The npm beta includes the independently signed Android beta and a self-contained .NET 10 Windows companion. Full .NET/ASP.NET Core licenses and third-party notices are included under `licenses/` in that package. Android dependencies include AndroidX Core/Activity/Compose/CameraX, Kotlin, OkHttp 4.12.0 and ZXing 3.5.3, under Apache License 2.0; the full license is included there as `APACHE-2.0.txt`. These components retain their upstream copyrights and licenses. The npm beta uses the tester's existing Node runtime rather than bundling Node.
|
|
4
|
+
|
|
5
|
+
The Android Gradle baseline, wrapper, and connection/lifecycle architecture were adapted using the local APK Bridge project. Telecall is a separate application and does not share its tokens or app identity. APK Bridge's license is reproduced below. The Gradle wrapper is distributed by Gradle under Apache License 2.0; dependency artifacts retain their respective licenses. Windows self-contained runtime license files are retained in build output.
|
|
6
|
+
|
|
7
|
+
## APK Bridge — MIT License
|
|
8
|
+
|
|
9
|
+
The local installation page follows APK Bridge's QR-to-download pattern. Its offline QR generator vendors the qrcode 1.5.4 core and SVG renderer (Copyright 2012 Ryan Day) and dijkstrajs (Copyright 2008 Wyatt Baldwin), both MIT licensed. Full license texts are in `install/vendor/qrcode/LICENSE` and `install/vendor/qrcode/DIJKSTRA-LICENSE`. The only QR library modification changes the dijkstrajs import to its vendored relative path. The portable install bundle includes the existing local Node.js runtime; its complete license notices are preserved in `runtime/LICENSE`.
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Mahmoud Helmy
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
package/app/Telecall.apk
ADDED
|
Binary file
|
package/app/app.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.3.0-beta.2-api36",
|
|
3
|
+
"packageName": "com.telecall.diagnostics.beta",
|
|
4
|
+
"companionVersion": "0.3.0-beta.2",
|
|
5
|
+
"size": 3548980,
|
|
6
|
+
"sha256": "87ca4b3ca6b8469a97fda017eaff2630d33dec811d01c66f2940e8b5fee27c8f",
|
|
7
|
+
"minimumAndroid": 10,
|
|
8
|
+
"channel": "signed-beta"
|
|
9
|
+
}
|
package/app/page.html
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Install Telecall</title><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/pair.css"><script src="/page.js" defer></script></head>
|
|
4
|
+
<body class="{{MODE}}" data-stop="{{STOP}}">
|
|
5
|
+
<div class="shell">
|
|
6
|
+
<header><a class="brand" href="/"><span class="mark" aria-hidden="true">↗</span>Telecall</a><nav class="desktop-only" aria-label="Setup"><a href="/" aria-current="page">Get the app</a><a href="/pair">Pair phone</a></nav><span class="badge phone-only">PREVIEW</span></header>
|
|
7
|
+
<main>
|
|
8
|
+
<section class="intro">
|
|
9
|
+
<p class="eyebrow"><span class="dot"></span>{{STATUS}}</p>
|
|
10
|
+
<h1>Your phone.<br>Your computer.<br><em>One closer step.</em></h1>
|
|
11
|
+
<p class="lead">Get Telecall on your Android phone. Try the call experience and set up a secure link to your computer.</p>
|
|
12
|
+
<div class="facts"><span>Android 10+</span><span>{{SIZE}}</span><span>No SIM needed to preview</span></div>
|
|
13
|
+
<p class="fine">Desktop call audio is still being verified. This is a local test version.</p>
|
|
14
|
+
</section>
|
|
15
|
+
<section class="install-card" aria-label="Install Telecall">
|
|
16
|
+
<div class="desktop-only">
|
|
17
|
+
<div class="card-heading"><span class="step-label">01 / OPEN ON YOUR PHONE</span><span class="live">Local link</span></div>
|
|
18
|
+
<h2>Scan. Download. Install.</h2>
|
|
19
|
+
<p class="muted">Point your phone’s camera at the QR code.</p>
|
|
20
|
+
<div class="qr-frame"><img src="/qr.svg?host={{HOST}}" width="240" height="240" alt="QR code opening the Telecall install page on this network"></div>
|
|
21
|
+
<label for="network">Phone connected to a different network?</label>
|
|
22
|
+
<select id="network" aria-describedby="network-hint">{{OPTIONS}}</select>
|
|
23
|
+
<p class="hint" id="network-hint">{{NETWORK}}</p>
|
|
24
|
+
<div class="address-row"><input id="address" aria-label="Phone download address" value="{{ADDRESS}}" readonly><button id="copy" type="button">Copy link</button></div>
|
|
25
|
+
<p class="feedback" id="copy-result" role="status"></p>
|
|
26
|
+
<div class="divider"><span>or transfer the file yourself</span></div>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="phone-only"><span class="step-label">LET’S GET YOU SET UP</span><h2>Telecall, on your phone.</h2><p class="muted">Download the app, then open it to install.</p></div>
|
|
29
|
+
<a class="download" href="/Telecall.apk" download="Telecall.apk">Download Telecall <span aria-hidden="true">↓</span></a>
|
|
30
|
+
<p class="version">{{VERSION}} · Signed preview APK</p>
|
|
31
|
+
<ol class="steps"><li><span>1</span><div><strong>Open the download</strong><p>Find <b>Telecall.apk</b> in your browser’s downloads or Files app.</p></div></li><li><span>2</span><div><strong>Allow installation if asked</strong><p>Tap Settings, allow this browser to install apps, then go back and tap Install.</p></div></li><li><span>3</span><div><strong>Try your first preview</strong><p>Open Telecall and tap <b>Try a call preview</b>. It needs no SIM or phone permissions.</p></div></li></ol>
|
|
32
|
+
<details><summary>Need a hand?</summary><p><strong>The link won’t open?</strong> Use the same Wi-Fi, or connect the phone to the PC’s Mobile hotspot. On the computer, choose the matching network above. Guest Wi-Fi and VPNs may block the connection.</p><p><strong>Replacing the existing preview?</strong> Tap Update to keep its settings. If Android reports a signature conflict, keep the existing app and ask for a build signed with the same key.</p><p><strong>Blocked by Android?</strong> Keep the warning intact. On a phone you own with Wireless debugging already authorized, APK Bridge can install this APK through your computer.</p></details>
|
|
33
|
+
<a class="checksum" href="/sha256.txt">View file checksum</a>
|
|
34
|
+
</section>
|
|
35
|
+
</main>
|
|
36
|
+
<footer><span>PHONE + DESKTOP, A LITTLE CLOSER.</span><button class="desktop-only" id="stop" type="button">Stop sharing</button><p id="share-status" role="status" aria-live="polite"></p></footer>
|
|
37
|
+
</div>
|
|
38
|
+
</body></html>
|
package/app/page.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const network = document.querySelector('#network');
|
|
2
|
+
network?.addEventListener('change', () => { location.search = new URLSearchParams({host: network.value}); });
|
|
3
|
+
document.querySelector('#copy')?.addEventListener('click', async () => {
|
|
4
|
+
const field = document.querySelector('#address');
|
|
5
|
+
const status = document.querySelector('#copy-result');
|
|
6
|
+
try { await navigator.clipboard.writeText(field.value); status.textContent = 'Link copied. Send it to your phone.'; }
|
|
7
|
+
catch { field.focus(); field.select(); status.textContent = 'Select the address and copy it to your phone.'; }
|
|
8
|
+
});
|
|
9
|
+
document.querySelector('#stop')?.addEventListener('click', async event => {
|
|
10
|
+
const status = document.querySelector('#share-status');
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetch('/stop', {method: 'POST', headers: {'X-Telecall-Stop': document.body.dataset.stop}});
|
|
13
|
+
if (!response.ok) throw new Error();
|
|
14
|
+
status.textContent = 'Sharing stopped. You can close this page. Double-click Install Telecall to share again.';
|
|
15
|
+
event.target.disabled = true;
|
|
16
|
+
document.querySelector('.download')?.removeAttribute('href');
|
|
17
|
+
} catch { status.textContent = 'The sharing service is no longer available. Close this page or reopen Install Telecall.'; }
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (document.body.classList.contains('pairing')) {
|
|
21
|
+
const title = document.querySelector('#pair-title');
|
|
22
|
+
const message = document.querySelector('#pair-message');
|
|
23
|
+
const error = document.querySelector('#pair-error');
|
|
24
|
+
const start = document.querySelector('#pair-start');
|
|
25
|
+
const stop = document.querySelector('#pair-stop');
|
|
26
|
+
const select = document.querySelector('#pair-network');
|
|
27
|
+
const image = document.querySelector('#pair-image');
|
|
28
|
+
const box = document.querySelector('#pair-qr');
|
|
29
|
+
let expires = null;
|
|
30
|
+
const descriptions = {
|
|
31
|
+
idle:['Ready to pair?','Choose the network your phone uses, then create a secure pairing QR.'],
|
|
32
|
+
starting:['Preparing your private link','Starting the encrypted listener on your selected network. No Windows settings are changed.'],
|
|
33
|
+
waiting:['Scan with Telecall','On your phone: Connect → Scan pairing QR. Then confirm this computer.'],
|
|
34
|
+
connected:['Your phone is connected','The encrypted link is active. Audio stays on your phone.'],
|
|
35
|
+
disconnected:['Phone disconnected','Create a fresh QR to connect again. Your cellular call remains on the phone.'],
|
|
36
|
+
expired:['Time for a fresh QR','The five-minute pairing window ended. Create another QR to continue.'],
|
|
37
|
+
blocked:['Connection setup needs attention','The secure connection could not start on this network.']
|
|
38
|
+
};
|
|
39
|
+
function render(state) {
|
|
40
|
+
const [heading, copy] = descriptions[state.state] || descriptions.blocked;
|
|
41
|
+
title.textContent = heading; message.textContent = copy;
|
|
42
|
+
const active = ['starting','waiting','connected'].includes(state.state);
|
|
43
|
+
select.disabled = active;
|
|
44
|
+
start.disabled = state.state === 'starting';
|
|
45
|
+
start.hidden = state.state === 'connected';
|
|
46
|
+
start.textContent = state.state === 'waiting' ? 'Create a fresh QR' : 'Create pairing QR';
|
|
47
|
+
stop.hidden = !active;
|
|
48
|
+
stop.textContent = state.state === 'connected' ? 'Disconnect phone' : 'Cancel pairing';
|
|
49
|
+
box.hidden = state.state !== 'waiting' || Date.now() >= state.expiresAt;
|
|
50
|
+
if (!box.hidden) {
|
|
51
|
+
if (expires !== state.expiresAt) {expires = state.expiresAt; image.src = `/pair/qr.svg?t=${Date.now()}`;}
|
|
52
|
+
const remaining = Math.max(0,Math.ceil((state.expiresAt-Date.now())/1000));
|
|
53
|
+
document.querySelector('#pair-countdown').textContent = `Expires in ${Math.floor(remaining/60)}:${String(remaining%60).padStart(2,'0')} · Keep this QR private`;
|
|
54
|
+
} else {image.removeAttribute('src'); expires = null;}
|
|
55
|
+
error.textContent = state.state !== 'blocked' ? '' : ({
|
|
56
|
+
PrivateProfileRequired:'Windows marks this network Public. If you trust it, select Private in Windows network settings, then try again.',
|
|
57
|
+
CannotVerifyPrivateProfile:'Windows could not confirm a Private profile for this adapter. Choose your trusted Private Wi-Fi network above.',
|
|
58
|
+
MissingPairingHost:'The secure companion is missing. Extract the complete updated Telecall installer folder.',
|
|
59
|
+
CouldNotStartSecureListener:'The secure listener could not start. Check that another Telecall session is not using port 8768.'
|
|
60
|
+
,LanPortBusy:'Port 8768 is already in use. Stop the other Telecall session, then create a fresh QR.'
|
|
61
|
+
}[state.code] || 'Check the companion and your Windows network settings, then create a fresh QR.');
|
|
62
|
+
}
|
|
63
|
+
async function action(path) {
|
|
64
|
+
error.textContent = '';
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetch(`/pair/${path}?host=${encodeURIComponent(select.value)}`,{method:'POST',headers:{'X-Telecall-Stop':document.body.dataset.stop}});
|
|
67
|
+
const body = await response.json();
|
|
68
|
+
if (!response.ok) throw new Error(body.error || 'Could not update pairing.');
|
|
69
|
+
render(body);
|
|
70
|
+
} catch (failure) {error.textContent = failure.message || 'The companion is unavailable. Reopen Install Telecall.';}
|
|
71
|
+
}
|
|
72
|
+
start.addEventListener('click', () => action('start'));
|
|
73
|
+
stop.addEventListener('click', () => action('stop'));
|
|
74
|
+
async function refresh() {
|
|
75
|
+
try {const response = await fetch('/pair/status'); if (response.ok) render(await response.json());}
|
|
76
|
+
catch {error.textContent = 'The companion is unavailable. Reopen Install Telecall.';}
|
|
77
|
+
}
|
|
78
|
+
const poll = setInterval(refresh,1000);
|
|
79
|
+
window.addEventListener('pagehide', () => clearInterval(poll));
|
|
80
|
+
refresh();
|
|
81
|
+
}
|
package/app/pair.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[hidden]{display:none!important}nav{display:flex;gap:6px}nav a{padding:10px 14px;text-decoration:none;border-radius:25px;font-size:13px;font-weight:600}nav a[aria-current]{background:#d5f4de}nav a:hover{background:#e5eee6}.full{width:100%;border:0;margin:18px 0}.secondary{background:#eef3ee;border:1px solid #c9d7cd;border-radius:30px;color:#163d38;min-height:48px;font:inherit;cursor:pointer}.pair-qr{text-align:center;margin:22px 0;background:#f3f6f3;border-radius:18px;padding:15px}.pair-qr img{max-width:100%;height:auto;display:block;margin:auto;background:white;border-radius:10px}.pair-qr .version{margin-bottom:0}.pairing .feedback{color:#a43e35}.pairing .install-card h2{line-height:1.2}.pairing .download{justify-content:center;gap:14px}.pairing select:disabled{opacity:.65}@media(max-width:680px){nav a{padding:9px 10px;font-size:12px}.shell{padding:24px 20px}.brand{gap:8px;font-size:23px}.mark{width:36px;height:36px}.pairing h1{font-size:46px}.pairing .intro{padding-top:5px}}
|
package/app/pair.html
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Pair phone · Telecall</title><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/pair.css"><script src="/page.js" defer></script></head>
|
|
3
|
+
<body class="desktop pairing" data-stop="{{STOP}}"><div class="shell">
|
|
4
|
+
<header><a class="brand" href="/"><span class="mark" aria-hidden="true">↗</span>Telecall</a><nav aria-label="Setup"><a href="/">Get the app</a><a href="/pair" aria-current="page">Pair phone</a></nav></header>
|
|
5
|
+
<main><section class="intro"><p class="eyebrow"><span class="dot"></span>YOUR COMPUTER, YOUR CONNECTION</p><h1>A quick scan.<br><em>A private link.</em></h1><p class="lead">Open Telecall on your phone. Go to Connect, tap <strong>Scan pairing QR</strong>, and point your camera here.</p><div class="facts"><span>Encrypted connection</span><span>Single-use QR</span><span>Expires in 5 minutes</span></div><p class="fine">Confirm the computer on your phone before connecting. This preview pairs for one session; audio stays on the phone. Keep this QR private.</p></section>
|
|
6
|
+
<section class="install-card" aria-label="Pair phone"><span class="step-label">02 / CONNECT YOUR PHONE</span><h2 id="pair-title">Ready to pair?</h2><p id="pair-message" class="muted" role="status" aria-live="polite">Choose the network your phone uses, then create a secure pairing QR.</p><label for="pair-network">Computer network</label><select id="pair-network">{{OPTIONS}}</select><div class="pair-qr" id="pair-qr" hidden><img id="pair-image" width="320" height="320" alt="Private single-use Telecall pairing QR"><p id="pair-countdown" class="version"></p></div><button id="pair-start" class="download full" type="button">Create pairing QR <span aria-hidden="true">↗</span></button><button id="pair-stop" class="secondary full" type="button" hidden>Cancel pairing</button><p id="pair-error" class="feedback" role="alert"></p><ol class="steps"><li><span>1</span><div><strong>Scan from inside Telecall</strong><p>Connect → Scan pairing QR. Allow the camera when asked.</p></div></li><li><span>2</span><div><strong>Check the computer</strong><p>Your phone shows its name and address. Tap Connect securely if they match.</p></div></li><li><span>3</span><div><strong>You’re connected</strong><p>Both devices confirm the encrypted link. A fresh QR is needed after disconnecting.</p></div></li></ol><details><summary>Connection needs attention?</summary><p>Pairing works without changing the Windows network profile. Both devices must be able to reach each other. Guest-network isolation, firewall policy and VPNs can still block the connection; Telecall reports these failures and does not change those settings.</p><p>The download QR gets the app; this pairing QR connects it. They are separate steps. Camera images stay on your phone.</p></details></section></main><footer><span>KEEP YOUR CONNECTION CLOSE.</span><a href="/">Back to installation</a></footer></div></body></html>
|
package/app/pairing.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import {existsSync} from 'node:fs';
|
|
3
|
+
import {join, resolve} from 'node:path';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import {createRequire} from 'node:module';
|
|
6
|
+
|
|
7
|
+
export function resolvePairingHost(root) {
|
|
8
|
+
const portable = join(root,'pairing','Telecall.PairingHost.exe');
|
|
9
|
+
if (existsSync(portable)) return portable;
|
|
10
|
+
try {return createRequire(import.meta.url).resolve('@mahmoud_helmy/telecall-windows-x64/host');}
|
|
11
|
+
catch {return null;}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class PairingController {
|
|
15
|
+
#child = null;
|
|
16
|
+
#state = {state:'idle'};
|
|
17
|
+
#payload = null;
|
|
18
|
+
#timer = null;
|
|
19
|
+
#generation = 0;
|
|
20
|
+
#proxy = null;
|
|
21
|
+
#sockets = new Set();
|
|
22
|
+
constructor(root) { this.root = root; }
|
|
23
|
+
status() { return {...this.#state}; }
|
|
24
|
+
payload() { return this.#state.state === 'waiting' && Date.now() < this.#state.expiresAt ? this.#payload : null; }
|
|
25
|
+
async start(address) {
|
|
26
|
+
if (['connected','starting'].includes(this.#state.state)) throw new Error('Finish or disconnect the current session before creating another QR.');
|
|
27
|
+
const previous = this.#child;
|
|
28
|
+
this.stop();
|
|
29
|
+
const generation = this.#generation;
|
|
30
|
+
this.#state = {state:'starting'};
|
|
31
|
+
if (previous && previous.exitCode === null) await new Promise(ok => {
|
|
32
|
+
const timer = setTimeout(ok,2000); timer.unref();
|
|
33
|
+
previous.once('exit', () => {clearTimeout(timer); ok();});
|
|
34
|
+
});
|
|
35
|
+
if (generation !== this.#generation) return;
|
|
36
|
+
// Only the selected adapter is exposed. This is a raw TLS passthrough: Node
|
|
37
|
+
// cannot read cellular status or modify the certificate-pinned session.
|
|
38
|
+
const proxy = net.createServer(client => {
|
|
39
|
+
if (!['waiting','connected'].includes(this.#state.state) || this.#sockets.size >= 8) {client.destroy(); return;}
|
|
40
|
+
const upstream = net.connect({host:'127.0.0.1',port:8769});
|
|
41
|
+
this.#sockets.add(client); this.#sockets.add(upstream);
|
|
42
|
+
for (const socket of [client,upstream]) {
|
|
43
|
+
socket.setTimeout(45000, () => socket.destroy());
|
|
44
|
+
socket.on('error', () => {client.destroy(); upstream.destroy();});
|
|
45
|
+
socket.on('close', () => {this.#sockets.delete(socket); client.destroy(); upstream.destroy();});
|
|
46
|
+
}
|
|
47
|
+
client.pipe(upstream).pipe(client);
|
|
48
|
+
});
|
|
49
|
+
this.#proxy = proxy;
|
|
50
|
+
const listening = await new Promise(ok => {
|
|
51
|
+
proxy.once('error', () => ok(false));
|
|
52
|
+
proxy.listen(8768,address, () => ok(true));
|
|
53
|
+
});
|
|
54
|
+
if (!listening) {this.stop(); this.#state = {state:'blocked',code:'LanPortBusy'}; return;}
|
|
55
|
+
if (generation !== this.#generation) {proxy.close(); return;}
|
|
56
|
+
const portable = resolvePairingHost(this.root);
|
|
57
|
+
const project = resolve(this.root,'..','..','..');
|
|
58
|
+
const dotnet = join(project,'.tooling','dotnet','dotnet.exe');
|
|
59
|
+
const dll = join(project,'windows','Telecall.PairingHost','bin','Debug','net10.0','win-x64','Telecall.PairingHost.dll');
|
|
60
|
+
let executable, args;
|
|
61
|
+
if (portable) { executable = portable; args = ['--qr-pairing',address]; }
|
|
62
|
+
else if (existsSync(dotnet) && existsSync(dll)) { executable = dotnet; args = [dll,'--qr-pairing',address]; }
|
|
63
|
+
else { this.stop(); this.#state = {state:'blocked',code:'MissingPairingHost'}; return; }
|
|
64
|
+
let child;
|
|
65
|
+
try {child = spawn(executable,args,{cwd:this.root,windowsHide:true,stdio:['ignore','pipe','pipe']});}
|
|
66
|
+
catch {this.stop(); this.#state = {state:'blocked',code:'CouldNotStartSecureListener'}; return;}
|
|
67
|
+
this.#child = child;
|
|
68
|
+
let buffer = '';
|
|
69
|
+
child.stdout.setEncoding('utf8');
|
|
70
|
+
child.stdout.on('data', chunk => {
|
|
71
|
+
if (this.#child !== child) return;
|
|
72
|
+
buffer += chunk;
|
|
73
|
+
if (buffer.length > 16384) { this.stop(); this.#state = {state:'blocked',code:'InvalidHostResponse'}; return; }
|
|
74
|
+
let end;
|
|
75
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
76
|
+
const line = buffer.slice(0,end).trim(); buffer = buffer.slice(end+1);
|
|
77
|
+
if (!line.startsWith('TELECALL_PAIRING:')) continue;
|
|
78
|
+
try {
|
|
79
|
+
const event = JSON.parse(line.slice('TELECALL_PAIRING:'.length));
|
|
80
|
+
if (!['waiting','connected','disconnected','expired','blocked'].includes(event.state)) continue;
|
|
81
|
+
clearTimeout(this.#timer);
|
|
82
|
+
this.#state = {state:event.state, code:event.code, expiresAt:event.expiresAt ?? this.#state.expiresAt};
|
|
83
|
+
this.#payload = event.state === 'waiting' ? event.payload : null;
|
|
84
|
+
if (['blocked','expired','disconnected'].includes(event.state)) this.#closeProxy();
|
|
85
|
+
} catch { this.stop(); this.#state = {state:'blocked',code:'InvalidHostResponse'}; }
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
// Consume errors without ever writing a child's output or a pairing credential to logs.
|
|
89
|
+
child.stderr.resume();
|
|
90
|
+
child.on('error', () => {
|
|
91
|
+
if (this.#child === child) {clearTimeout(this.#timer); this.#closeProxy(); this.#payload = null; this.#state = {state:'blocked',code:'CouldNotStartSecureListener'};}
|
|
92
|
+
});
|
|
93
|
+
child.on('exit', () => {
|
|
94
|
+
if (this.#child !== child) return;
|
|
95
|
+
this.#child = null; clearTimeout(this.#timer); this.#payload = null;
|
|
96
|
+
this.#closeProxy();
|
|
97
|
+
if (!['blocked','expired','disconnected'].includes(this.#state.state)) this.#state = {state:'disconnected'};
|
|
98
|
+
});
|
|
99
|
+
this.#timer = setTimeout(() => {if (this.#child === child && this.#state.state === 'starting') {this.stop(); this.#state = {state:'blocked',code:'PairingHostTimeout'};}},15000);
|
|
100
|
+
this.#timer.unref();
|
|
101
|
+
}
|
|
102
|
+
stop() {
|
|
103
|
+
this.#generation++;
|
|
104
|
+
clearTimeout(this.#timer);
|
|
105
|
+
const child = this.#child; this.#child = null;
|
|
106
|
+
child?.kill();
|
|
107
|
+
this.#closeProxy();
|
|
108
|
+
this.#payload = null;
|
|
109
|
+
this.#state = {state:'idle'};
|
|
110
|
+
}
|
|
111
|
+
#closeProxy() {
|
|
112
|
+
this.#proxy?.close(); this.#proxy = null;
|
|
113
|
+
for (const socket of this.#sockets) socket.destroy();
|
|
114
|
+
this.#sockets.clear();
|
|
115
|
+
}
|
|
116
|
+
}
|
package/app/server.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
4
|
+
import { networkInterfaces } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { PairingController } from './pairing.mjs';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const QR = require('./vendor/qrcode/lib/core/qrcode.js');
|
|
12
|
+
const SVG = require('./vendor/qrcode/lib/renderer/svg-tag.js');
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const escape = value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
15
|
+
export function isLocalAddress(ip = '') {
|
|
16
|
+
if (ip.startsWith('::ffff:')) ip = ip.slice(7);
|
|
17
|
+
if (ip === '::1') return true;
|
|
18
|
+
const parts = ip.split('.').map(Number);
|
|
19
|
+
if (parts.length !== 4 || parts.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return false;
|
|
20
|
+
return parts[0] === 127 || parts[0] === 10 || parts[0] === 192 && parts[1] === 168 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31;
|
|
21
|
+
}
|
|
22
|
+
const loopback = ip => ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
|
23
|
+
export function addresses(interfaces = networkInterfaces()) {
|
|
24
|
+
return Object.entries(interfaces).flatMap(([name, items]) => items
|
|
25
|
+
.filter(i => i.family === 'IPv4' && !i.internal && isLocalAddress(i.address) && !/vmware|virtualbox|vethernet|docker|wsl|loopback/i.test(name))
|
|
26
|
+
.map(i => ({ name, address: i.address })))
|
|
27
|
+
.sort((a, b) => Number(b.address.startsWith('192.168.137.')) - Number(a.address.startsWith('192.168.137.')));
|
|
28
|
+
}
|
|
29
|
+
export async function startInstallServer({root = here, port = 8770, host = '0.0.0.0', links = addresses(), onStop} = {}) {
|
|
30
|
+
const metadata = JSON.parse(await readFile(join(root, 'app.json'), 'utf8'));
|
|
31
|
+
const apk = join(root, 'Telecall.apk');
|
|
32
|
+
const bytes = await readFile(apk);
|
|
33
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
34
|
+
if (sha256 !== metadata.sha256 || bytes.length !== metadata.size) throw new Error('The APK does not match the packaged version. Repackage Telecall before sharing.');
|
|
35
|
+
const [template, css, js] = await Promise.all(['page.html', 'style.css', 'page.js'].map(f => readFile(join(root, f))));
|
|
36
|
+
const pairTemplate = await readFile(join(root,'pair.html'),'utf8');
|
|
37
|
+
const pairCss = await readFile(join(root,'pair.css'));
|
|
38
|
+
const pairing = new PairingController(root);
|
|
39
|
+
const stopToken = randomBytes(24).toString('hex');
|
|
40
|
+
let activePort = port;
|
|
41
|
+
let lastDownload = null;
|
|
42
|
+
const allowedHosts = () => new Set(['localhost', '127.0.0.1', ...links.map(l => l.address)].map(a => `${a}:${activePort}`));
|
|
43
|
+
const server = http.createServer(async (req, res) => {
|
|
44
|
+
const send = (code, type, data) => {
|
|
45
|
+
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store',
|
|
46
|
+
'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer',
|
|
47
|
+
'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" });
|
|
48
|
+
res.end(req.method === 'HEAD' ? undefined : data);
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
if (!isLocalAddress(req.socket.remoteAddress) || !allowedHosts().has(req.headers.host)) return send(403, 'text/plain', 'Open the local Telecall install address shown on your computer.');
|
|
52
|
+
const url = new URL(req.url, 'http://localhost');
|
|
53
|
+
const local = loopback(req.socket.remoteAddress);
|
|
54
|
+
if (url.pathname === '/pair' || url.pathname.startsWith('/pair/')) {
|
|
55
|
+
if (!local || req.headers['sec-fetch-site'] === 'cross-site') return send(403,'text/plain','Pairing QR codes are shown only on this computer. Open Telecall on your phone and scan the computer screen.');
|
|
56
|
+
const selected = links.find(l => l.address === url.searchParams.get('host')) || links[0];
|
|
57
|
+
if (url.pathname === '/pair/start' || url.pathname === '/pair/stop') {
|
|
58
|
+
if (req.method !== 'POST' || req.headers['x-telecall-stop'] !== stopToken || req.headers.origin !== `http://${req.headers.host}`) return send(403,'text/plain','Use the buttons on this computer page.');
|
|
59
|
+
if (url.pathname === '/pair/stop') pairing.stop();
|
|
60
|
+
else {
|
|
61
|
+
if (!selected) return send(409,'application/json',JSON.stringify({error:'Connect the PC to a local network first.'}));
|
|
62
|
+
try {await pairing.start(selected.address);} catch {return send(409,'application/json',JSON.stringify({error:'Finish or disconnect the current session before creating another QR.'}));}
|
|
63
|
+
}
|
|
64
|
+
return send(200,'application/json',JSON.stringify(pairing.status()));
|
|
65
|
+
}
|
|
66
|
+
if (!['GET','HEAD'].includes(req.method)) return send(405,'text/plain','Method not allowed');
|
|
67
|
+
if (url.pathname === '/pair/status') return send(200,'application/json',JSON.stringify(pairing.status()));
|
|
68
|
+
if (url.pathname === '/pair/qr.svg') {
|
|
69
|
+
const payload = pairing.payload();
|
|
70
|
+
if (!payload) return send(410,'text/plain','Create a fresh pairing QR.');
|
|
71
|
+
return send(200,'image/svg+xml',SVG.render(QR.create(payload,{errorCorrectionLevel:'M'}),{margin:4,width:400}));
|
|
72
|
+
}
|
|
73
|
+
if (url.pathname !== '/pair') return send(404,'text/plain','Not found');
|
|
74
|
+
const options = links.map(l => `<option value="${escape(l.address)}"${l === selected ? ' selected' : ''}>${escape(l.name)} · ${escape(l.address)}</option>`).join('');
|
|
75
|
+
return send(200,'text/html; charset=utf-8',pairTemplate.replaceAll('{{STOP}}',stopToken).replaceAll('{{OPTIONS}}',options));
|
|
76
|
+
}
|
|
77
|
+
if (url.pathname === '/stop' && req.method === 'POST') {
|
|
78
|
+
if (!local || req.headers['x-telecall-stop'] !== stopToken || req.headers.origin !== `http://${req.headers.host}`) return send(403, 'text/plain', 'Stop sharing from the computer page.');
|
|
79
|
+
send(200, 'application/json', '{"stopped":true}');
|
|
80
|
+
setTimeout(() => { pairing.stop(); server.close(); server.closeAllConnections(); onStop?.(); }, 100);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!['GET', 'HEAD'].includes(req.method)) return send(405, 'text/plain', 'This page only offers the Telecall download.');
|
|
84
|
+
if (url.pathname === '/health') {
|
|
85
|
+
if (!local) return send(403, 'text/plain', 'Computer only');
|
|
86
|
+
return send(200, 'application/json', JSON.stringify({ app: 'telecall-installer', version: metadata.version, sha256, lastDownload }));
|
|
87
|
+
}
|
|
88
|
+
if (url.pathname === '/style.css') return send(200, 'text/css; charset=utf-8', css);
|
|
89
|
+
if (url.pathname === '/pair.css') return send(200, 'text/css; charset=utf-8', pairCss);
|
|
90
|
+
if (url.pathname === '/page.js') return send(200, 'text/javascript; charset=utf-8', js);
|
|
91
|
+
if (url.pathname === '/sha256.txt') return send(200, 'text/plain; charset=utf-8', `${sha256} Telecall.apk\n`);
|
|
92
|
+
if (url.pathname === '/Telecall.apk') {
|
|
93
|
+
// A single fixed APK is public to this LAN. No pairing credentials or call data are served.
|
|
94
|
+
const current = await stat(apk);
|
|
95
|
+
if (current.size !== bytes.length) return send(409, 'text/plain', 'The build changed. Restart sharing.');
|
|
96
|
+
res.writeHead(200, { 'content-type': 'application/vnd.android.package-archive',
|
|
97
|
+
'content-disposition': 'attachment; filename="Telecall.apk"', 'content-length': bytes.length,
|
|
98
|
+
'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
99
|
+
if (req.method === 'HEAD') return res.end();
|
|
100
|
+
res.on('finish', () => { lastDownload = new Date().toISOString(); });
|
|
101
|
+
return res.end(bytes);
|
|
102
|
+
}
|
|
103
|
+
const selected = links.find(l => l.address === url.searchParams.get('host')) || links[0];
|
|
104
|
+
const address = selected ? `http://${selected.address}:${activePort}/app` : '';
|
|
105
|
+
if (url.pathname === '/qr.svg') {
|
|
106
|
+
if (!selected) return send(404, 'text/plain', 'Connect this computer to Wi-Fi or turn on Mobile hotspot.');
|
|
107
|
+
return send(200, 'image/svg+xml', SVG.render(QR.create(address, {errorCorrectionLevel: 'M'}), {margin: 4, width: 280}));
|
|
108
|
+
}
|
|
109
|
+
if (url.pathname !== '/' && url.pathname !== '/app') return send(404, 'text/plain', 'Not found');
|
|
110
|
+
const desktop = local && url.pathname === '/';
|
|
111
|
+
const options = links.map(l => `<option value="${escape(l.address)}"${l === selected ? ' selected' : ''}>${escape(l.name)} · ${escape(l.address)}</option>`).join('');
|
|
112
|
+
const replacements = {
|
|
113
|
+
MODE: desktop ? 'desktop' : 'phone', VERSION: escape(metadata.version), SIZE: `${(bytes.length / 1048576).toFixed(1)} MB`,
|
|
114
|
+
ADDRESS: escape(address), OPTIONS: options, HOST: escape(selected?.address || ''),
|
|
115
|
+
STOP: desktop ? stopToken : '', STATUS: desktop ? 'SHARING ON THIS COMPUTER' : 'ANDROID PREVIEW',
|
|
116
|
+
NETWORK: links.length ? 'Keep your phone and computer on the same Wi-Fi or PC hotspot.' : 'Connect the PC to Wi-Fi or turn on Mobile hotspot, then restart sharing.'
|
|
117
|
+
};
|
|
118
|
+
return send(200, 'text/html; charset=utf-8', template.toString().replace(/\{\{([A-Z]+)\}\}/g, (_, key) => replacements[key] ?? ''));
|
|
119
|
+
} catch { if (!res.headersSent) send(500, 'text/plain', 'Could not load the install page. Restart sharing.'); else res.destroy(); }
|
|
120
|
+
});
|
|
121
|
+
server.requestTimeout = 30000;
|
|
122
|
+
server.on('close', () => pairing.stop());
|
|
123
|
+
server.headersTimeout = 15000;
|
|
124
|
+
await new Promise((ok, fail) => { server.once('error', fail); server.listen(port, host, ok); });
|
|
125
|
+
activePort = server.address().port;
|
|
126
|
+
return { server, port: activePort, links, sha256 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
130
|
+
const rootAt = process.argv.indexOf('--root');
|
|
131
|
+
const portAt = process.argv.indexOf('--port');
|
|
132
|
+
startInstallServer({root: rootAt < 0 ? here : resolve(process.argv[rootAt + 1]), port: portAt < 0 ? 8770 : Number(process.argv[portAt + 1]),
|
|
133
|
+
onStop: () => process.exit(0)}).then(({port, links}) => {
|
|
134
|
+
console.log(`Telecall install page: http://localhost:${port}`);
|
|
135
|
+
for (const item of links) console.log(`${item.name}: http://${item.address}:${port}/app`);
|
|
136
|
+
console.log('APK sharing and computer-only QR pairing. Use Stop sharing when finished.');
|
|
137
|
+
}).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
138
|
+
}
|
package/app/style.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--paper:#f3f6f3;--ink:#163d38;--muted:#5c706a;--mint:#d5f4de;--line:#dce5dd;--white:#fff;font-family:"Segoe UI Variable Text","Segoe UI",sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button,a,select{-webkit-tap-highlight-color:transparent}a{color:inherit}button,a,summary,select{touch-action:manipulation}button:focus-visible,a:focus-visible,summary:focus-visible,select:focus-visible,input:focus-visible{outline:3px solid #248463;outline-offset:4px}.shell{max-width:1240px;margin:auto;padding:36px 52px 24px}header{display:flex;align-items:center;justify-content:space-between;padding-bottom:48px}.brand{font-size:28px;letter-spacing:-1px;font-weight:700;display:flex;align-items:center;gap:12px;text-decoration:none}.mark{display:grid;place-items:center;width:44px;height:44px;border-radius:13px;background:var(--ink);color:var(--mint);font-size:30px}.badge,.step-label,.eyebrow,.live{font-size:11px;letter-spacing:1.3px;font-weight:650}.badge{padding:9px 14px;border:1px solid #bacbbf;border-radius:24px}main{display:grid;grid-template-columns:1fr 440px;gap:72px;align-items:start}.intro{padding-top:45px;position:sticky;top:35px}.eyebrow{display:flex;align-items:center;gap:8px;margin:0 0 30px}.dot{width:7px;height:7px;border-radius:50%;background:#288858;box-shadow:0 0 0 4px #e2eee3}h1{font-family:"Palatino Linotype",Palatino,Georgia,serif;font-size:clamp(44px,5.1vw,68px);font-weight:400;letter-spacing:-2.9px;line-height:1.04;margin:0 0 30px}h1 em{font-weight:400;color:#43806a}.lead{max-width:410px;font-size:18px;line-height:1.65;color:var(--muted)}.facts{display:flex;gap:8px;flex-wrap:wrap;margin:28px 0 18px}.facts span{font-size:12px;border:1px solid #d2dfd4;border-radius:8px;padding:8px 10px}.fine{font-size:12px;line-height:1.65;color:var(--muted);max-width:355px}.install-card{background:var(--white);border:1px solid var(--line);padding:30px;border-radius:28px;box-shadow:0 15px 65px #163d3808}.card-heading{display:flex;justify-content:space-between;gap:8px;align-items:center}.step-label{color:var(--muted)}.live{background:var(--mint);padding:5px 9px;border-radius:9px;letter-spacing:0}h2{font-size:26px;letter-spacing:-.8px;margin:20px 0 7px;font-weight:650}.muted{font-size:14px;color:var(--muted);line-height:1.6;margin:0 0 20px}.qr-frame{display:flex;justify-content:center;background:var(--paper);border-radius:18px;padding:12px;margin-bottom:20px}.qr-frame img{width:210px;height:210px;max-width:100%;border-radius:8px;background:white}label{display:block;font-size:12px;font-weight:600;margin:12px 0 8px}select{width:100%;background:#fff;color:var(--ink);border:1px solid var(--line);padding:11px;border-radius:10px;font-size:13px}.hint{color:var(--muted);font-size:11px;line-height:1.6}.address-row{display:flex;border:1px solid var(--line);border-radius:10px;overflow:hidden}.address-row input{min-width:0;width:100%;border:0;padding:12px 10px;font-size:11px;background:#f9fbf8;color:var(--muted)}button{cursor:pointer}.address-row button{border:0;border-left:1px solid var(--line);background:#f9fbf8;color:var(--ink);font-size:11px;padding:0 12px;white-space:nowrap;min-height:44px}.feedback{font-size:12px;color:var(--muted);margin:7px 0;min-height:16px}.divider{display:flex;align-items:center;gap:12px;color:var(--muted);font-size:11px;margin:16px 0}.divider:before,.divider:after{content:"";height:1px;flex:1;background:var(--line)}.download{display:flex;align-items:center;justify-content:space-between;background:var(--ink);color:var(--mint);font-size:16px;font-weight:600;padding:17px 22px;border-radius:40px;text-decoration:none;min-height:56px;transition:background .15s,transform .15s}.download:hover{background:#245b4c;transform:translateY(-1px)}.download span{font-size:24px;line-height:1}.version{text-align:center;font-size:11px;color:var(--muted);margin:11px 0 26px}.steps{list-style:none;margin:0;padding:0}.steps li{display:flex;gap:13px;margin:0 0 19px}.steps li>span{flex:none;display:grid;place-items:center;background:#eaf2eb;border-radius:50%;width:26px;height:26px;font-size:11px;font-weight:650}.steps strong{font-size:13px}.steps p{font-size:12px;color:var(--muted);line-height:1.7;margin:3px 0 0}.steps b{font-weight:550;color:var(--ink)}details{border-top:1px solid var(--line);padding-top:16px;margin-top:22px}summary{cursor:pointer;font-size:13px;font-weight:600;padding:4px 0}details p{font-size:12px;line-height:1.7;color:var(--muted)}.checksum{display:inline-block;font-size:11px;color:var(--muted);margin-top:18px;text-underline-offset:3px}footer{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;border-top:1px solid var(--line);padding-top:22px;margin-top:45px;font-size:10px;letter-spacing:1.5px;color:var(--muted)}footer button{background:none;border:1px solid #b7c9bc;border-radius:30px;color:var(--ink);padding:10px 17px;min-height:44px;font-size:12px;letter-spacing:0}footer p{width:100%;letter-spacing:0;font-size:12px;margin:0}.desktop .phone-only,.phone .desktop-only{display:none}.phone main{display:block}.phone .shell{max-width:530px;padding:26px 20px}.phone header{padding-bottom:28px}.phone .intro{padding-top:0;position:static}.phone h1{font-size:44px;letter-spacing:-1.8px;line-height:1.1;margin-bottom:20px}.phone .intro h1 br:first-child{display:none}.phone .eyebrow{margin-bottom:20px}.phone .lead{font-size:15px}.phone .facts{margin:20px 0 12px}.phone .fine{margin-bottom:26px}.phone .install-card{padding:25px}.phone h2{font-size:25px;margin-top:12px}.phone footer{margin-top:28px}.phone .qr-frame{display:none}button:disabled{opacity:.5;cursor:default}@media(max-width:850px){.shell{padding:24px}.intro{position:static;padding-top:10px}main{gap:35px;grid-template-columns:1fr 390px}h1{font-size:47px}.install-card{padding:24px}}@media(max-width:680px){main{display:block}.intro{margin-bottom:30px}.shell{max-width:530px}.desktop h1{font-size:48px}.desktop .lead{font-size:16px}header{padding-bottom:30px}.qr-frame img{width:230px;height:230px}.brand{font-size:26px}.badge{font-size:10px}.install-card{border-radius:22px}}@media(prefers-reduced-motion:reduce){*{transition:none!important}}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
```
|
|
2
|
+
Dijkstra path-finding functions. Adapted from the Dijkstar Python project.
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2008
|
|
5
|
+
Wyatt Baldwin <self@wyattbaldwin.com>
|
|
6
|
+
All rights reserved
|
|
7
|
+
|
|
8
|
+
Licensed under the MIT license.
|
|
9
|
+
|
|
10
|
+
http://www.opensource.org/licenses/mit-license.php
|
|
11
|
+
|
|
12
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
13
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
14
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
15
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
16
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
17
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
18
|
+
THE SOFTWARE.
|
|
19
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2012 Ryan Day
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
10
|
+
|