Render a session's track on the map
CI / check (push) Successful in 47s
CI / deploy (push) Successful in 19s

Clicking a LiveTrack session now fetches and draws its track (line +
latest position). Garmin's own API 403s any plain server-side request
(Cloudflare bot protection), so LiveTrackClient fetches the session
page instead and pulls the track out of the server-rendered data
embedded in it. The webhook now captures the session's token too,
since the id alone isn't enough to fetch anything.
This commit is contained in:
randy committed 2026-09-06 22:07:13 +02:00
1 parent 7083cec36c
commit f4b5237f01
7 files changed
+326 -29

No files matched your search

+39 -2
View File
@@ -6,6 +6,7 @@ require_relative "lib/maptool/gpx"
require_relative "lib/maptool/gpx_store" require_relative "lib/maptool/gpx_store"
require_relative "lib/maptool/gpsd_client" require_relative "lib/maptool/gpsd_client"
require_relative "lib/maptool/livetrack_store" require_relative "lib/maptool/livetrack_store"
require_relative "lib/maptool/livetrack_client"
module Maptool module Maptool
class App < Sinatra::Base class App < Sinatra::Base
@@ -228,14 +229,15 @@ module Maptool
end end
# Called by the Apps Script watching Gmail for Garmin LiveTrack emails — # Called by the Apps Script watching Gmail for Garmin LiveTrack emails —
# it only extracts and forwards the session ID, nothing else. # it only extracts and forwards the session id/token, nothing else.
post "/api/livetrack/session" do post "/api/livetrack/session" do
halt 403 if LIVETRACK_WEBHOOK_SECRET.empty? || params[:secret] != LIVETRACK_WEBHOOK_SECRET halt 403 if LIVETRACK_WEBHOOK_SECRET.empty? || params[:secret] != LIVETRACK_WEBHOOK_SECRET
session_id = params[:session_id].to_s.strip session_id = params[:session_id].to_s.strip
session_token = params[:session_token].to_s.strip
halt 400, json(error: "session_id is required") if session_id.empty? halt 400, json(error: "session_id is required") if session_id.empty?
LIVETRACK_STORE.add(session_id) LIVETRACK_STORE.add(session_id, session_token)
json ok: true json ok: true
end end
@@ -243,6 +245,41 @@ module Maptool
json sessions: LIVETRACK_STORE.list json sessions: LIVETRACK_STORE.list
end end
# Garmin's own API (livetrack.garmin.com/api/v2/...) 403s a plain server
# request no matter what headers it gets — see LiveTrackClient — so this
# fetches the session page itself and pulls the track out of that.
get "/api/livetrack/sessions/:id/track" do
session = LIVETRACK_STORE.find(params[:id])
halt 404, json(error: "unknown session") unless session
halt 404, json(error: "session has no token") if session["token"].to_s.empty?
begin
points = LiveTrackClient.track_points(session["id"], session["token"])
rescue LiveTrackClient::FetchError => e
halt 502, json(error: "could not fetch track: #{e.message}")
end
coordinates = points.filter_map { |p| p.dig("position", "lon") && p.dig("position", "lat") && [p["position"]["lon"], p["position"]["lat"]] }
halt 502, json(error: "track has no positions") if coordinates.empty?
last = points.last
json(
type: "FeatureCollection",
features: [
{ type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: {} },
{
type: "Feature",
geometry: { type: "Point", coordinates: coordinates.last },
properties: {
time: last["dateTime"],
speedMetersPerSec: last["speedMetersPerSec"],
heartRateBeatsPerMin: last["heartRateBeatsPerMin"],
},
},
]
)
end
get "/api/gnss/position" do get "/api/gnss/position" do
GNSS_LATEST_MUTEX.synchronize do GNSS_LATEST_MUTEX.synchronize do
received_at = GNSS_LATEST[:received_at] received_at = GNSS_LATEST[:received_at]
+23 -11
View File
@@ -109,13 +109,25 @@ usernames, e.g. `randy,friend1,friend2` — not their emails, the
== Garmin LiveTrack session webhook == Garmin LiveTrack session webhook
`POST /api/livetrack/session` (`session_id`, `secret` form params) records a `POST /api/livetrack/session` (`session_id`, `session_token`, `secret` form
Garmin LiveTrack session ID to `data/livetrack.json`; `GET params) records a Garmin LiveTrack session to `data/livetrack.json`; `GET
/api/livetrack/sessions` lists them, newest first. Intended caller: a Google /api/livetrack/sessions` lists them, newest first. Intended caller: a Google
Apps Script bound to the Gmail account that receives LiveTrack emails, Apps Script bound to the Gmail account that receives LiveTrack emails,
triggered on a timer, extracting the session ID from the email and posting triggered on a timer, extracting the session id/token from the email and
it here — the endpoint itself doesn't know anything about email or Garmin's posting them here — the endpoint itself doesn't know anything about email or
API, just the ID string. Garmin's API, just those two strings.
`GET /api/livetrack/sessions/:id/track` fetches and returns that session's
track as GeoJSON (a `LineString` plus a `Point` at the latest position) —
see `LiveTrackClient` (`lib/maptool/livetrack_client.rb`) for how, since
there's no usable REST call for this: Garmin's own API
(`livetrack.garmin.com/api/v2/...`) 403s a plain server-side HTTP request no
matter what headers it's given (Cloudflare bot protection, fingerprinting
at the TLS/HTTP level rather than anything header-based) — but the session
page itself isn't blocked, and Garmin's Next.js frontend renders the full
track server-side into it (as React Server Component data, not a follow-up
API call), so the client fetches that page and pulls the track out of the
embedded payload instead.
`secret` must match the `LIVETRACK_WEBHOOK_SECRET` repo secret (added to `secret` must match the `LIVETRACK_WEBHOOK_SECRET` repo secret (added to
`.env` the same way as the others above). Unlike the other secrets, this one `.env` the same way as the others above). Unlike the other secrets, this one
@@ -142,12 +154,12 @@ function checkLiveTrackEmail() {
// part, only text/html, and the link only exists as an href attribute // part, only text/html, and the link only exists as an href attribute
// — the auto-generated plain-text conversion isn't reliable for it. // — the auto-generated plain-text conversion isn't reliable for it.
const body = thread.getMessages()[0].getBody(); const body = thread.getMessages()[0].getBody();
const match = body.match(/https:\/\/livetrack\.garmin\.com\/session\/([\w-]+)/); const match = body.match(/https:\/\/livetrack\.garmin\.com\/session\/([\w-]+)\/token\/([\w-]+)/);
if (match) { if (match) {
UrlFetchApp.fetch(WEBHOOK_URL, { UrlFetchApp.fetch(WEBHOOK_URL, {
method: "post", method: "post",
payload: { session_id: match[1], secret: WEBHOOK_SECRET } payload: { session_id: match[1], session_token: match[2], secret: WEBHOOK_SECRET }
}); });
} }
@@ -168,11 +180,11 @@ every 5 minutes.
Verified against a real "started an activity" LiveTrack email Verified against a real "started an activity" LiveTrack email
(2026-09-06): sender `noreply@garmin.com`, link (2026-09-06): sender `noreply@garmin.com`, link
`https://livetrack.garmin.com/session/<uuid>/token/<token>` — the regex `https://livetrack.garmin.com/session/<uuid>/token/<token>`, and against
above only captures the `<uuid>` part, stopping at the next `/`. Garmin the resulting session page/track fetch (`LiveTrackClient`, same day).
also sends other LiveTrack notifications (e.g. activity ended) that Garmin also sends other LiveTrack notifications (e.g. activity ended) that
weren't checked — if one turns out to use a different link shape, the weren't checked — if one turns out to use a different link shape, the
webhook simply won't receive that session ID; nothing breaks. webhook simply won't receive that session; nothing breaks.
== CI deploy job == CI deploy job
+115
View File
@@ -0,0 +1,115 @@
require "net/http"
require "json"
module Maptool
# Fetches a Garmin LiveTrack session's track points.
#
# There's no usable REST call for this: livetrack.garmin.com/api/v2/... is
# behind Cloudflare bot protection that 403s a plain HTTP client regardless
# of headers (checked — not just a missing User-Agent). The session page
# itself isn't blocked, though, and Garmin's Next.js frontend renders it
# server-side, embedding the full track (as React Server Component data,
# via `self.__next_f.push(...)` calls in the HTML) rather than fetching it
# client-side. So: fetch the page, and pull the track out of that instead
# of the API it would otherwise call.
class LiveTrackClient
class FetchError < StandardError; end
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
PUSH_PATTERN = /self\.__next_f\.push\((\[.*?\])\)<\/script>/m
def self.track_points(session_id, token)
new.track_points(session_id, token)
end
def track_points(session_id, token)
points = find_track_points(fetch_html(session_id, token))
raise FetchError, "no track points found (session invalid or expired?)" unless points
points.map { |point| clean(point) }
end
private
# The RSC payload serializes an absent field as the literal string
# "$undefined" (Next.js's flight protocol, not JSON's own undefined) —
# normalize that to nil so nothing downstream has to know about it.
def clean(node)
case node
when "$undefined" then nil
when Hash then node.transform_values { |value| clean(value) }
when Array then node.map { |value| clean(value) }
else node
end
end
def fetch_html(session_id, token)
uri = URI("https://livetrack.garmin.com/session/#{session_id}/token/#{token}")
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 15) do |http|
http.get(uri.request_uri, "User-Agent" => USER_AGENT)
end
raise FetchError, "unexpected response #{response.code}" unless response.is_a?(Net::HTTPSuccess)
response.body
rescue SocketError, Timeout::Error, OpenSSL::SSL::SSLError => e
raise FetchError, e.message
end
# Next.js streams several of these RSC payloads per page; the track
# points live inside one of them, under some data-fetching library's own
# nesting (React Query, at the time this was written) that isn't worth
# depending on the exact shape of — so this just walks every payload
# looking for a "trackPoints" array, wherever it ends up.
def find_track_points(html)
html.scan(PUSH_PATTERN).each do |(raw)|
push = safe_parse(raw)
next unless push.is_a?(Array) && push[1].is_a?(String)
_line_id, json = push[1].split(":", 2)
next unless json
found = search_track_points(safe_parse(json))
return found if found
end
nil
end
def search_track_points(node)
case node
when Hash
return node["trackPoints"] if node["trackPoints"].is_a?(Array)
node.each_value { |value| (found = search_track_points(value)) and return found }
nil
when Array
node.each { |value| (found = search_track_points(value)) and return found }
nil
end
end
def safe_parse(json)
JSON.parse(json)
rescue JSON::ParserError
nil
end
end
end
if __FILE__ == $0
client = Maptool::LiveTrackClient.new
fake_page = <<~HTML
<html><body>
<script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script>
<script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\"}]}]}}}"])</script>
</body></html>
HTML
points = client.send(:find_track_points, fake_page)
raise "expected 2 points" unless points&.size == 2
raise "expected first point lat 48.1" unless points[0]["position"]["lat"] == 48.1
raise "expected nil when no trackPoints present" unless client.send(:find_track_points, "<html></html>").nil?
puts "ok"
end
+18 -9
View File
@@ -2,10 +2,12 @@ require "json"
require "fileutils" require "fileutils"
module Maptool module Maptool
# JSON-file-backed store of Garmin LiveTrack session IDs, appended to by a # JSON-file-backed store of Garmin LiveTrack sessions, appended to by a
# webhook whenever a new LiveTrack email arrives (see docs/deployment.adoc). # webhook whenever a new LiveTrack email arrives (see docs/deployment.adoc).
# Validity/expiry of a session isn't tracked here — checking whether a # The token is what actually lets us fetch the session's track later (see
# given ID still has live data is up to whoever polls Garmin with it. # LiveTrackClient) — Garmin's session URL is /session/<id>/token/<token>,
# and the id alone isn't enough. Validity/expiry isn't tracked here —
# whoever fetches the track finds out then.
class LiveTrackStore class LiveTrackStore
def initialize(path) def initialize(path)
@path = path @path = path
@@ -14,12 +16,12 @@ module Maptool
File.write(@path, "[]") unless File.exist?(@path) File.write(@path, "[]") unless File.exist?(@path)
end end
# Records `session_id`, moving it to the front if already present. # Records `session_id`/`token`, moving it to the front if already present.
def add(session_id) def add(session_id, token)
@mutex.synchronize do @mutex.synchronize do
sessions = read sessions = read
sessions.reject! { |s| s["id"] == session_id } sessions.reject! { |s| s["id"] == session_id }
sessions.unshift({ "id" => session_id, "received_at" => Time.now.utc.iso8601 }) sessions.unshift({ "id" => session_id, "token" => token, "received_at" => Time.now.utc.iso8601 })
File.write(@path, JSON.pretty_generate(sessions)) File.write(@path, JSON.pretty_generate(sessions))
end end
end end
@@ -29,6 +31,10 @@ module Maptool
read read
end end
def find(session_id)
read.find { |s| s["id"] == session_id }
end
private private
def read def read
@@ -46,14 +52,17 @@ if __FILE__ == $0
store = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json")) store = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json"))
raise "expected empty list" unless store.list == [] raise "expected empty list" unless store.list == []
store.add("abc123") store.add("abc123", "tok-abc")
store.add("def456") store.add("def456", "tok-def")
raise "expected 2 sessions" unless store.list.size == 2 raise "expected 2 sessions" unless store.list.size == 2
raise "expected newest first" unless store.list.first["id"] == "def456" raise "expected newest first" unless store.list.first["id"] == "def456"
raise "expected token stored" unless store.find("abc123")["token"] == "tok-abc"
raise "expected unknown id to be nil" unless store.find("nope").nil?
store.add("abc123") store.add("abc123", "tok-abc-2")
raise "expected re-added session moved to front, not duplicated" \ raise "expected re-added session moved to front, not duplicated" \
unless store.list.map { |s| s["id"] } == %w[abc123 def456] unless store.list.map { |s| s["id"] } == %w[abc123 def456]
raise "expected token updated on re-add" unless store.find("abc123")["token"] == "tok-abc-2"
reloaded = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json")) reloaded = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json"))
raise "expected data to survive reload" unless reloaded.list.size == 2 raise "expected data to survive reload" unless reloaded.list.size == 2
+25 -2
View File
@@ -440,12 +440,35 @@ html, body {
} }
.livetrack-panel__item { .livetrack-panel__item {
border-bottom: 1px solid #f0f0f0;
}
.livetrack-panel__row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding: 6px 0; width: 100%;
border-bottom: 1px solid #f0f0f0; padding: 6px 4px;
border: none;
border-radius: 4px;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.livetrack-panel__row:hover {
background: #f0f0f0;
}
.livetrack-panel__row.active {
background: #f1e6fb;
}
.livetrack-panel__row.loading {
opacity: 0.6;
cursor: wait;
} }
.livetrack-panel__id { .livetrack-panel__id {
+105 -4
View File
@@ -2,11 +2,27 @@ import { LIVETRACK_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
const SESSIONS_URL = "/api/livetrack/sessions"; const SESSIONS_URL = "/api/livetrack/sessions";
const TRACK_SOURCE = "livetrack-track";
const TRACK_COLOR = "#7209b7";
// A sidebar icon that opens a side panel listing Garmin LiveTrack session // A sidebar icon that opens a side panel listing Garmin LiveTrack sessions
// IDs received via the webhook (see docs/deployment.adoc) — read-only, just // received via the webhook (see docs/deployment.adoc). Clicking one fetches
// so they're visible somewhere instead of only in data/livetrack.json. // and draws its track on the map (only one shown at a time — clicking the
// active one again hides it).
export class LiveTrackPanelControl { export class LiveTrackPanelControl {
constructor(map) {
this._map = map;
this._activeSessionId = null;
this._geojson = null;
// setStyle() (switching the Online/Offline base map) tears down all
// custom sources and layers — put the track back afterwards, same as
// GpxPanelControl does for visible GPX files.
map.on("style.load", () => {
if (this._geojson) this._showTrack(this._geojson, { fit: false });
});
}
mount(sidebar) { mount(sidebar) {
this._container = document.createElement("div"); this._container = document.createElement("div");
this._container.className = "sidebar-item"; this._container.className = "sidebar-item";
@@ -56,6 +72,11 @@ export class LiveTrackPanelControl {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "livetrack-panel__item"; li.className = "livetrack-panel__item";
const button = document.createElement("button");
button.type = "button";
button.className = "livetrack-panel__row";
button.classList.toggle("active", id === this._activeSessionId);
const idSpan = document.createElement("span"); const idSpan = document.createElement("span");
idSpan.className = "livetrack-panel__id"; idSpan.className = "livetrack-panel__id";
idSpan.textContent = id; idSpan.textContent = id;
@@ -64,8 +85,88 @@ export class LiveTrackPanelControl {
timeSpan.className = "livetrack-panel__time"; timeSpan.className = "livetrack-panel__time";
timeSpan.textContent = new Date(received_at).toLocaleString(); timeSpan.textContent = new Date(received_at).toLocaleString();
li.append(idSpan, timeSpan); button.append(idSpan, timeSpan);
button.addEventListener("click", () => this._toggleTrack(id, button));
li.appendChild(button);
this._list.appendChild(li); this._list.appendChild(li);
}); });
} }
async _toggleTrack(id, button) {
const wasActive = id === this._activeSessionId;
this._removeTrack();
this._list.querySelectorAll(".livetrack-panel__row.active").forEach((el) => el.classList.remove("active"));
if (wasActive) return;
button.classList.add("loading");
try {
const response = await fetch(`/api/livetrack/sessions/${encodeURIComponent(id)}/track`);
if (!response.ok) {
const { error } = await response.json().catch(() => ({}));
window.alert(error || "Could not load track.");
return;
}
this._geojson = await response.json();
this._showTrack(this._geojson);
this._activeSessionId = id;
button.classList.add("active");
} finally {
button.classList.remove("loading");
}
}
_showTrack(geojson, { fit = true } = {}) {
if (this._map.getSource(TRACK_SOURCE)) return;
this._map.addSource(TRACK_SOURCE, { type: "geojson", data: geojson });
this._map.addLayer({
id: `${TRACK_SOURCE}-line`,
type: "line",
source: TRACK_SOURCE,
filter: ["==", ["geometry-type"], "LineString"],
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
});
this._map.addLayer({
id: `${TRACK_SOURCE}-point`,
type: "circle",
source: TRACK_SOURCE,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-radius": 6,
"circle-color": TRACK_COLOR,
"circle-stroke-color": "#fff",
"circle-stroke-width": 1.5,
},
});
if (!fit) return;
const coordinates = geojson.features[0].geometry.coordinates;
const bounds = coordinates.reduce(
([minLon, minLat, maxLon, maxLat], [lon, lat]) => [
Math.min(minLon, lon),
Math.min(minLat, lat),
Math.max(maxLon, lon),
Math.max(maxLat, lat),
],
[Infinity, Infinity, -Infinity, -Infinity],
);
this._map.fitBounds(
[
[bounds[0], bounds[1]],
[bounds[2], bounds[3]],
],
{ padding: 40 },
);
}
_removeTrack() {
this._activeSessionId = null;
this._geojson = null;
[`${TRACK_SOURCE}-line`, `${TRACK_SOURCE}-point`].forEach((layerId) => {
if (this._map.getLayer(layerId)) this._map.removeLayer(layerId);
});
if (this._map.getSource(TRACK_SOURCE)) this._map.removeSource(TRACK_SOURCE);
}
} }
+1 -1
View File
@@ -128,7 +128,7 @@ layerToggle.setActive("online");
new GpxPanelControl(map).mount(sidebar); new GpxPanelControl(map).mount(sidebar);
new RadarControl(map).mount(sidebar); new RadarControl(map).mount(sidebar);
new GnssControl(map).mount(sidebar); new GnssControl(map).mount(sidebar);
new LiveTrackPanelControl().mount(sidebar); new LiveTrackPanelControl(map).mount(sidebar);
function applyStyle(name) { function applyStyle(name) {
layerToggle.setActive(name); layerToggle.setActive(name);