Skip to content

Commit ad14a94

Browse files
Eric Migicovskyclaude
andcommitted
qemu-wasm: robust geolocation for JS apps
Call the top page's geolocation rather than the sandbox iframe's (removes the permissions-delegation variable), fall back to IP-based city-level location when the browser provider fails (common when OS location services are off), and support a ?loc=lat,lon override. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
1 parent 58add8d commit ad14a94

4 files changed

Lines changed: 126 additions & 32 deletions

File tree

tools/qemu-wasm/site-dist/index.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,15 @@ <h1>PebbleOS Emulator</h1>
485485
const PKJS_PROXY = params2.get('pkjs_proxy') ||
486486
'https://pkjs-proxy.ericmigi.workers.dev/?url=';
487487

488+
// Optional fixed location for JS apps: ?loc=37.44,-122.14
489+
let PKJS_LOC = null;
490+
if (params2.get('loc')) {
491+
const m = params2.get('loc').split(',').map(Number);
492+
if (m.length === 2 && m.every(Number.isFinite)) {
493+
PKJS_LOC = { latitude: m[0], longitude: m[1] };
494+
}
495+
}
496+
488497
// Per-app persistent storage, scoped into the page's localStorage.
489498
function scopedStorage(prefix) {
490499
const p = 'pkjs-' + prefix + '-';
@@ -594,7 +603,7 @@ <h1>PebbleOS Emulator</h1>
594603
installer = new AppInstaller(phone, onProgress, (m) => say(m));
595604
const am = new AppMessageClient(phone, jsLog);
596605
pkjs = new PkjsRuntime(phone, am, {
597-
createSandbox: makeIframeSandbox(PKJS_PROXY, jsLog),
606+
createSandbox: makeIframeSandbox(PKJS_PROXY, jsLog, PKJS_LOC),
598607
storage: scopedStorage,
599608
tokenStore: window.localStorage,
600609
openUrl: openConfigUrl,

tools/qemu-wasm/site-dist/pkjs-runtime.js

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,33 @@ export function makeMemoryStorage() {
248248
};
249249
}
250250

251+
// IP-based location, used when the browser's own geolocation fails
252+
// (common: OS location services disabled). City-level accuracy is
253+
// plenty for weather watchfaces. Cached per page load.
254+
let ipLocationCache = null;
255+
async function ipLocate(fetchFn) {
256+
if (ipLocationCache) return ipLocationCache;
257+
const r = await fetchFn('https://ipwho.is/');
258+
const j = await r.json();
259+
if (!j || j.success === false || typeof j.latitude !== 'number') {
260+
throw new Error('IP lookup returned no location');
261+
}
262+
ipLocationCache = { latitude: j.latitude, longitude: j.longitude, city: j.city };
263+
return ipLocationCache;
264+
}
265+
266+
function fakePosition(lat, lon, accuracy) {
267+
return {
268+
coords: { latitude: lat, longitude: lon, accuracy,
269+
altitude: null, altitudeAccuracy: null, heading: null, speed: null },
270+
timestamp: Date.now(),
271+
};
272+
}
273+
251274
// Browser sandbox: hidden same-origin iframe. The app JS gets the page's
252275
// real fetch/XHR (patched with the proxy fallback), geolocation, etc.
253-
export function makeIframeSandbox(proxyUrl, log = () => {}) {
276+
// fixedLoc: optional {latitude, longitude} override (?loc=lat,lon).
277+
export function makeIframeSandbox(proxyUrl, log = () => {}, fixedLoc = null) {
254278
return (globals) => {
255279
const frame = document.createElement('iframe');
256280
frame.style.display = 'none';
@@ -272,28 +296,42 @@ export function makeIframeSandbox(proxyUrl, log = () => {}) {
272296
}
273297
throw e;
274298
});
275-
// Log geolocation outcomes — a silent OS-level denial otherwise
276-
// looks identical to an app that never asked.
299+
// Geolocation with fallbacks: ?loc= override -> native -> IP-based.
300+
// An OS-level denial must not strand weather watchfaces.
277301
try {
278302
const geo = w.navigator.geolocation;
279-
const origGet = geo.getCurrentPosition.bind(geo);
280-
geo.getCurrentPosition = (ok, err, opts) => {
303+
// Use the TOP page's geolocation, not the iframe's — it removes
304+
// the permissions-delegation variable, and the permission grant
305+
// is attributed to the visible page either way.
306+
const pageGeo = window.navigator.geolocation;
307+
const origGet = pageGeo.getCurrentPosition.bind(pageGeo);
308+
const fallback = (ok, err, cause) => {
309+
log(`geolocation failed (${cause}); trying IP-based location…`);
310+
ipLocate(w.fetch).then((loc) => {
311+
log(`IP location: ~${loc.city || 'unknown'}`);
312+
ok(fakePosition(loc.latitude, loc.longitude, 25000));
313+
}).catch((e2) => {
314+
log('IP location failed too: ' + e2.message);
315+
if (err) err({ code: 2, message: cause });
316+
});
317+
};
318+
const resolvePosition = (ok, err, opts) => {
281319
log('app requested geolocation…');
320+
if (fixedLoc) {
321+
log(`using fixed location ${fixedLoc.latitude},${fixedLoc.longitude}`);
322+
ok(fakePosition(fixedLoc.latitude, fixedLoc.longitude, 10));
323+
return;
324+
}
282325
origGet(
283326
(pos) => { log(`geolocation ok (±${Math.round(pos.coords.accuracy)}m)`); ok(pos); },
284-
(e) => { log(`geolocation DENIED/failed: ${e.message} (code ${e.code})`); if (err) err(e); },
285-
opts,
286-
);
287-
};
288-
const origWatch = geo.watchPosition.bind(geo);
289-
geo.watchPosition = (ok, err, opts) => {
290-
log('app watching geolocation…');
291-
return origWatch(
292-
(pos) => { log('geolocation update'); ok(pos); },
293-
(e) => { log(`geolocation DENIED/failed: ${e.message} (code ${e.code})`); if (err) err(e); },
327+
(e) => fallback(ok, err, `${e.message} (code ${e.code})`),
294328
opts,
295329
);
296330
};
331+
geo.getCurrentPosition = resolvePosition;
332+
// Single-shot semantics are fine for watchfaces polling weather.
333+
geo.watchPosition = (ok, err, opts) => { resolvePosition(ok, err, opts); return 0; };
334+
geo.clearWatch = () => {};
297335
} catch (e) { /* geolocation unavailable in this context */ }
298336
return {
299337
run: (code) => w.eval(code),

tools/qemu-wasm/web/index.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,15 @@ <h1>PebbleOS Emulator</h1>
485485
const PKJS_PROXY = params2.get('pkjs_proxy') ||
486486
'https://pkjs-proxy.ericmigi.workers.dev/?url=';
487487

488+
// Optional fixed location for JS apps: ?loc=37.44,-122.14
489+
let PKJS_LOC = null;
490+
if (params2.get('loc')) {
491+
const m = params2.get('loc').split(',').map(Number);
492+
if (m.length === 2 && m.every(Number.isFinite)) {
493+
PKJS_LOC = { latitude: m[0], longitude: m[1] };
494+
}
495+
}
496+
488497
// Per-app persistent storage, scoped into the page's localStorage.
489498
function scopedStorage(prefix) {
490499
const p = 'pkjs-' + prefix + '-';
@@ -594,7 +603,7 @@ <h1>PebbleOS Emulator</h1>
594603
installer = new AppInstaller(phone, onProgress, (m) => say(m));
595604
const am = new AppMessageClient(phone, jsLog);
596605
pkjs = new PkjsRuntime(phone, am, {
597-
createSandbox: makeIframeSandbox(PKJS_PROXY, jsLog),
606+
createSandbox: makeIframeSandbox(PKJS_PROXY, jsLog, PKJS_LOC),
598607
storage: scopedStorage,
599608
tokenStore: window.localStorage,
600609
openUrl: openConfigUrl,

tools/qemu-wasm/web/pkjs-runtime.js

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,33 @@ export function makeMemoryStorage() {
248248
};
249249
}
250250

251+
// IP-based location, used when the browser's own geolocation fails
252+
// (common: OS location services disabled). City-level accuracy is
253+
// plenty for weather watchfaces. Cached per page load.
254+
let ipLocationCache = null;
255+
async function ipLocate(fetchFn) {
256+
if (ipLocationCache) return ipLocationCache;
257+
const r = await fetchFn('https://ipwho.is/');
258+
const j = await r.json();
259+
if (!j || j.success === false || typeof j.latitude !== 'number') {
260+
throw new Error('IP lookup returned no location');
261+
}
262+
ipLocationCache = { latitude: j.latitude, longitude: j.longitude, city: j.city };
263+
return ipLocationCache;
264+
}
265+
266+
function fakePosition(lat, lon, accuracy) {
267+
return {
268+
coords: { latitude: lat, longitude: lon, accuracy,
269+
altitude: null, altitudeAccuracy: null, heading: null, speed: null },
270+
timestamp: Date.now(),
271+
};
272+
}
273+
251274
// Browser sandbox: hidden same-origin iframe. The app JS gets the page's
252275
// real fetch/XHR (patched with the proxy fallback), geolocation, etc.
253-
export function makeIframeSandbox(proxyUrl, log = () => {}) {
276+
// fixedLoc: optional {latitude, longitude} override (?loc=lat,lon).
277+
export function makeIframeSandbox(proxyUrl, log = () => {}, fixedLoc = null) {
254278
return (globals) => {
255279
const frame = document.createElement('iframe');
256280
frame.style.display = 'none';
@@ -272,28 +296,42 @@ export function makeIframeSandbox(proxyUrl, log = () => {}) {
272296
}
273297
throw e;
274298
});
275-
// Log geolocation outcomes — a silent OS-level denial otherwise
276-
// looks identical to an app that never asked.
299+
// Geolocation with fallbacks: ?loc= override -> native -> IP-based.
300+
// An OS-level denial must not strand weather watchfaces.
277301
try {
278302
const geo = w.navigator.geolocation;
279-
const origGet = geo.getCurrentPosition.bind(geo);
280-
geo.getCurrentPosition = (ok, err, opts) => {
303+
// Use the TOP page's geolocation, not the iframe's — it removes
304+
// the permissions-delegation variable, and the permission grant
305+
// is attributed to the visible page either way.
306+
const pageGeo = window.navigator.geolocation;
307+
const origGet = pageGeo.getCurrentPosition.bind(pageGeo);
308+
const fallback = (ok, err, cause) => {
309+
log(`geolocation failed (${cause}); trying IP-based location…`);
310+
ipLocate(w.fetch).then((loc) => {
311+
log(`IP location: ~${loc.city || 'unknown'}`);
312+
ok(fakePosition(loc.latitude, loc.longitude, 25000));
313+
}).catch((e2) => {
314+
log('IP location failed too: ' + e2.message);
315+
if (err) err({ code: 2, message: cause });
316+
});
317+
};
318+
const resolvePosition = (ok, err, opts) => {
281319
log('app requested geolocation…');
320+
if (fixedLoc) {
321+
log(`using fixed location ${fixedLoc.latitude},${fixedLoc.longitude}`);
322+
ok(fakePosition(fixedLoc.latitude, fixedLoc.longitude, 10));
323+
return;
324+
}
282325
origGet(
283326
(pos) => { log(`geolocation ok (±${Math.round(pos.coords.accuracy)}m)`); ok(pos); },
284-
(e) => { log(`geolocation DENIED/failed: ${e.message} (code ${e.code})`); if (err) err(e); },
285-
opts,
286-
);
287-
};
288-
const origWatch = geo.watchPosition.bind(geo);
289-
geo.watchPosition = (ok, err, opts) => {
290-
log('app watching geolocation…');
291-
return origWatch(
292-
(pos) => { log('geolocation update'); ok(pos); },
293-
(e) => { log(`geolocation DENIED/failed: ${e.message} (code ${e.code})`); if (err) err(e); },
327+
(e) => fallback(ok, err, `${e.message} (code ${e.code})`),
294328
opts,
295329
);
296330
};
331+
geo.getCurrentPosition = resolvePosition;
332+
// Single-shot semantics are fine for watchfaces polling weather.
333+
geo.watchPosition = (ok, err, opts) => { resolvePosition(ok, err, opts); return 0; };
334+
geo.clearWatch = () => {};
297335
} catch (e) { /* geolocation unavailable in this context */ }
298336
return {
299337
run: (code) => w.eval(code),

0 commit comments

Comments
 (0)