Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7437d1f
feat: auto-pause workout on sensor dropout (#148)
danwo Apr 19, 2026
4a29349
feat: proactive BLE sensor battery-level warnings (#156)
danwo Apr 19, 2026
02a7a2b
feat: per-interval transition summary overlay (#154)
danwo Apr 19, 2026
75c6d8b
feat(#151): Add dark mode / system-adaptive theme support
danwo Apr 19, 2026
5818298
feat(#155): Add WASM PWA support with manifest and service worker
danwo Apr 19, 2026
15fd2c6
fix(#148): Remove duplicate onBleConnectionError slot declaration in …
danwo Apr 19, 2026
a88daf8
fix(#151): Add missing QColor/QPalette includes in apptheme.h for MSVC
danwo Apr 19, 2026
3ee1fe6
Merge feat/issue-148-auto-pause-sensor-dropout into master — closes #148
danwo Apr 19, 2026
c43e6bc
Merge feat/issue-156-battery-warnings into master — closes #156
danwo Apr 19, 2026
21eb9ed
Merge feat/issue-154-interval-overlay into master — closes #154
danwo Apr 19, 2026
4ce8af1
Merge feat/issue-155-pwa-support into master — closes #155
danwo Apr 19, 2026
754e2ce
fix: resolve merge conflict markers left in account.cpp
danwo Apr 20, 2026
ff958d5
Merge feat/issue-151-dark-mode: dark mode / system-adaptive theme (#151)
danwo Apr 20, 2026
73e32f6
Merge feat/issue-144-workout-history: workout history dashboard (#144)
danwo Apr 20, 2026
b74d0ee
fix: remove duplicate saveBatteryWarningThreshold declaration in acco…
danwo Apr 20, 2026
12bf1af
Add missing signal_battery to BtleHubWasm with sensor type inference …
Copilot Apr 20, 2026
b52727f
Feat/issue 152 workout queue (#162)
MaximumTrainer Apr 20, 2026
0301083
feat(#146): Performance Management Chart (CTL/ATL/TSB)
MaximumTrainer Apr 20, 2026
3cbd3d7
feat(#145): Mean Maximal Power (MMP) / Critical Power Curve Analysis
MaximumTrainer Apr 21, 2026
376d677
feat(#40): Implement missing WASM functionality (#166)
MaximumTrainer Apr 21, 2026
7a72d23
Apply suggestions from code review
MaximumTrainer Apr 21, 2026
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
21 changes: 21 additions & 0 deletions docs/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
<title>Maximum Trainer — Web App (Beta)</title>
<meta name="description" content="Maximum Trainer running in your browser via WebAssembly and WebBluetooth." />
<link rel="icon" type="image/png" href="../assets/images/main_icon.png" />
<link rel="manifest" href="manifest.json" />
<meta name="theme-color" content="#e05a00" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Maximum Trainer" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
Expand Down Expand Up @@ -315,5 +321,20 @@ <h2>🔌 Trainer Disconnected</h2>
}
});
</script>

<!-- PWA Service Worker registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('./service-worker.js', { scope: './' })
.then(function(reg) {
console.log('[SW] Registered, scope:', reg.scope);
})
.catch(function(err) {
console.warn('[SW] Registration failed:', err);
});
});
}
</script>
</body>
</html>
20 changes: 20 additions & 0 deletions docs/app/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "Maximum Trainer",
"short_name": "MaxTrainer",
"description": "Free, open-source indoor cycling training — ERG mode, real-time power, BLE sensors & structured intervals.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#0d0d0d",
"theme_color": "#e05a00",
"orientation": "landscape-primary",
"categories": ["sports", "fitness", "health"],
"icons": [
{
"src": "../assets/images/main_icon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
129 changes: 129 additions & 0 deletions docs/app/service-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Maximum Trainer — Service Worker
*
* Caches the WASM application shell and assets for offline use.
* Cache key includes the SW version so that new deployments bust the cache
* automatically.
*
* Cache strategy:
* - App shell assets (JS, WASM, HTML, icons): Cache-first with network fallback.
* - Uncached requests: Network-first.
*/

const SW_VERSION = 'v1';
const CACHE_NAME = `maximum-trainer-${SW_VERSION}`;

// Assets to pre-cache at install time.
// The WASM/JS assets are large — we only pre-cache the thin shell files and
// icons; the WASM binary is cached on first fetch (runtime caching below).
const PRECACHE_ASSETS = [
'./',
'./index.html',
'./logger.js',
'./qtloader.js',
'./manifest.json',
'../assets/images/main_icon.png',
];

// ── Install: pre-cache shell assets ──────────────────────────────────────────
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// Individually fetch and cache each asset; ignore failures for assets
// that may not yet exist in the current deployment (e.g. WASM binary).
return Promise.allSettled(
PRECACHE_ASSETS.map((url) =>
cache.add(url).catch((err) => {
console.warn('[SW] Pre-cache failed for', url, err);
})
)
);
}).then(() => self.skipWaiting())
);
});

// ── Activate: delete old caches ───────────────────────────────────────────────
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => {
console.log('[SW] Deleting old cache:', key);
return caches.delete(key);
})
)
).then(() => self.clients.claim())
);
});

