mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
- Implement full SoundTouch app flow for Spotify registration in the Web UI. - Update `/mgmt/spotify/init` to pass `accountID` via OAuth `state`. - Add "Connect Spotify" button to Local Account tab in Web UI with polling. - Implement legacy and Marge-sync fallbacks for speaker notifications (Error 1029). - Add support for parsing multi-error XML responses (`<errors>`) from speakers. - Add `NotifySourcesUpdated` to client for triggering manual source synchronization. - Improve test coverage for error parsing and Spotify initialization handlers. Co-authored-by: Junie <junie@jetbrains.com>
1495 lines
59 KiB
HTML
1495 lines
59 KiB
HTML
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<title>AfterTouch (SoundTouch Toolkit)</title>
|
|
<link rel="icon" href="/web/img/favicon-braille.svg" type="image/svg+xml"/>
|
|
<link rel="stylesheet" href="/web/css/style.css"/>
|
|
<script src="/web/js/diff.min.js"></script>
|
|
</head>
|
|
<body>
|
|
<h1><img src="/web/img/favicon-braille.svg" alt="AfterTouch Logo" class="logo"/>AfterTouch</h1>
|
|
<p style="margin-top: -10px; font-style: italic; color: #666">
|
|
Bose SoundTouch Toolkit
|
|
</p>
|
|
|
|
<div class="tabs">
|
|
<div class="tab-buttons">
|
|
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">
|
|
Overview
|
|
</button>
|
|
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">
|
|
1. Settings
|
|
</button>
|
|
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">
|
|
2. Devices
|
|
</button>
|
|
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">
|
|
3. Data Sync
|
|
</button>
|
|
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">
|
|
4. Migration
|
|
</button>
|
|
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">
|
|
5. Interactions & Events
|
|
</button>
|
|
<button class="tab-btn" onclick="openTab(event, 'tab-parity')">
|
|
6. Parity & Mirroring
|
|
</button>
|
|
<button class="tab-btn" onclick="openTab(event, 'tab-account')">
|
|
7. Local Account
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tab 0: Overview -->
|
|
<div id="tab-overview" class="tab-content active">
|
|
<h2>Welcome to AfterTouch</h2>
|
|
<p>
|
|
This toolkit helps you keep your Bose SoundTouch speakers
|
|
functional even after the Bose Cloud shutdown in May 2026.
|
|
It emulates the necessary cloud services locally on your
|
|
network.
|
|
</p>
|
|
|
|
<h3>Migration Process at a Glance</h3>
|
|
<div class="info-box prerequisite-box">
|
|
<strong>🔌 Prerequisite: Enable SSH</strong><br/>
|
|
Migration requires SSH access. To enable it:
|
|
<ol style="margin-top: 5px; margin-bottom: 5px">
|
|
<li>
|
|
Create an empty file named
|
|
<code>remote_services</code> on a USB stick.
|
|
</li>
|
|
<li>
|
|
Insert it into the speaker's
|
|
<strong>SERVICE</strong> port and reboot the
|
|
speaker.
|
|
</li>
|
|
</ol>
|
|
<strong>Verify connection:</strong>
|
|
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
|
|
<li>
|
|
Use the <strong>Migration</strong> tab to select
|
|
your device and verify that
|
|
<em>SSH Connection</em> shows ✅ Success.
|
|
</li>
|
|
<li>
|
|
Or manually:
|
|
<code
|
|
>ssh -oHostKeyAlgorithms=+ssh-rsa
|
|
root@<SPEAKER-IP></code
|
|
>
|
|
(no password).
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<ol class="guide-steps">
|
|
<li>
|
|
<strong>Settings:</strong> Review the
|
|
<strong>Settings</strong> tab. Ensure the "Target
|
|
Domain" and "Proxy Domain" use an IP address or domain
|
|
name that is
|
|
<strong>accessible from your speakers</strong> (usually
|
|
the IP of this server on your local network).
|
|
</li>
|
|
<li>
|
|
<strong>Discovery:</strong> Go to the
|
|
<strong>Devices</strong> tab to find your speakers on
|
|
the network. Ensure your speakers are powered on and
|
|
connected to the same network.
|
|
</li>
|
|
<li>
|
|
<strong>Data Sync:</strong> In the
|
|
<strong>Data Sync</strong> tab, fetch your current
|
|
presets, recents, and sources. This step is critical to
|
|
ensure your local service has all your personalized data
|
|
before you disconnect from the Bose cloud.
|
|
</li>
|
|
<li>
|
|
<strong>Migration:</strong> In the
|
|
<strong>Migration</strong> tab, redirect your speaker to
|
|
this local service. We recommend the
|
|
<strong>XML Configuration</strong> method as it is
|
|
surgical and easily reversible.
|
|
</li>
|
|
<li>
|
|
<strong>Verification:</strong> After migration and
|
|
reboot, your speaker will communicate with this toolkit
|
|
instead of Bose servers.
|
|
</li>
|
|
</ol>
|
|
|
|
<div class="info-box safety-box">
|
|
<strong>⚠️ Safety First:</strong> Before starting any
|
|
migration, please read our
|
|
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html" target="_blank">Professional
|
|
Migration & Safety Guide</a>. The toolkit automatically creates backups, but understanding the process
|
|
is key to a smooth transition.
|
|
</div>
|
|
|
|
<h3>Useful Links</h3>
|
|
<ul>
|
|
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud
|
|
Shutdown Survival Guide</a></li>
|
|
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI
|
|
Reference</a></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Tab 1: Settings -->
|
|
<div id="tab-settings" class="tab-content">
|
|
<h2>System Settings</h2>
|
|
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px">
|
|
<strong>Note:</strong> These URLs must be
|
|
<strong>accessible from your SoundTouch devices</strong>.
|
|
Use the IP address of this server on your local network
|
|
(e.g., <code>http://192.168.1.100:8000</code>) rather than
|
|
<code>localhost</code>.
|
|
</p>
|
|
<div style="margin-bottom: 20px">
|
|
<label for="target-domain">Target Domain:</label>
|
|
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px"/>
|
|
<span style="font-size: 0.8em; color: #666">(Standard services URL)</span>
|
|
</div>
|
|
<div style="margin-bottom: 20px">
|
|
<label for="discovery-interval">Discovery Interval:</label>
|
|
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px"/>
|
|
<label style="margin-left: 15px"><input type="checkbox" id="discovery-enabled"/> Enable Automated Discovery</label>
|
|
</div>
|
|
<div style="margin-bottom: 20px">
|
|
<strong>DNS Discovery:</strong>
|
|
<div style="margin-top: 5px">
|
|
<label style="display: block; margin-bottom: 5px">
|
|
<input type="checkbox" id="dns-enabled"/> Enable
|
|
DNS Discovery Server
|
|
</label>
|
|
<div style="margin-left: 20px; margin-bottom: 5px">
|
|
<label for="dns-upstream">Upstream DNS:</label>
|
|
<input
|
|
type="text"
|
|
id="dns-upstream"
|
|
placeholder="Default: system nameservers"
|
|
style="width: 200px"
|
|
/>
|
|
<span
|
|
class="info-toggle"
|
|
onclick="toggleInfo('dns-upstream-info')"
|
|
>ⓘ</span
|
|
>
|
|
<div id="dns-upstream-info" class="info-details">
|
|
Optional: comma-separated list of DNS servers
|
|
(e.g., <code>1.1.1.1, 8.8.8.8</code>).<br/>
|
|
If empty, AfterTouch defaults to the system
|
|
nameservers (e.g. from
|
|
<code>/etc/resolv.conf</code>).<br/>
|
|
<div
|
|
id="dns-current-upstream"
|
|
style="margin-top: 5px; font-weight: bold"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-left: 20px">
|
|
<label for="dns-bind">DNS Bind Address:</label>
|
|
<input
|
|
type="text"
|
|
id="dns-bind"
|
|
placeholder=":53"
|
|
style="width: 100px"
|
|
/>
|
|
<span
|
|
style="
|
|
font-size: 0.8em;
|
|
color: #666;
|
|
margin-left: 5px;
|
|
"
|
|
>(e.g., :53 or 0.0.0.0:53.
|
|
<strong>Port 53</strong> is required for actual
|
|
migration)</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom: 20px">
|
|
<strong>Endpoint Mirroring:</strong>
|
|
<div style="margin-top: 5px">
|
|
<label style="display: block; margin-bottom: 5px">
|
|
<input type="checkbox" id="mirror-enabled"/> Enable
|
|
Background Mirroring to Bose Cloud
|
|
</label>
|
|
<div style="margin-left: 20px; margin-bottom: 5px">
|
|
<label style="display: block; margin-bottom: 5px">
|
|
<input type="checkbox" id="preferred-source-upstream"/>
|
|
Prefer Upstream Response for Mirrored Endpoints
|
|
</label>
|
|
<label for="mirror-endpoints">Mirror Endpoints (one per line, supports * wildcards):</label><br/>
|
|
<textarea
|
|
id="mirror-endpoints"
|
|
rows="4"
|
|
style="width: 100%; max-width: 600px; margin-top: 5px; font-family: monospace;"
|
|
placeholder="/streaming/account/*/device/*/recent /accounts/*/devices/*/presets/*"
|
|
></textarea>
|
|
<label for="skip-mirror-endpoints" style="display: block; margin-top: 10px;">Skip Mirror Endpoints (local only, one per line):</label>
|
|
<textarea
|
|
id="skip-mirror-endpoints"
|
|
rows="2"
|
|
style="width: 100%; max-width: 600px; margin-top: 5px; font-family: monospace;"
|
|
placeholder="/oauth/device/*/music/musicprovider/*/token/cs3 /oauth/device/*/music/musicprovider/*/token"
|
|
></textarea>
|
|
<div
|
|
class="info-box"
|
|
style="
|
|
margin-top: 5px;
|
|
font-size: 0.85em;
|
|
padding: 10px;
|
|
"
|
|
>
|
|
<strong>Note:</strong> Mirroring sends matching
|
|
requests (including full headers) to the
|
|
official Bose servers for parity comparison. If
|
|
<em>Redact Sensitive Data</em> is enabled in
|
|
Proxy Settings, credentials will be masked in
|
|
<strong>logs and recordings</strong>, but full
|
|
headers are always sent to Bose to ensure
|
|
service compatibility.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom: 20px">
|
|
<strong>Spotify Integration:</strong>
|
|
<div
|
|
id="spotify-config-status"
|
|
style="margin-top: 5px; font-size: 0.9em"
|
|
>
|
|
Checking configuration...
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom: 20px">
|
|
<strong>Proxy Logging:</strong>
|
|
<div style="margin-top: 5px">
|
|
<label style="display: block; margin-bottom: 5px"><input type="checkbox" id="proxy-redact"
|
|
onchange="updateProxySettings()"/> Redact
|
|
Sensitive Headers</label>
|
|
<label style="display: block; margin-bottom: 5px"><input type="checkbox" id="proxy-log-body"
|
|
onchange="updateProxySettings()"/> Log
|
|
Bodies</label>
|
|
<label style="display: block; margin-bottom: 5px"><input type="checkbox" id="proxy-record"
|
|
onchange="updateProxySettings()"/> Record
|
|
Interactions<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span></label>
|
|
<div style="margin-left: 20px; margin-top: 10px">
|
|
<label for="internal-paths">Internal Paths (skip recording for these patterns):</label><br/>
|
|
<textarea
|
|
id="internal-paths"
|
|
rows="2"
|
|
style="width: 100%; max-width: 600px; margin-top: 5px; font-family: monospace;"
|
|
placeholder="/setup/* /web/*"
|
|
></textarea>
|
|
<div
|
|
style="
|
|
font-size: 0.8em;
|
|
color: #666;
|
|
margin-top: 2px;
|
|
"
|
|
>
|
|
Requests matching these patterns will be
|
|
excluded from recording. Use one pattern per
|
|
line.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 20px">
|
|
<button onclick="updateSettings()">Save Settings</button>
|
|
<span
|
|
id="settings-status"
|
|
style="margin-left: 10px; font-size: 0.9em"
|
|
></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 2: Devices -->
|
|
<div id="tab-devices" class="tab-content">
|
|
<div
|
|
style="
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
"
|
|
>
|
|
<h2>
|
|
Known Devices
|
|
<span
|
|
id="discovery-indicator"
|
|
style="
|
|
font-size: 0.5em;
|
|
vertical-align: middle;
|
|
display: none;
|
|
"
|
|
>🔍 Scanning...</span
|
|
>
|
|
</h2>
|
|
<div
|
|
id="spotify-status-header"
|
|
style="
|
|
background: #f0f0f0;
|
|
padding: 5px 15px;
|
|
border-radius: 20px;
|
|
font-size: 0.9em;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
"
|
|
>
|
|
Spotify:
|
|
<span
|
|
id="spotify-account-name"
|
|
style="font-weight: bold"
|
|
>Not Linked</span
|
|
>
|
|
<button
|
|
id="link-spotify-btn"
|
|
onclick="linkSpotify()"
|
|
style="
|
|
font-size: 0.8em;
|
|
padding: 2px 8px;
|
|
background: #1db954;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
"
|
|
>
|
|
Link Account
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="device-list">Loading devices...</div>
|
|
<div style="margin-top: 20px">
|
|
<button onclick="triggerDiscovery()">Scan Again</button>
|
|
<input
|
|
type="text"
|
|
id="add-manual-ip"
|
|
placeholder="Manual IP (e.g. 192.168.1.100)"
|
|
style="margin-left: 20px; padding: 4px"
|
|
/>
|
|
<button onclick="addManualDevice()">Add Device</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 3: Data Sync -->
|
|
<div id="tab-sync" class="tab-content">
|
|
<h2>Initial Data Sync</h2>
|
|
<p>
|
|
Before migrating, fetch your presets, recents, and
|
|
configured sources from the device to ensure they are
|
|
available locally.
|
|
</p>
|
|
<div class="device-selection">
|
|
<label for="sync-device-list">Device:</label>
|
|
<select id="sync-device-list">
|
|
<option value="">-- Select a device --</option>
|
|
</select>
|
|
<button id="sync-now-btn">Start Sync</button>
|
|
</div>
|
|
<div id="sync-status" class="status"></div>
|
|
<div id="sync-results" style="margin-top: 20px; display: none">
|
|
<h3>Sync Results</h3>
|
|
<div
|
|
id="sync-log"
|
|
style="
|
|
font-family: monospace;
|
|
background: #f4f4f4;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 4: Migration -->
|
|
<div id="tab-migration" class="tab-content">
|
|
<h2>Device Migration</h2>
|
|
<div class="device-selection">
|
|
<label for="migration-device-list">Device:</label>
|
|
<select
|
|
id="migration-device-list"
|
|
onchange="showSummary(this.value)"
|
|
>
|
|
<option value="">-- Select a device --</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div id="status" class="status"></div>
|
|
|
|
<div
|
|
id="command-output-box"
|
|
class="summary-box"
|
|
style="display: none; background-color: #f0f0f0"
|
|
>
|
|
<h3>Command Output</h3>
|
|
<div
|
|
id="command-output"
|
|
style="
|
|
font-family: monospace;
|
|
white-space: pre-wrap;
|
|
font-size: 0.85em;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
padding: 10px;
|
|
border: 1px solid #ccc;
|
|
background: #fff;
|
|
"
|
|
></div>
|
|
</div>
|
|
|
|
<div
|
|
id="migration-summary"
|
|
class="summary-box"
|
|
style="display: none"
|
|
>
|
|
<h3>
|
|
Migration Summary for
|
|
<span id="summary-device-display"></span>
|
|
</h3>
|
|
<p>Migration Status: <span id="migration-status"></span></p>
|
|
<input type="hidden" id="summary-device-id"/>
|
|
<p>SSH Connection: <span id="ssh-status"></span></p>
|
|
<p id="original-config-status" style="display: none">
|
|
Backup: ✅ Found .original config at
|
|
<code
|
|
>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code
|
|
>
|
|
<button onclick="toggleOriginalConfig()">
|
|
Show Original Config
|
|
</button>
|
|
</p>
|
|
<p id="no-original-config-status" style="display: none">
|
|
Backup: ❌ Not found
|
|
<button id="backup-config-btn">
|
|
Backup Config Now
|
|
</button>
|
|
</p>
|
|
<p>
|
|
Remote Services Enabled:
|
|
<span id="remote-services-status"></span>
|
|
<span
|
|
id="remote-services-found"
|
|
style="font-size: 0.8em; color: #666"
|
|
></span>
|
|
</p>
|
|
<p>
|
|
AfterTouch Local Root CA Trusted:
|
|
<span id="ca-trust-status"></span>
|
|
<button
|
|
id="trust-ca-btn"
|
|
style="
|
|
display: none;
|
|
background-color: #607d8b;
|
|
color: white;
|
|
border: none;
|
|
padding: 2px 8px;
|
|
font-size: 0.8em;
|
|
margin-left: 10px;
|
|
"
|
|
>
|
|
Trust CA Now
|
|
</button>
|
|
</p>
|
|
|
|
<div
|
|
id="connection-test"
|
|
style="
|
|
margin: 15px 0;
|
|
padding: 10px;
|
|
border: 1px solid #ddd;
|
|
background-color: #eefbff;
|
|
"
|
|
>
|
|
<strong>HTTPS Connection Test:</strong><br/>
|
|
<span style="font-size: 0.85em; color: #555"
|
|
>Verify the device can reach the server over
|
|
HTTPS.</span
|
|
>
|
|
<div style="margin-top: 10px">
|
|
URL: <code id="test-url"></code>
|
|
</div>
|
|
<div style="margin-top: 10px">
|
|
<button
|
|
id="test-connection-explicit-btn"
|
|
style="
|
|
background-color: #607d8b;
|
|
color: white;
|
|
border: none;
|
|
padding: 5px 10px;
|
|
font-size: 0.9em;
|
|
"
|
|
>
|
|
Test with Explicit CA.crt
|
|
</button>
|
|
<button
|
|
id="test-connection-trusted-btn"
|
|
style="
|
|
background-color: #607d8b;
|
|
color: white;
|
|
border: none;
|
|
padding: 5px 10px;
|
|
font-size: 0.9em;
|
|
"
|
|
>
|
|
Test with Shared Trust Store
|
|
</button>
|
|
</div>
|
|
<div
|
|
id="test-result"
|
|
style="
|
|
margin-top: 10px;
|
|
display: none;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
white-space: pre-wrap;
|
|
font-size: 0.85em;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
"
|
|
></div>
|
|
</div>
|
|
|
|
<div
|
|
id="hosts-redirection-test"
|
|
style="
|
|
margin: 15px 0;
|
|
padding: 10px;
|
|
border: 1px solid #ddd;
|
|
background-color: #fff4e6;
|
|
display: none;
|
|
"
|
|
>
|
|
<strong>Preliminary /etc/hosts Test:</strong><br/>
|
|
<span style="font-size: 0.85em; color: #555"
|
|
>Verify the device's /etc/hosts mechanism before
|
|
full migration.</span
|
|
>
|
|
<div style="margin-top: 10px">
|
|
Domain: <code>custom-test-api.bose.fake</code>
|
|
</div>
|
|
<div style="margin-top: 10px">
|
|
<button
|
|
id="test-hosts-btn"
|
|
style="
|
|
background-color: #ff9800;
|
|
color: white;
|
|
border: none;
|
|
padding: 5px 10px;
|
|
font-size: 0.9em;
|
|
"
|
|
>
|
|
Test Hosts Redirection
|
|
</button>
|
|
</div>
|
|
<div
|
|
id="hosts-test-result"
|
|
style="
|
|
margin-top: 10px;
|
|
display: none;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
white-space: pre-wrap;
|
|
font-size: 0.85em;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
"
|
|
></div>
|
|
</div>
|
|
|
|
<div
|
|
id="dns-redirection-test"
|
|
style="
|
|
margin: 15px 0;
|
|
padding: 10px;
|
|
border: 1px solid #ddd;
|
|
background-color: #e6ffed;
|
|
display: none;
|
|
"
|
|
>
|
|
<strong>Preliminary DNS Test:</strong><br/>
|
|
<span style="font-size: 0.85em; color: #555"
|
|
>Verify the device can resolve domains via the
|
|
AfterTouch DNS server.</span
|
|
>
|
|
<div style="margin-top: 10px">
|
|
Domain: <code>aftertouch.test</code>
|
|
</div>
|
|
<div style="margin-top: 10px">
|
|
<button
|
|
id="test-dns-btn"
|
|
style="
|
|
background-color: #28a745;
|
|
color: white;
|
|
border: none;
|
|
padding: 5px 10px;
|
|
font-size: 0.9em;
|
|
"
|
|
>
|
|
Test DNS Redirection
|
|
</button>
|
|
</div>
|
|
<div
|
|
id="dns-test-result"
|
|
style="
|
|
margin-top: 10px;
|
|
display: none;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
white-space: pre-wrap;
|
|
font-size: 0.85em;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
"
|
|
></div>
|
|
</div>
|
|
|
|
<div
|
|
style="
|
|
margin: 15px 0;
|
|
padding: 10px;
|
|
border: 1px solid #ddd;
|
|
background-color: #f9f9f9;
|
|
"
|
|
>
|
|
<label for="migration-method"
|
|
><strong>Migration Method:</strong></label
|
|
>
|
|
<select
|
|
id="migration-method"
|
|
onchange="toggleMigrationMethod()"
|
|
>
|
|
<option value="xml">
|
|
XML Configuration (Recommended - redirects
|
|
specific services)
|
|
</option>
|
|
<option value="hosts">
|
|
/etc/hosts + Root CA (Advanced - global
|
|
redirection)
|
|
</option>
|
|
<option value="resolv">
|
|
/etc/resolv.conf (DHCP-Aware - Redirect via DNS
|
|
Hook)
|
|
</option>
|
|
</select>
|
|
<div
|
|
id="dns-port-warning"
|
|
style="
|
|
margin-top: 5px;
|
|
color: #d32f2f;
|
|
font-weight: bold;
|
|
font-size: 0.9em;
|
|
display: none;
|
|
"
|
|
></div>
|
|
</div>
|
|
|
|
<div
|
|
id="current-resolv-pane"
|
|
style="display: none; margin-bottom: 20px"
|
|
>
|
|
<span class="config-header"
|
|
>Current /etc/resolv.conf</span
|
|
>
|
|
<pre id="current-resolv-content"></pre>
|
|
</div>
|
|
|
|
<div
|
|
id="original-config-pane"
|
|
style="display: none; margin-bottom: 20px"
|
|
>
|
|
<span class="config-header"
|
|
>Original Config (Backup)</span
|
|
>
|
|
<pre id="original-config-content"></pre>
|
|
</div>
|
|
|
|
<div
|
|
id="service-options"
|
|
style="margin-bottom: 20px; display: none"
|
|
>
|
|
<h4>Service Implementations</h4>
|
|
<table>
|
|
<tr>
|
|
<th>Service</th>
|
|
<th>Original URL</th>
|
|
<th>Implementation</th>
|
|
</tr>
|
|
<tr>
|
|
<td>Marge (Streaming)</td>
|
|
<td id="orig-marge">loading...</td>
|
|
<td>
|
|
<select
|
|
id="opt-marge"
|
|
onchange="refreshSummary()"
|
|
>
|
|
<option value="self">
|
|
AfterTouch (Local Service)
|
|
</option>
|
|
<option value="upstream">
|
|
Upstream (Proxy via local service)
|
|
</option>
|
|
</select>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Stats</td>
|
|
<td id="orig-stats">loading...</td>
|
|
<td>
|
|
<select
|
|
id="opt-stats"
|
|
onchange="refreshSummary()"
|
|
>
|
|
<option value="self">
|
|
AfterTouch (Local Service)
|
|
</option>
|
|
<option value="upstream">
|
|
Upstream (Proxy via local service)
|
|
</option>
|
|
</select>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Software Update</td>
|
|
<td id="orig-sw_update">loading...</td>
|
|
<td>
|
|
<select
|
|
id="opt-sw_update"
|
|
onchange="refreshSummary()"
|
|
>
|
|
<option value="self">
|
|
AfterTouch (Local Service)
|
|
</option>
|
|
<option value="upstream">
|
|
Upstream (Proxy via local service)
|
|
</option>
|
|
</select>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>BMX (Registry)</td>
|
|
<td id="orig-bmx">loading...</td>
|
|
<td>
|
|
<select
|
|
id="opt-bmx"
|
|
onchange="refreshSummary()"
|
|
>
|
|
<option value="self">
|
|
AfterTouch (Local Service)
|
|
</option>
|
|
<option value="upstream">
|
|
Upstream (Proxy via local service)
|
|
</option>
|
|
</select>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="diff-container">
|
|
<div id="xml-diff-pane" class="diff-pane">
|
|
<span class="config-header"
|
|
>Current Config (on Speaker)</span
|
|
>
|
|
<pre id="current-config"></pre>
|
|
</div>
|
|
<div id="planned-xml-pane" class="diff-pane">
|
|
<span class="config-header"
|
|
>Planned Config (AfterTouch)</span
|
|
>
|
|
<pre id="planned-config"></pre>
|
|
</div>
|
|
<div
|
|
id="planned-hosts-pane"
|
|
class="diff-pane"
|
|
style="display: none"
|
|
>
|
|
<span class="config-header"
|
|
>Planned /etc/hosts Entries</span
|
|
>
|
|
<pre id="planned-hosts"></pre>
|
|
<div
|
|
style="
|
|
margin-top: 10px;
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
"
|
|
>
|
|
<strong>Note:</strong> This method also injects
|
|
the AfterTouch Local Root CA into
|
|
<code>/etc/pki/tls/certs/ca-bundle.crt</code> to
|
|
enable secure HTTPS communication.
|
|
</div>
|
|
</div>
|
|
<div
|
|
id="planned-resolv-pane"
|
|
class="diff-pane"
|
|
style="display: none"
|
|
>
|
|
<span class="config-header"
|
|
>Planned /etc/resolv.conf Hook</span
|
|
>
|
|
<pre id="planned-resolv"></pre>
|
|
<div
|
|
id="resolv-note"
|
|
style="
|
|
margin-top: 10px;
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
"
|
|
>
|
|
<strong>Note:</strong> This method injects a
|
|
persistent DNS priority hook into the DHCP logic
|
|
(<code>/etc/udhcpc.d/50default</code>). It
|
|
preserves your router's search domain and
|
|
secondary DNS servers. It also injects the Local
|
|
Root CA.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top: 15px">
|
|
<button
|
|
id="confirm-migrate-btn"
|
|
style="
|
|
background-color: #4caf50;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
"
|
|
>
|
|
Confirm Migration
|
|
</button>
|
|
<button
|
|
id="revert-migrate-btn"
|
|
style="
|
|
background-color: #ff9800;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
display: none;
|
|
"
|
|
>
|
|
Revert to Defaults
|
|
</button>
|
|
<button
|
|
id="reboot-speaker-btn"
|
|
style="
|
|
background-color: #607d8b;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
"
|
|
>
|
|
Reboot Speaker
|
|
</button>
|
|
<button
|
|
id="ensure-remote-btn"
|
|
style="
|
|
background-color: #2196f3;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
"
|
|
>
|
|
Enable Persistent Remote Services
|
|
</button>
|
|
<button
|
|
id="remove-remote-btn"
|
|
style="
|
|
background-color: #f44336;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
"
|
|
>
|
|
Remove Persistent Remote Services
|
|
</button>
|
|
<button
|
|
onclick="
|
|
document.getElementById(
|
|
'migration-summary',
|
|
).style.display = 'none'
|
|
"
|
|
style="padding: 10px 20px"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 5: Interactions & Events -->
|
|
<div id="tab-interactions" class="tab-content">
|
|
<h2>Recorded Interactions & Device Events</h2>
|
|
<p>
|
|
Analysis of traffic handled by this service (self), proxied
|
|
to Bose (upstream), and internal device events (telemetry).
|
|
</p>
|
|
|
|
<div id="interaction-stats-container" class="summary-box">
|
|
<div
|
|
style="
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
"
|
|
>
|
|
<p style="margin: 0">
|
|
Total Requests:
|
|
<strong id="total-requests">0</strong>
|
|
</p>
|
|
<button onclick="fetchInteractionStats()">
|
|
Refresh Stats
|
|
</button>
|
|
<div style="margin-left: 10px">
|
|
<button onclick="showDeviceEvents()">
|
|
View App/Device Events
|
|
</button>
|
|
</div>
|
|
<div style="margin-left: auto; text-align: right">
|
|
<button
|
|
onclick="cleanupSessions()"
|
|
class="btn-danger"
|
|
>
|
|
Cleanup old sessions
|
|
</button>
|
|
<div
|
|
style="
|
|
font-size: 0.75em;
|
|
color: #666;
|
|
margin-top: 3px;
|
|
"
|
|
>
|
|
Keeps only the 10 most recent sessions
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 20px">
|
|
<div
|
|
style="
|
|
flex: 1;
|
|
border-right: 1px solid #eee;
|
|
padding-right: 20px;
|
|
"
|
|
>
|
|
<h3>By Service</h3>
|
|
<ul id="stats-by-service" class="stats-list"></ul>
|
|
</div>
|
|
<div style="flex: 2">
|
|
<h3>Sessions</h3>
|
|
<div
|
|
id="stats-by-session-container"
|
|
style="
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
border: 1px solid #eee;
|
|
padding: 5px;
|
|
border-radius: 4px;
|
|
"
|
|
>
|
|
<ul
|
|
id="stats-by-session"
|
|
class="stats-list"
|
|
></ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
id="browse-recordings"
|
|
class="summary-box"
|
|
style="margin-top: 20px"
|
|
>
|
|
<div
|
|
style="
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
"
|
|
>
|
|
<h3 style="margin: 0">Browse Recordings</h3>
|
|
</div>
|
|
<div
|
|
style="
|
|
margin-bottom: 15px;
|
|
display: flex;
|
|
gap: 15px;
|
|
align-items: center;
|
|
background: #f9f9f9;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
"
|
|
>
|
|
<div>
|
|
<label for="filter-session">Session:</label>
|
|
<select
|
|
id="filter-session"
|
|
onchange="fetchInteractions()"
|
|
>
|
|
<option value="">All Sessions</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="filter-category">Category:</label>
|
|
<select
|
|
id="filter-category"
|
|
onchange="fetchInteractions()"
|
|
>
|
|
<option value="">All Categories</option>
|
|
<option value="self">Self (Emulated)</option>
|
|
<option value="upstream">
|
|
Upstream (Bose)
|
|
</option>
|
|
<option value="mirror">Mirror (Bose)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="filter-since"
|
|
>Since (YYYY-MM-DD HH:mm:ss):</label
|
|
>
|
|
<input
|
|
type="text"
|
|
id="filter-since"
|
|
placeholder="e.g. 2026-02-15 15:00:00"
|
|
size="25"
|
|
onchange="fetchInteractions()"
|
|
/>
|
|
</div>
|
|
<button onclick="fetchInteractions()">
|
|
Apply Filters
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
id="interactions-list-container"
|
|
style="max-height: 400px; overflow-y: auto"
|
|
>
|
|
<table style="width: 100%; border-collapse: collapse">
|
|
<thead>
|
|
<tr
|
|
style="
|
|
text-align: left;
|
|
border-bottom: 2px solid #eee;
|
|
"
|
|
>
|
|
<th style="padding: 8px">#</th>
|
|
<th style="padding: 8px">Time</th>
|
|
<th style="padding: 8px">Method</th>
|
|
<th style="padding: 8px">Path</th>
|
|
<th style="padding: 8px">Status</th>
|
|
<th style="padding: 8px">Category</th>
|
|
<th style="padding: 8px">Event Details</th>
|
|
<th style="padding: 8px">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="interactions-list">
|
|
<tr>
|
|
<td
|
|
colspan="8"
|
|
style="
|
|
padding: 20px;
|
|
text-align: center;
|
|
color: #666;
|
|
"
|
|
>
|
|
No interactions found.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
id="dns-discoveries"
|
|
class="summary-box"
|
|
style="margin-top: 20px"
|
|
>
|
|
<div
|
|
style="
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
"
|
|
>
|
|
<h3 style="margin: 0">DNS Discoveries</h3>
|
|
<div style="display: flex; gap: 10px">
|
|
<button
|
|
onclick="downloadDNSDiscoveries()"
|
|
class="btn-info"
|
|
>
|
|
Download JSON
|
|
</button>
|
|
<button
|
|
onclick="clearDNSDiscoveries()"
|
|
class="btn-danger"
|
|
>
|
|
Clear DNS Logs
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p style="font-size: 0.85em; color: #666">
|
|
Hosts discovered via the AfterTouch DNS server. "Self"
|
|
means the domain was intercepted and redirected to this
|
|
service.
|
|
</p>
|
|
<div
|
|
id="dns-discoveries-list-container"
|
|
style="max-height: 400px; overflow-y: auto"
|
|
>
|
|
<table style="width: 100%; border-collapse: collapse">
|
|
<thead>
|
|
<tr
|
|
style="
|
|
text-align: left;
|
|
border-bottom: 2px solid #eee;
|
|
"
|
|
>
|
|
<th style="padding: 8px">Hostname</th>
|
|
<th style="padding: 8px">Last Seen</th>
|
|
<th
|
|
style="padding: 8px; text-align: center"
|
|
>
|
|
Queries
|
|
</th>
|
|
<th
|
|
style="padding: 8px; text-align: center"
|
|
>
|
|
Bose?
|
|
</th>
|
|
<th style="padding: 8px">Category</th>
|
|
<th style="padding: 8px">Last Client IP</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="dns-discoveries-list">
|
|
<tr>
|
|
<td
|
|
colspan="6"
|
|
style="
|
|
padding: 20px;
|
|
text-align: center;
|
|
color: #666;
|
|
"
|
|
>
|
|
No DNS discoveries found.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
id="interaction-viewer"
|
|
class="summary-box"
|
|
style="
|
|
margin-top: 20px;
|
|
display: none;
|
|
background: #2b2b2b;
|
|
color: #a9b7c6;
|
|
"
|
|
>
|
|
<div
|
|
style="
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
"
|
|
>
|
|
<h3 style="margin: 0; color: #fff">
|
|
Recording Viewer:
|
|
<span id="viewer-filename" style="font-weight: normal; font-size: 0.8em"></span>
|
|
</h3>
|
|
<button onclick="document.getElementById('interaction-viewer').style.display = 'none'"
|
|
style="background: #444; color: #fff; border: 1px solid #666;">
|
|
Close
|
|
</button>
|
|
</div>
|
|
<pre
|
|
id="interaction-content"
|
|
style="
|
|
white-space: pre-wrap;
|
|
font-family: 'Courier New', Courier, monospace;
|
|
font-size: 0.9em;
|
|
margin: 0;
|
|
padding: 10px;
|
|
overflow-x: auto;
|
|
max-height: 600px;
|
|
"
|
|
></pre>
|
|
</div>
|
|
|
|
<!-- Device Events Overlay -->
|
|
<div
|
|
id="device-events-overlay"
|
|
class="summary-box"
|
|
style="
|
|
margin-top: 20px;
|
|
display: none;
|
|
background: #fdfdfd;
|
|
border: 1px solid #ddd;
|
|
"
|
|
>
|
|
<div
|
|
style="
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
"
|
|
>
|
|
<h3 style="margin: 0">App & Device Events</h3>
|
|
<div>
|
|
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
|
|
<option value="">-- Select Device --</option>
|
|
</select>
|
|
<button onclick="document.getElementById('device-events-overlay').style.display = 'none'"
|
|
style="margin-left: 10px">Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
id="events-list-container"
|
|
style="max-height: 400px; overflow-y: auto"
|
|
>
|
|
<table style="width: 100%; border-collapse: collapse">
|
|
<thead>
|
|
<tr
|
|
style="
|
|
text-align: left;
|
|
border-bottom: 2px solid #eee;
|
|
"
|
|
>
|
|
<th style="padding: 8px">Time</th>
|
|
<th style="padding: 8px">Type</th>
|
|
<th style="padding: 8px">Data</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="events-list">
|
|
<tr>
|
|
<td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view
|
|
events.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 6: Parity & Mirroring -->
|
|
<div id="tab-parity" class="tab-content">
|
|
<h2>Parity Analysis</h2>
|
|
<p>
|
|
Detection of discrepancies between AfterTouch local
|
|
responses and official Bose Cloud responses for mirrored
|
|
endpoints.
|
|
</p>
|
|
|
|
<div class="summary-box">
|
|
<div
|
|
style="
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
"
|
|
>
|
|
<h3 style="margin: 0">Parity Mismatches</h3>
|
|
<div style="display: flex; gap: 10px">
|
|
<button onclick="fetchParityMismatches()">
|
|
Refresh Mismatches
|
|
</button>
|
|
<button
|
|
onclick="clearParityMismatches()"
|
|
class="btn-danger"
|
|
>
|
|
Clear All Records
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
id="parity-list-container"
|
|
style="max-height: 500px; overflow-y: auto"
|
|
>
|
|
<table style="width: 100%; border-collapse: collapse">
|
|
<thead>
|
|
<tr
|
|
style="
|
|
text-align: left;
|
|
border-bottom: 2px solid #eee;
|
|
"
|
|
>
|
|
<th style="padding: 8px">Time</th>
|
|
<th style="padding: 8px">Method</th>
|
|
<th style="padding: 8px">Path</th>
|
|
<th style="padding: 8px">Reasons</th>
|
|
<th style="padding: 8px">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="parity-mismatches-list">
|
|
<tr>
|
|
<td
|
|
colspan="5"
|
|
style="
|
|
padding: 20px;
|
|
text-align: center;
|
|
color: #666;
|
|
"
|
|
>
|
|
Loading mismatches...
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
id="parity-diff-view"
|
|
class="summary-box"
|
|
style="display: none; margin-top: 20px"
|
|
>
|
|
<div
|
|
style="
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
"
|
|
>
|
|
<h3 style="margin: 0">
|
|
Mismatch Detail:
|
|
<span id="diff-path-display"></span>
|
|
</h3>
|
|
<button onclick="document.getElementById('parity-diff-view').style.display = 'none'">Close Detail
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
style="
|
|
margin-bottom: 15px;
|
|
padding: 10px;
|
|
background: #fff4f4;
|
|
border: 1px solid #f5c6cb;
|
|
border-radius: 4px;
|
|
color: #721c24;
|
|
"
|
|
>
|
|
<strong>Detection Reasons:</strong>
|
|
<ul
|
|
id="diff-reasons-list"
|
|
style="margin: 5px 0 0 0; padding-left: 20px"
|
|
></ul>
|
|
</div>
|
|
|
|
<div id="diff-size-warning" style="display: none; margin-bottom: 15px; padding: 10px; background: #fff3cd; border: 1px solid #ffeeba; border-radius: 4px; color: #856404;">
|
|
⚠️ <strong>Large Payload:</strong> Rich diff highlighting is disabled to prevent a browser freeze. Showing raw comparison instead.
|
|
<button id="force-rich-diff-btn" style="margin-left: 10px; font-size: 0.8em; cursor: pointer;">Show Rich Diff Anyway</button>
|
|
</div>
|
|
|
|
<div class="diff-container" style="margin-top: 15px">
|
|
<div class="diff-pane">
|
|
<span
|
|
class="config-header"
|
|
style="background: #eefbff; color: #0056b3"
|
|
>Local Response (AfterTouch)</span
|
|
>
|
|
<div
|
|
id="diff-local-meta"
|
|
style="
|
|
font-size: 0.8em;
|
|
margin-bottom: 5px;
|
|
color: #666;
|
|
"
|
|
></div>
|
|
<pre
|
|
id="diff-local-body"
|
|
></pre>
|
|
</div>
|
|
<div class="diff-pane">
|
|
<span
|
|
class="config-header"
|
|
style="background: #fff4e6; color: #856404"
|
|
>Upstream Response (Bose)</span
|
|
>
|
|
<div
|
|
id="diff-upstream-meta"
|
|
style="
|
|
font-size: 0.8em;
|
|
margin-bottom: 5px;
|
|
color: #666;
|
|
"
|
|
></div>
|
|
<pre
|
|
id="diff-upstream-body"
|
|
></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 7: Local Account -->
|
|
<div id="tab-account" class="tab-content">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<h2>Local Account Details</h2>
|
|
<div style="display: flex; gap: 10px; align-items: center;">
|
|
<label for="account-selector">Account:</label>
|
|
<select id="account-selector" onchange="fetchAccountDetails(this.value)">
|
|
<option value="default">Default</option>
|
|
</select>
|
|
<button onclick="fetchAccountDetails(document.getElementById('account-selector').value)">Refresh</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="account-info-container" class="summary-box">
|
|
<h3>Account Overview</h3>
|
|
<div id="account-metadata">Loading...</div>
|
|
</div>
|
|
|
|
<div id="spotify-registration-container" class="summary-box" style="margin-top: 20px;">
|
|
<h3>Spotify Integration</h3>
|
|
<p style="font-size: 0.9em; color: #555;">
|
|
Register a new Spotify source for this local account. This mimics the official SoundTouch app flow:
|
|
</p>
|
|
<ol style="font-size: 0.85em; color: #555; margin-bottom: 15px;">
|
|
<li>Exchange OAuth code for a Bose-mediated token.</li>
|
|
<li>Register the source in the local Marge cloud profile.</li>
|
|
</ol>
|
|
<button id="connect-spotify-account-btn" onclick="connectSpotifyToAccount()" style="background: #1db954; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer;">
|
|
Connect Spotify to this Account
|
|
</button>
|
|
<div id="spotify-reg-status" style="margin-top: 10px; font-size: 0.9em;"></div>
|
|
</div>
|
|
|
|
<div id="account-devices-container">
|
|
<h3>Connected Devices</h3>
|
|
<div id="account-devices-list">Select an account to view devices.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/web/js/script.js"></script>
|
|
<footer
|
|
style="margin-top: 50px; padding: 20px; border-top: 1px solid #eee; font-size: 0.8em; color: #888; text-align: center;">
|
|
<span id="version-info">AfterTouch</span>
|
|
</footer>
|
|
</body>
|
|
</html>
|