Require signed PWA auth for findmydevice

This commit is contained in:
seo committed 2026-08-03 05:06:31 +09:00
1 parent 1ba8c257a3
commit 76f9c7c549
3 files changed
+346 -56

No files matched your search

+251 -24
View File
@@ -30,6 +30,18 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
batteryLevel: "배터리 잔량",
watchbatteryState: "워치 배터리 상태",
watchbatteryLevel: "워치 배터리 잔량",
watch: "워치",
watchBatteryState: "워치 배터리 상태",
watchBatteryLevel: "워치 배터리 잔량",
watchChargerType: "워치 충전 방식",
watchDeviceState: "워치 위치 상태",
watchInteractive: "워치 화면 활성",
watchPowerSave: "워치 절전 모드",
watchDozeMode: "워치 Doze 모드",
watchBedtimeMode: "워치 취침 모드",
watchTheaterMode: "워치 극장 모드",
watchWetMode: "워치 방수 모드",
watchOnBodySensor: "워치 착용 감지",
isCharging: "충전 여부",
chargerType: "충전 방식",
batteryHealth: "배터리 건강",
@@ -39,6 +51,7 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
batteryCycleCount: "배터리 사이클",
activity: "활동",
currentActivity: "현재 활동",
activityState: "활동 상태",
activityAccuracy: "활동 정확도",
distance: "도보로 움직인 거리",
steps: "걸음 수",
@@ -55,6 +68,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
phoneState: "전화 상태",
interactive: "화면 활성",
dozeMode: "Doze 모드",
bedtimeMode: "취침 모드",
theaterMode: "극장 모드",
wetMode: "방수 모드",
onBodySensor: "착용 감지",
powerSave: "절전 모드",
deviceLocked: "잠금 상태",
network: "네트워크",
@@ -138,6 +155,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
height: "키",
dailyHydration: "일일 수분",
dailyElevationGained: "일일 고도 상승",
dailyCalories: "일일 칼로리",
dailyDistance: "일일 이동 거리",
dailyFloors: "일일 오른 층",
dailySteps: "일일 걸음 수",
stepsSensor: "걸음 센서",
androidAuto: "Android Auto",
carName: "차량 이름",
@@ -194,6 +215,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
historyStay: "체류",
historyStops: "체류 지점",
historyFiltered: "GPS 튐 보정",
historyTrackGap: "추적 끊김",
historyTrackGapCount: "끊김",
historyTrackGapUnit: "개",
historyTrackGapNotice: "이 구간에서는 고정밀 위치 추적이 이어지지 않았습니다.",
historyTimeline: "시간 위치",
speedLegend: "속도",
speedStill: "정지",
@@ -241,6 +266,18 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
batteryLevel: "Battery Level",
watchbatteryState: "Watch Battery State",
watchbatteryLevel: "Watch Battery Level",
watch: "Watch",
watchBatteryState: "Watch Battery State",
watchBatteryLevel: "Watch Battery Level",
watchChargerType: "Watch Charger Type",
watchDeviceState: "Watch Location State",
watchInteractive: "Watch Interactive",
watchPowerSave: "Watch Power Save",
watchDozeMode: "Watch Doze Mode",
watchBedtimeMode: "Watch Bedtime Mode",
watchTheaterMode: "Watch Theater Mode",
watchWetMode: "Watch Water Lock",
watchOnBodySensor: "Watch On Body",
isCharging: "Charging",
chargerType: "Charger Type",
batteryHealth: "Battery Health",
@@ -250,6 +287,7 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
batteryCycleCount: "Battery Cycles",
activity: "Activity",
currentActivity: "Current Activity",
activityState: "Activity State",
activityAccuracy: "Activity Confidence",
distance: "Distance walked",
steps: "Steps",
@@ -266,6 +304,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
phoneState: "Phone State",
interactive: "Interactive",
dozeMode: "Doze Mode",
bedtimeMode: "Bedtime Mode",
theaterMode: "Theater Mode",
wetMode: "Water Lock",
onBodySensor: "On Body",
powerSave: "Power Save",
deviceLocked: "Device Locked",
network: "Network",
@@ -349,6 +391,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
height: "Height",
dailyHydration: "Daily Hydration",
dailyElevationGained: "Daily Elevation Gained",
dailyCalories: "Daily Calories",
dailyDistance: "Daily Distance",
dailyFloors: "Daily Floors",
dailySteps: "Daily Steps",
stepsSensor: "Step Sensor",
androidAuto: "Android Auto",
carName: "Car Name",
@@ -405,6 +451,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
historyStay: "Stay",
historyStops: "Stops",
historyFiltered: "GPS Filter",
historyTrackGap: "Tracking Gap",
historyTrackGapCount: "Gaps",
historyTrackGapUnit: "",
historyTrackGapNotice: "High accuracy location tracking was not continuous in this segment.",
historyTimeline: "Timeline",
speedLegend: "Speed",
speedStill: "Still",
@@ -466,6 +516,7 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
homeCenters: [],
rawHistoryPoints: [],
historyPoints: [],
trackGaps: [],
historyRawCount: null,
historyRenderCount: null,
historyStats: null,
@@ -514,10 +565,11 @@ const BACK_LINK_PATH_TITLE = {
'/blog': '블로그',
};
const FINDMYDEVICE_AUTH_REQUIRED = location.pathname.startsWith('/findmydevice');
const FINDMYDEVICE_PWA_SERVICE = 'findmydevice';
function isStandalonePwa() {
return window.navigator.standalone || window.matchMedia('(display-mode: standalone)').matches;
}
let findmydeviceAuthenticated = !FINDMYDEVICE_AUTH_REQUIRED || isStandalonePwa();
let findmydeviceAuthenticated = !FINDMYDEVICE_AUTH_REQUIRED;
function pad2(value) {
return String(value).padStart(2, '0');
@@ -583,6 +635,7 @@ function renderAuthErrorPage() {
placeholder: '비밀번호',
backLabel: '채건닷컴',
backHref: 'https://chaegeon.com/',
pwaService: isStandalonePwa() ? FINDMYDEVICE_PWA_SERVICE : '',
onSuccess: () => location.reload(),
});
if (window.CustomAuthPin) {
@@ -596,11 +649,14 @@ function renderAuthErrorPage() {
}
const findmydeviceAuthPromise = FINDMYDEVICE_AUTH_REQUIRED
&& !isStandalonePwa()
? fetch('/custom/launcher/api.php?session=1', { credentials: 'same-origin' })
? fetch(`/custom/launcher/api.php?session=1&pwaService=${encodeURIComponent(FINDMYDEVICE_PWA_SERVICE)}`, { credentials: 'same-origin' })
.then(res => res.ok ? res.json() : Promise.reject())
.then(session => {
findmydeviceAuthenticated = !!session.authenticated;
if (session.loginBlocked) {
location.replace('https://chaegeon.com/');
return false;
}
findmydeviceAuthenticated = !!session.authenticated || (isStandalonePwa() && !!session.pwaAuthenticated);
return findmydeviceAuthenticated;
})
.catch(() => {
@@ -770,8 +826,8 @@ function ensureFindMyDeviceShell() {
<div class="settingTitle" data-live-status-section="1"><span id="titleBattery"></span></div>
<div class="settingSection" id="batterySection" data-live-status-section="1">
<div class="settingRow"><span id="labelBatteryState"></span><span id="batteryStatus"></span></div>
<div class="settingRow"><span id="labelBatteryLevel"></span><span id="Battery"></span></div>
<div class="settingRow"><span id="labelWatchBatteryState"></span><span id="WbatteryStatus"></span></div>
<div class="settingRow"><span id="labelBatteryLevel"></span><span id="Battery"></span></div>
<div class="settingRow"><span id="labelWatchBatteryLevel"></span><span id="WBattery"></span></div>
</div>
<div class="settingTitle" data-live-status-section="1"><span id="titleActivity"></span></div>
@@ -1338,6 +1394,16 @@ function cleanupLegacyConnectionRow() {
}
}
function sensorLabel(t, key) {
if (t[key]) return t[key];
if (key.startsWith('watch') && key.length > 5) {
const baseKey = key.charAt(5).toLowerCase() + key.slice(6);
const baseLabel = t[baseKey] || baseKey;
return (t.watch || 'Watch') + ' ' + baseLabel;
}
return key;
}
function updateSensorSections(sections) {
const t = translations[langCode] || translations.ko;
const activeSectionIds = new Set();
@@ -1359,7 +1425,7 @@ function updateSensorSections(sections) {
entries.forEach(([key, value]) => {
activeRowIds.add(`sensor_${sectionKey}_${key}`);
upsertOptionalRow(section, `sensor_${sectionKey}_${key}`, t[key] || key, value);
upsertOptionalRow(section, `sensor_${sectionKey}_${key}`, sensorLabel(t, key), value);
});
});
@@ -1515,6 +1581,7 @@ function initMap(latlng, accuracy = 50) {
ensureMapLayers();
naver.maps.Event.addListener(mapState.map, 'zoom_changed', () => {
renderStayMarkers();
renderTrackGapMarkers(mapState.trackGaps);
if (mapState.map.getZoom() < CURRENT_STAY_MIN_ZOOM) {
stopCurrentStayBadge();
setStayMarkersVisibleForCurrentPoint(
@@ -1798,6 +1865,9 @@ const HOME_RADIUS_M = 50;
const HOME_JITTER_ABSORB_RADIUS_M = 1200;
const HOME_JITTER_SAMPLE_SECONDS = 30 * 60;
const TRACK_MAX_GAP_SECONDS = 30 * 60;
const TRACK_MAX_GAP_DISTANCE_M = 300;
const TRACK_GAP_MARKER_MIN_ZOOM = 12;
const TRACK_GAP_SINGLE_MIN_ZOOM = 15;
const AWAY_MAX_GAP_SECONDS = 2 * 60 * 60;
function normalizeHomeConfig(home) {
@@ -1839,16 +1909,33 @@ function pointWithLatLng(point, lat, lng, extra = {}) {
};
}
function normalizeHomeJitterPoint(point) {
function isSameHome(homeA, homeB) {
return !!homeA && !!homeB && homeA.lat === homeB.lat && homeA.lng === homeB.lng;
}
function isTemporaryHomeJitter(prev, cur, next, nearest) {
if (!prev || !cur || !next || !nearest?.home) return false;
if (nearest.distance <= nearest.home.radius) return true;
const prevHome = nearestHome(prev);
const nextHome = nearestHome(next);
if (!isSameHome(prevHome.home, nearest.home) || !isSameHome(nextHome.home, nearest.home)) return false;
if (prevHome.distance > prevHome.home.radius || nextHome.distance > nextHome.home.radius) return false;
const spanSeconds = Math.max(0, (toTimestamp(next.time) - toTimestamp(prev.time)) / 1000);
return spanSeconds <= HOME_JITTER_SAMPLE_SECONDS;
}
function normalizeHomeJitterPoint(point, prevRaw = null, nextRaw = null) {
if (!mapState.filterOutliers || !mapState.homeCenters?.length || !point) return point;
const nearest = nearestHome(point);
const distance = nearest.distance;
if (distance > HOME_JITTER_ABSORB_RADIUS_M) return point;
if (nearest.distance > HOME_JITTER_ABSORB_RADIUS_M) return point;
if (nearest.distance > nearest.home.radius && !isTemporaryHomeJitter(prevRaw, point, nextRaw, nearest)) return point;
return pointWithLatLng(point, nearest.home.lat, nearest.home.lng, {
homeJitter: true,
originalLat: point.lat,
originalLng: point.lng,
originalDistanceFromHome: distance,
originalDistanceFromHome: nearest.distance,
});
}
@@ -1870,14 +1957,14 @@ function isSinglePointSpike(prev, cur, next) {
function filterHistoryOutliers(points) {
if (!mapState.filterOutliers || points.length < 3) return points;
const filtered = [normalizeHomeJitterPoint(points[0])];
const filtered = [normalizeHomeJitterPoint(points[0], null, points[1])];
for (let i = 1; i < points.length; i++) {
if (i < points.length - 1 && isSinglePointSpike(points[i - 1], points[i], points[i + 1])) {
continue;
}
const prev = filtered[filtered.length - 1];
const cur = normalizeHomeJitterPoint(points[i]);
const cur = normalizeHomeJitterPoint(points[i], points[i - 1], points[i + 1] || null);
const seconds = Math.max(1, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
const distance = distanceMeters(prev, cur);
const kmh = distance / seconds * 3.6;
@@ -2020,7 +2107,6 @@ function classifyHomePoint(point) {
const nearest = nearestHome(point);
if (!nearest.home) return 'away';
if (nearest.distance <= nearest.home.radius) return 'home';
if (mapState.filterOutliers && nearest.distance <= HOME_JITTER_ABSORB_RADIUS_M) return 'home';
return 'away';
}
@@ -2112,8 +2198,9 @@ function buildHistoryStats(points) {
let maxGap = 0;
for (let i = 1; i < points.length; i++) {
const gap = Math.max(0, (toTimestamp(points[i].time) - toTimestamp(points[i - 1].time)) / 1000);
const trackGap = buildTrackGap(points[i - 1], points[i]);
maxGap = Math.max(maxGap, gap);
if (gap <= TRACK_MAX_GAP_SECONDS) {
if (!trackGap) {
distance += distanceMeters(points[i - 1], points[i]);
}
}
@@ -2182,7 +2269,9 @@ function buildHistoryInfoContent(point, type, label) {
const index = mapState.historyPoints.findIndex(item => item.time === point.time && item.lat === point.lat && item.lng === point.lng);
let fromStart = 0;
for (let i = 1; i <= index; i++) {
fromStart += distanceMeters(mapState.historyPoints[i - 1], mapState.historyPoints[i]);
if (!buildTrackGap(mapState.historyPoints[i - 1], mapState.historyPoints[i])) {
fromStart += distanceMeters(mapState.historyPoints[i - 1], mapState.historyPoints[i]);
}
}
const typeClass = type === 'start' ? 'start' : (type === 'end' ? 'end' : 'point');
return `
@@ -2212,6 +2301,49 @@ function buildStayInfoContent(stay) {
`;
}
function buildTrackGap(prev, cur) {
if (!prev || !cur) return null;
const seconds = Math.max(0, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
const distance = distanceMeters(prev, cur);
if (seconds <= TRACK_MAX_GAP_SECONDS && distance <= TRACK_MAX_GAP_DISTANCE_M) return null;
const lat = (Number(prev.lat) + Number(cur.lat)) / 2;
const lng = (Number(prev.lng) + Number(cur.lng)) / 2;
return { from: prev, to: cur, seconds, distance, lat, lng };
}
function shouldShowTrackGapMarker(gap) {
if (!gap || !hasValidCoords(gap.lat, gap.lng)) return false;
if (isHomePoint(gap) || isHomePoint(gap.from) || isHomePoint(gap.to)) return false;
return true;
}
function buildTrackGapInfoContent(gapGroup) {
const t = translations[langCode] || translations.ko;
const gaps = Array.isArray(gapGroup?.gaps) && gapGroup.gaps.length ? gapGroup.gaps : [gapGroup];
const first = gaps[0];
const last = gaps[gaps.length - 1];
const maxDistance = gaps.reduce((max, gap) => Math.max(max, Number(gap.distance) || 0), 0);
const maxSeconds = gaps.reduce((max, gap) => Math.max(max, Number(gap.seconds) || 0), 0);
const samples = gaps.slice(0, 4).map((gap, index) => `
<span>${index + 1}. ${formatDateTime(gap.from.time)} - ${formatDateTime(gap.to.time)} · ${formatDistance(gap.distance)} · ${formatDuration(Math.round(gap.seconds))}</span>
`).join('');
const more = gaps.length > 4 ? `<span>+${formatNumber(gaps.length - 4)}</span>` : '';
const countText = langCode === 'ko'
? `${t.historyTrackGapCount} ${formatNumber(gaps.length)}${t.historyTrackGapUnit}`
: `${formatNumber(gaps.length)} ${t.historyTrackGapCount}`;
return `
<div class="history-info-window history-info-gap">
<strong>${gaps.length > 1 ? countText : t.historyTrackGap}</strong>
<span>${t.historyTrackGapNotice}</span>
<span>${formatDateTime(first.from.time)} - ${formatDateTime(last.to.time)}</span>
<span>${formatDistance(maxDistance)} · ${formatDuration(Math.round(maxSeconds))}</span>
${samples}
${more}
</div>
`;
}
function fitHistoryOrCurrentBounds() {
if (!mapState.map) return;
if (mapState.historyPoints?.length) {
@@ -2228,6 +2360,23 @@ function fitHistoryOrCurrentBounds() {
}
}
function fitPointBounds(points, fallbackPoint = null, maxZoom = 15) {
if (!mapState.map) return;
const coords = (Array.isArray(points) ? points : [])
.filter(point => hasValidCoords(point.lat, point.lng))
.map(point => new naver.maps.LatLng(point.lat, point.lng));
if (coords.length >= 2) {
mapState.map.fitBounds(coords, { top: 60, right: 48, bottom: 140, left: 48, maxZoom });
return;
}
const point = coords[0] || (fallbackPoint && hasValidCoords(fallbackPoint.lat, fallbackPoint.lng)
? new naver.maps.LatLng(fallbackPoint.lat, fallbackPoint.lng)
: null);
if (!point) return;
mapState.map.panTo(point);
mapState.map.setZoom(Math.max(mapState.map.getZoom(), Math.min(mapState.map.getZoom() + 2, maxZoom)));
}
function stopHistoryPlayback(restoreCurrent = true) {
if (mapState.playbackTimer) {
clearInterval(mapState.playbackTimer);
@@ -2338,16 +2487,25 @@ function clearTrackLines() {
function drawSpeedTrack(points) {
clearTrackLines();
if (!points?.length || points.length < 2) return;
if (mapState.smallMarkers?.length) {
mapState.smallMarkers.forEach(marker => marker.setMap(null));
mapState.smallMarkers = [];
}
if (!points?.length || points.length < 2) {
mapState.trackGaps = [];
return;
}
const segments = [];
const trackGaps = [];
let segment = [points[0]];
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const cur = points[i];
const gap = Math.max(0, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
if (gap > TRACK_MAX_GAP_SECONDS) {
const gap = buildTrackGap(prev, cur);
if (gap) {
if (segment.length >= 2) segments.push(segment);
if (shouldShowTrackGapMarker(gap)) trackGaps.push(gap);
segment = [cur];
continue;
}
@@ -2369,6 +2527,79 @@ function drawSpeedTrack(points) {
mapState.trackLines.push(line);
if (!mapState.trackLine) mapState.trackLine = line;
});
mapState.trackGaps = trackGaps;
renderTrackGapMarkers(mapState.trackGaps);
}
function trackGapClusterRadiusForZoom(zoom) {
if (zoom >= 16) return 35;
if (zoom >= 14) return 90;
if (zoom >= 12) return 250;
if (zoom >= 10) return 900;
return 2500;
}
function buildTrackGapGroups(gaps) {
const radius = trackGapClusterRadiusForZoom(mapState.map?.getZoom?.() || 12);
const groups = [];
gaps.forEach(gap => {
if (!hasValidCoords(gap.lat, gap.lng)) return;
const group = groups.find(item => distanceMeters(item, gap) <= radius);
if (group) {
const oldCount = group.gaps.length;
const nextCount = oldCount + 1;
group.lat = ((group.lat * oldCount) + Number(gap.lat)) / nextCount;
group.lng = ((group.lng * oldCount) + Number(gap.lng)) / nextCount;
group.gaps.push(gap);
} else {
groups.push({ lat: Number(gap.lat), lng: Number(gap.lng), gaps: [gap] });
}
});
return groups;
}
function renderTrackGapMarkers(gaps) {
if (mapState.smallMarkers?.length) {
mapState.smallMarkers.forEach(marker => marker.setMap(null));
mapState.smallMarkers = [];
}
if (!mapState.map || !Array.isArray(gaps) || !gaps.length) return;
const zoom = mapState.map.getZoom?.() || 12;
if (zoom < TRACK_GAP_MARKER_MIN_ZOOM) return;
const t = translations[langCode] || translations.ko;
buildTrackGapGroups(gaps).forEach(group => {
const count = group.gaps.length;
if (count === 1 && zoom < TRACK_GAP_SINGLE_MIN_ZOOM) return;
const countLabel = langCode === 'ko'
? `${formatNumber(count)}${t.historyTrackGapUnit}`
: formatNumber(count);
const marker = new naver.maps.Marker({
position: new naver.maps.LatLng(group.lat, group.lng),
map: mapState.mapLayerState.history ? mapState.map : null,
zIndex: count > 1 ? 72 : 70,
icon: {
content: `<div class="history-marker history-marker-stay">${count > 1 ? countLabel : t.historyTrackGap}</div>`,
anchor: new naver.maps.Point(count > 1 ? 28 : 34, 14)
}
});
marker.infoWindow = new naver.maps.InfoWindow({
content: buildTrackGapInfoContent(group)
});
naver.maps.Event.addListener(marker, "click", () => {
if (count > 1) {
fitPointBounds(group.gaps.flatMap(gap => [gap.from, gap.to]), group, 15);
return;
}
if (mapState.openInfoWindow && mapState.openInfoWindow !== marker.infoWindow) {
mapState.openInfoWindow.close();
}
marker.infoWindow.open(mapState.map, marker);
mapState.openInfoWindow = marker.infoWindow;
});
mapState.smallMarkers.push(marker);
});
}
function updateTimelinePanel() {
@@ -2651,6 +2882,7 @@ function resetHistoryLayer() {
}
mapState.rawHistoryPoints = [];
mapState.historyPoints = [];
mapState.trackGaps = [];
mapState.historyRawCount = null;
mapState.historyRenderCount = null;
mapState.historyStats = null;
@@ -2743,8 +2975,7 @@ function renderStayMarkers() {
marker.stayPoints = group.stays;
if (isCluster) {
naver.maps.Event.addListener(marker, "click", () => {
mapState.map.panTo(marker.getPosition());
mapState.map.setZoom(Math.max(mapState.map.getZoom() + 2, STAY_CLUSTER_MIN_ZOOM));
fitPointBounds(group.stays, group, STAY_CLUSTER_MIN_ZOOM);
});
} else {
const stay = group.stays[0];
@@ -2783,10 +3014,6 @@ function renderHistoryPoints(rawPoints) {
renderHistorySummary();
updateTimelinePanel();
if (mapState.smallMarkers?.length) {
mapState.smallMarkers.forEach(marker => marker.setMap(null));
mapState.smallMarkers = [];
}
if (mapState.historyStartMarker) {
mapState.historyStartMarker.setMap(null);
mapState.historyStartMarker = null;