// ── Fetch: cache-first for same-origin requests ───────────────────────────────
self.addEventListener('fetch', (event) => {
// Only intercept GET requests to same origin.
if (event.request.method !== 'GET') return;

const url = new URL(event.request.url);
if (url.origin !== self.location.origin) return;

event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) {
// Return cached version and update cache in background for
// HTML/JS files so the next load gets fresh content.
const isRevalidatable =
event.request.url.endsWith('.html') ||
event.request.url.endsWith('.js');

if (isRevalidatable) {
const networkUpdate = fetch(event.request)
.then((response) => {
if (response && response.status === 200) {
caches.open(CACHE_NAME).then((cache) =>
cache.put(event.request, response.clone())
);
}
return response;
})
.catch(() => null);

// Return stale-while-revalidate
void networkUpdate;
}

return cached;
}

// Not in cache — fetch from network and cache the response.
return fetch(event.request).then((response) => {
if (!response || response.status !== 200 || response.type === 'opaque') {
return response;
}

// Cache WASM, JS, and image assets for offline use.
const shouldCache =
event.request.url.endsWith('.wasm') ||
event.request.url.endsWith('.js') ||
event.request.url.endsWith('.html') ||
event.request.url.endsWith('.png') ||
event.request.url.endsWith('.svg') ||
event.request.url.endsWith('.json');

if (shouldCache) {
caches.open(CACHE_NAME).then((cache) =>
cache.put(event.request, response.clone())
);
}

return response;
}).catch(() => {
// Network failed and not in cache — return a minimal offline page
// for navigation requests.
if (event.request.mode === 'navigate') {
return caches.match('./index.html');
}
return new Response('', { status: 503, statusText: 'Service Unavailable' });
});
})
);
});
3 changes: 2 additions & 1 deletion src/app/credential_store.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
/// macOS — Security.framework Keychain (SecItemAdd / SecItemCopyMatching).
/// Linux — AES-256-GCM via OpenSSL with a randomly-generated key persisted
/// in a chmod-600 protected file in the application data directory.
/// WASM — No-op: third-party tokens are not persisted in the browser context.
/// WASM — browser localStorage via Emscripten JS interop (not encrypted;
/// consistent with standard browser-based web app security model).
///
/// Credentials are keyed by (service, key), e.g.:
/// store("strava", "access_token", token)
Expand Down
74 changes: 63 additions & 11 deletions src/app/credential_store_wasm.cpp
Original file line number Diff line number Diff line change
@@ -1,22 +1,74 @@
// WASM stub for CredentialStore.
// Third-party service tokens are not persisted in the browser context —
// browser-local storage has no reliable encryption boundary and the WASM
// build targets single-session use. All operations are silent no-ops.
// WASM credential store using browser localStorage via Emscripten JS interop.
//
// Desktop builds use platform keychain (DPAPI / Keychain / OpenSSL-AES).
// WASM has no native secure enclave, so credentials are stored in
// localStorage under the key "mt_cred_<service>_<key>". The store is
// cleared when the user's browser data is wiped; values are not encrypted
// but this matches the security model of any other browser-based web app.
//
// This enables OAuth tokens for Strava, Intervals.icu, and TrainingPeaks to
// persist across browser sessions in the WASM build.

