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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG BASE_PATH=/
RUN BASE_PATH="$BASE_PATH" npm run build
# .git isn't in the build context, so pass the version in: --build-arg APP_VERSION=v1.2.0
ARG APP_VERSION=dev
RUN BASE_PATH="$BASE_PATH" APP_VERSION="$APP_VERSION" npm run build

FROM nginxinc/nginx-unprivileged:1.29-alpine AS serve
ARG BASE_PATH=/
Expand Down
5 changes: 2 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ConnectBar } from './components/ConnectBar.tsx';
import { LiveSession } from './components/LiveSession.tsx';
import { DiagnosticsPanel } from './components/DiagnosticsPanel.tsx';
import { ReportToast } from './components/ReportToast.tsx';
import { AppFooter } from './components/AppFooter.tsx';
import { StatGrid } from './components/StatGrid.tsx';
import { TrendChart } from './components/TrendChart.tsx';
import { Insights } from './components/Insights.tsx';
Expand Down Expand Up @@ -92,9 +93,7 @@ export default function App() {
</>
)}

<footer className="pt-2 text-center text-[11px] text-fg-subtle">
Brushlog · self-owned · offline-first · your data never leaves this device
</footer>
<AppFooter />
</main>

{savedToast && (
Expand Down
53 changes: 53 additions & 0 deletions src/components/AppFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const REPO = 'https://github.com/libreble/brushlog';
/** A clean tag build ("v1.2.0") links to its release; anything else is shown as plain text. */
const RELEASE = /^v\d+\.\d+\.\d+$/.test(__APP_VERSION__) ? `${REPO}/releases/tag/${__APP_VERSION__}` : null;

const LINK = 'inline-flex items-center gap-1 hover:text-fg';

export function AppFooter() {
return (
<footer className="pt-2 text-[11px] text-fg-subtle">
<p className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
<a href="https://libreble.github.io/" className={LINK}>
<LibrebleMark />
Part of <span className="font-medium">libre<span className="text-[#c2410c] dark:text-[#ffa259]">ble</span></span>
</a>
<a href={REPO} target="_blank" rel="noopener noreferrer" className={LINK}>
<GitHubMark />
GitHub
</a>
<a href={`${REPO}/issues/new/choose`} target="_blank" rel="noopener noreferrer" className={LINK}>
Report an issue
</a>
{RELEASE ? (
<a href={RELEASE} target="_blank" rel="noopener noreferrer" className={`${LINK} font-mono`}>
{__APP_VERSION__}
</a>
) : (
<span className="font-mono">{__APP_VERSION__}</span>
)}
</p>
</footer>
);
}

/** The libreble "open beacon": an LED inside two rings opening to the top right. */
function LibrebleMark() {
return (
<svg viewBox="0 0 64 64" className="h-3.5 w-3.5" aria-hidden>
<g transform="translate(32 32) rotate(-45) translate(-32 -32)" fill="none" stroke="currentColor" strokeWidth="6" strokeLinecap="round">
<path d="M44.36 40.50A15 15 0 1 1 44.36 23.50" />
<path d="M54.69 42.50A25 25 0 1 1 54.69 21.50" />
</g>
<circle cx="32" cy="32" r="6.5" className="fill-[#f26b1d] dark:fill-[#ff8a3d]" />
</svg>
);
}

function GitHubMark() {
return (
<svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5" aria-hidden>
<path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z" />
</svg>
);
}
1 change: 1 addition & 0 deletions src/components/ConnectBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function ConnectBar(props: Props) {
</button>
<button
onClick={props.onDisconnect}
title="Disconnect and forget this brush — connecting again shows the chooser"
className="rounded-lg border border-line-strong px-3 py-1.5 text-sm text-fg hover:bg-surface-hover"
>
Disconnect
Expand Down
29 changes: 16 additions & 13 deletions src/components/ReportToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,43 @@ interface Props {
raised: boolean;
}

const doneKey = (d: DeviceInfo) => `brushlog.reportDone.${d.modelId}-${d.protocolVersion ?? 'x'}`;
/** Asked once per install, whatever the model — a working brush shouldn't nag. */
const DONE_KEY = 'brushlog.reportDone';

function isDone(d: DeviceInfo): boolean {
function isDone(): boolean {
try {
return localStorage.getItem(doneKey(d)) !== null;
// Older builds stored one key per model (brushlog.reportDone.<model>-<protocol>) — honour those.
return Object.keys(localStorage).some((k) => k === DONE_KEY || k.startsWith(`${DONE_KEY}.`));
} catch {
return false;
}
}

/**
* A dismissible, non-blocking toast inviting a device report (see lib/report.ts):
* - a brush that isn't an iO connected → "how is it working?", once per model (dismiss or report);
* - the chooser was dismissed or connecting failed → "trouble finding or connecting?",
* hidden on dismiss until the next cancel/failure.
* - a brush that isn't an iO connected → "how is it working?", once per install (dismiss or report);
* - the chooser was dismissed or any error (connect, wrong device, sync) → "trouble with your
* brush?", hidden on dismiss until the next cancel/error.
* Both open a pre-filled GitHub issue form; the user reviews and submits it there.
*/
export function ReportToast({ state, cancelled, error, device, discovery, raised }: Props) {
const [doneNow, setDoneNow] = useState(false);
const [problemDismissed, setProblemDismissed] = useState(false);
const problem = cancelled || state === 'error';
const problem = cancelled || state === 'error' || error !== null;

// Re-arm once the problem clears, or when a different error replaces the dismissed one.
useEffect(() => {
if (!problem) setProblemDismissed(false);
}, [problem]);
setProblemDismissed(false);
}, [problem, error]);

// Wait for discovery so the report carries the GATT layout.
const ask =
state === 'connected' && device && discovery && !isConfirmedModel(device) && !doneNow && !isDone(device);
state === 'connected' && device && discovery && !isConfirmedModel(device) && !doneNow && !isDone();

if (ask) {
const finish = () => {
try {
localStorage.setItem(doneKey(device), '1');
localStorage.setItem(DONE_KEY, '1');
} catch { /* storage unavailable — it may ask again next time */ }
setDoneNow(true);
};
Expand All @@ -60,10 +63,10 @@ export function ReportToast({ state, cancelled, error, device, discovery, raised
);
}

if (problem && !problemDismissed && state !== 'connected') {
if (problem && !problemDismissed) {
return (
<Toast raised={raised} onDismiss={() => setProblemDismissed(true)}>
<p>Trouble finding or connecting your brush?</p>
<p>Trouble with your brush?</p>
<a
href={connectionProblemUrl(error)}
target="_blank"
Expand Down
39 changes: 33 additions & 6 deletions src/hooks/useBrush.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { OralBBrush } from '../protocol/oralb.ts';
import { OralBBrush, NotABrushError } from '../protocol/oralb.ts';
import type { Session, DeviceInfo, BrushHead, LiveState, DiscoveredService, RawFrame } from '../protocol/types.ts';

export type ConnState = 'unsupported' | 'insecure' | 'idle' | 'connecting' | 'connected' | 'error';
Expand All @@ -18,6 +18,12 @@ const RECONNECT_POLL_MS = 1000;
/** Give up a single silent connect attempt after this so an out-of-range brush can't hang it. */
const RECONNECT_ATTEMPT_TIMEOUT_MS = 4000;

function forgetLastDevice(): void {
try {
localStorage.removeItem(LAST_DEVICE_KEY);
} catch { /* ignore */ }
}

function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const t = setTimeout(() => reject(new Error('timeout')), ms);
Expand Down Expand Up @@ -136,6 +142,13 @@ export function useBrush({ onSync, lastSyncedTimestamp }: UseBrushOptions) {
try {
const brush = await OralBBrush.request();
await brush.connect();
try {
await brush.verify();
} catch (e) {
// Wrong device picked: drop the permission so auto-reconnect never comes back to it.
await brush.forget();
throw e;
}
activate(brush);
} catch (e) {
// User cancelling the chooser throws — treat that as a soft return to idle.
Expand All @@ -158,18 +171,30 @@ export function useBrush({ onSync, lastSyncedTimestamp }: UseBrushOptions) {
const tryReconnect = useCallback(async () => {
if (reconnectingRef.current || pausedRef.current) return;
if (stateRef.current !== 'idle') return;
const devices = await OralBBrush.knownDevices();
if (devices.length === 0) return;
let savedId: string | null = null;
try {
savedId = localStorage.getItem(LAST_DEVICE_KEY);
} catch { /* ignore */ }
const target = devices.find((d) => d.id === savedId) ?? devices[0];
if (!savedId) return;
// Only the brush we last verified — never "whatever else this origin was granted".
const target = (await OralBBrush.knownDevices()).find((d) => d.id === savedId);
if (!target) return;

reconnectingRef.current = true;
const brush = OralBBrush.fromDevice(target);
try {
await withTimeout(brush.connect(), RECONNECT_ATTEMPT_TIMEOUT_MS);
try {
await withTimeout(brush.verify(), RECONNECT_ATTEMPT_TIMEOUT_MS);
} catch (e) {
if (e instanceof NotABrushError) {
// Remembered from before this check existed — forget it for good.
forgetLastDevice();
await brush.forget();
return;
}
throw e;
}
// Re-check we didn't connect/disconnect elsewhere while awaiting.
if (stateRef.current === 'idle' && !pausedRef.current) {
activate(brush);
Expand All @@ -183,9 +208,11 @@ export function useBrush({ onSync, lastSyncedTimestamp }: UseBrushOptions) {
}
}, [activate]);

/** Disconnect = forget: revoke the permission so the brush isn't silently reconnected. */
const disconnect = useCallback(() => {
pausedRef.current = true; // stop the background poll until the user reconnects
brushRef.current?.disconnect();
pausedRef.current = true; // belt and braces where forget() is unsupported
forgetLastDevice();
void brushRef.current?.forget();
brushRef.current = null;
cleanup();
setState('idle');
Expand Down
38 changes: 38 additions & 0 deletions src/protocol/oralb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ const LIVE_SUBSCRIPTIONS: LiveSubscription[] = [
{ service: SERVICE.GENERAL, uuid: CHAR.SMILEY, apply: (v, s) => { s.smiley = decodeSmiley(v); } },
];

/** The picked/remembered device doesn't speak the Oral-B protocol (e.g. some other BLE gadget). */
export class NotABrushError extends Error {
constructor(deviceName?: string) {
super(`${deviceName ? `"${deviceName}"` : 'That device'} doesn't look like an Oral-B brush.`);
this.name = 'NotABrushError';
}
}

export class OralBBrush {
readonly device: BluetoothDevice;
private server: BluetoothRemoteGATTServer | null = null;
Expand Down Expand Up @@ -173,6 +181,36 @@ export class OralBBrush {
this.device.gatt?.disconnect();
}

/**
* Disconnect and revoke this origin's permission for the device, so getDevices() no longer
* returns it and silent reconnect can't pick it up. Falls back to a plain disconnect where
* forget() is unavailable (Chrome < 101).
*/
async forget(): Promise<void> {
this.device.removeEventListener('gattserverdisconnected', this.onDisconnectBound);
this.device.gatt?.disconnect();
if (typeof this.device.forget === 'function') {
try {
await this.device.forget();
} catch { /* already revoked */ }
}
}

/**
* Confirm the connected device really is an Oral-B brush: it must expose the Oral-B GENERAL
* service and answer a DEVICE_TYPE read with a well-formed 1- or 3-byte value. Read-only, and
* present on every generation. Throws NotABrushError otherwise.
*/
async verify(): Promise<DeviceInfo> {
const typeChar = await this.getCharacteristic(SERVICE.GENERAL, CHAR.DEVICE_TYPE);
if (!typeChar) throw new NotABrushError(this.device.name);
try {
return decodeDeviceType(await typeChar.readValue());
} catch {
throw new NotABrushError(this.device.name);
}
}

/** Detach device-level listeners for a brush we're abandoning (e.g. a failed reconnect). */
dispose(): void {
this.device.removeEventListener('gattserverdisconnected', this.onDisconnectBound);
Expand Down
3 changes: 3 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

/** Build version for the footer — see appVersion() in vite.config.ts. */
declare const __APP_VERSION__: string;
14 changes: 14 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { VitePWA } from 'vite-plugin-pwa';
import { execSync } from 'node:child_process';

// Brushlog is a fully local PWA: no backend, no analytics, no cloud.
// The service worker precaches the app shell so it runs offline once installed.
Expand All @@ -10,8 +11,21 @@ import { VitePWA } from 'vite-plugin-pwa';
// Self-hosters override it: `BASE_PATH=/ npm run build` (the Docker image does this).
const base = `/${(process.env.BASE_PATH ?? '/brushlog/').replace(/^\/+|\/+$/g, '')}/`.replace('//', '/');

// Shown in the footer. Tag builds in CI use the tag (v1.2.0); elsewhere `git describe`
// (v1.2.0-3-gabc1234, or a bare sha on a shallow clone); `APP_VERSION` overrides; no git → "dev".
function appVersion(): string {
if (process.env.APP_VERSION) return process.env.APP_VERSION;
if (process.env.GITHUB_REF_TYPE === 'tag' && process.env.GITHUB_REF_NAME) return process.env.GITHUB_REF_NAME;
try {
return execSync('git describe --tags --always', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
} catch {
return 'dev';
}
}

export default defineConfig({
base,
define: { __APP_VERSION__: JSON.stringify(appVersion()) },
plugins: [
react(),
tailwindcss(),
Expand Down
Loading