#include "credential_store.h"
#include <emscripten.h>
#include <cstdlib>

bool CredentialStore::store(const QString & /*service*/,
const QString & /*key*/,
const QString & /*value*/)
static std::string makeLocalStorageKey(const QString &service, const QString &key)
{
return false;
return std::string("mt_cred_")
+ service.toStdString()
+ "_"
+ key.toStdString();
}

QString CredentialStore::load(const QString & /*service*/, const QString & /*key*/)
bool CredentialStore::store(const QString &service,
const QString &key,
const QString &value)
{
return {};
const std::string k = makeLocalStorageKey(service, key);
const std::string v = value.toStdString();
const int stored = EM_ASM_INT({
try {
window.localStorage.setItem(
UTF8ToString($0),
UTF8ToString($1));
return 1;
} catch(e) {
return 0;
}
}, k.c_str(), v.c_str());
return stored != 0;
}

void CredentialStore::remove(const QString & /*service*/, const QString & /*key*/)
QString CredentialStore::load(const QString &service, const QString &key)
{
const std::string k = makeLocalStorageKey(service, key);
char *raw = reinterpret_cast<char *>(EM_ASM_PTR({
try {
const val = window.localStorage.getItem(UTF8ToString($0));
if (!val) return 0;
const len = lengthBytesUTF8(val) + 1;
const heap = _malloc(len);
stringToUTF8(val, heap, len);
return heap;
} catch(e) {
return 0;
}
}, k.c_str()));
if (!raw)
return {};
const QString result = QString::fromUtf8(raw);
// NOLINTNEXTLINE(cppcoreguidelines-no-malloc)
free(raw);
return result;
}

void CredentialStore::remove(const QString &service, const QString &key)
{
const std::string k = makeLocalStorageKey(service, key);
EM_ASM({
try {
window.localStorage.removeItem(UTF8ToString($0));
} catch(e) {}
}, k.c_str());
}
15 changes: 13 additions & 2 deletions src/app/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include "splashscreen.h"
#include "account.h"
#include "xmlutil.h"
#include "apptheme.h"

#ifdef GC_HAVE_VLCQT
#include "myvlcplayer.h"
Expand Down Expand Up @@ -94,9 +95,12 @@ int main(int argc, char *argv[]) {
splash.setProgress(30);
#endif // Q_OS_WASM

/// App Stylesheet (hack so I can type stylesheet in designer instead of source code)
/// App Stylesheet — apply light theme as baseline; re-applied after login
/// based on the user's saved theme preference (Light / Dark / System).
Z_StyleSheet styleSheetDummy;
app.setStyleSheet(styleSheetDummy.styleSheet());
const QString lightQss = styleSheetDummy.styleSheet();
qApp->setProperty("lightStylesheet", lightQss);
app.setStyleSheet(lightQss);

// --screenshots [dir] / /screenshots [dir]: bypass login, capture UI
// screenshots to [dir] (default: <appdir>/screenshots), then quit.
Expand Down Expand Up @@ -128,6 +132,13 @@ int main(int argc, char *argv[]) {
}
#endif // Q_OS_WASM

// Re-apply theme based on user preference now that the Account is loaded.
{
auto *account = qApp->property("Account").value<Account*>();
if (account)
AppTheme::apply(&app, static_cast<AppTheme::Mode>(account->app_theme));
}

#ifdef Q_OS_WASM
// WASM has no persistent server login. Initialize the global Account with
// safe offline/guest defaults — identical to DialogLogin::loginOffline().
Expand Down
Loading
Loading