Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

1140 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const frame = document.getElementById('demo-frame');
const bootBtnHTML = frame.innerHTML;
const offBtn = document.getElementById('demo-poweroff-btn');
const studioStatus = document.getElementById('studio-status');
const studioTabs = Array.from(document.querySelectorAll('[data-studio-tab]'));
const studioPanels = Array.from(document.querySelectorAll('[data-studio-panel]'));
const studioActionTabs = Array.from(document.querySelectorAll('[data-studio-action]'));
const studioActionPanels = Array.from(document.querySelectorAll('[data-studio-action-panel]'));
const languageChoices = Array.from(document.querySelectorAll('[data-language-choice]'));
const SNAPSHOT_STORAGE_KEY = 'mobilegym_demo_snapshots_v1';
const LOCALE_STORAGE_KEY = 'mobilegym_demo_locale_v1';
const MAX_SNAPSHOTS = 8;
function $(id) {
return document.getElementById(id);
}
function setStudioStatus(message, kind) {
studioStatus.textContent = message;
studioStatus.dataset.kind = kind || 'warn';
}
function escapeHTML(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function cleanText(value, fallback, maxLen) {
const text = String(value || '').trim().replace(/\s+/g, ' ');
return (text || fallback).slice(0, maxLen || 120);
}
function wechatAsset(value) {
const path = cleanText(value, 'avatars/avatar_default.jpg', 120);
if (path.startsWith('http') || path.startsWith('/@app-assets/')) return path;
return `/@app-assets/Wechat/${path.replace(/^\/+/, '')}`;
}
function money(value, fallback) {
const parsed = Number.parseFloat(String(value));
if (!Number.isFinite(parsed)) return fallback;
return Math.round(parsed * 100) / 100;
}
function clampNumber(value, min, max, fallback) {
const parsed = Number.parseFloat(String(value));
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function getDemoIframe() {
return frame.querySelector('iframe');
}
function getDemoWindow() {
const iframe = getDemoIframe();
if (!iframe) return null;
try {
return iframe.contentWindow;
} catch {
return null;
}
}
function getStateTime(state) {
const ts = Number(state?.os?.time?.timestamp);
return Number.isFinite(ts) ? ts : Date.now();
}
function toLocalDatetimeInput(timestamp) {
const date = new Date(timestamp);
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function toShortTime(timestamp) {
const date = new Date(timestamp);
const pad = (n) => String(n).padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function cloneJSON(value) {
return JSON.parse(JSON.stringify(value));
}
function readSnapshots() {
try {
const parsed = JSON.parse(localStorage.getItem(SNAPSHOT_STORAGE_KEY) || '[]');
return Array.isArray(parsed)
? parsed.filter((item) => item?.id && item?.state)
: [];
} catch {
return [];
}
}
function writeSnapshots(snapshots) {
const compacted = compactSnapshotsForStorage(snapshots);
let lastError = null;
for (let count = compacted.length; count >= 1; count -= 1) {
try {
localStorage.setItem(SNAPSHOT_STORAGE_KEY, JSON.stringify(compacted.slice(0, count)));
return;
} catch (error) {
lastError = error;
}
}
throw lastError || new Error('Could not save snapshots.');
}
function compactRedBookState(redbook) {
if (!redbook || typeof redbook !== 'object') return redbook;
const compact = { ...redbook };
delete compact.entities;
delete compact.feedIds;
delete compact.userIds;
return compact;
}
function compactSnapshotState(state) {
const compact = cloneJSON(state || {});
if (compact.apps?.redbook) {
compact.apps.redbook = compactRedBookState(compact.apps.redbook);
}
return compact;
}
function compactSnapshotsForStorage(snapshots) {
return snapshots.slice(0, MAX_SNAPSHOTS).map((item) => ({
...item,
state: compactSnapshotState(item.state),
}));
}
function refreshSnapshotSelect(selectedId) {
const select = $('studio-snapshot-select');
const snapshots = readSnapshots();
if (!snapshots.length) {
select.innerHTML = '<option value="">No snapshots yet</option>';
return;
}
select.innerHTML = snapshots.map((item) => {
const created = Number.isFinite(Number(item.createdAt)) ? toShortTime(Number(item.createdAt)) : '--:--';
return `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name || 'Snapshot')} · ${created}</option>`;
}).join('');
if (selectedId && snapshots.some((item) => item.id === selectedId)) {
select.value = selectedId;
}
}
function applySnapshotTime(win, snapshot) {
const api = win.__SIM_TIME__;
const cfg = snapshot?.timeConfig;
if (!api || !cfg) return;
if (cfg.mode === 'real') {
api.setRealTime?.();
api.setSpeed?.(1);
return;
}
const fallback = getStateTime(snapshot.state);
api.setSimulatedTime?.(cfg.simulatedTime || fallback, cfg.flowing !== false);
api.setSpeed?.(cfg.flowing === false ? 1 : Number(cfg.speed) || 1);
}
function applySnapshotLocation(win, snapshot) {
const api = win.__SIM_LOCATION__;
const cfg = snapshot?.locationConfig;
if (!api || !cfg) return;
if (cfg.mode === 'real') {
api.setRealLocation?.();
return;
}
if (cfg.simulatedLocation) {
api.setSimulatedLocation?.(cfg.simulatedLocation);
return;
}
if (snapshot.locationCoords) {
api.setSimulatedLocation?.(snapshot.locationCoords);
}
}
function isHydratedRedBookSnapshot(redbook) {
const notesById = redbook?.entities?.notesById;
return Boolean(
redbook &&
notesById &&
typeof notesById === 'object' &&
Object.keys(notesById).length > 0 &&
Array.isArray(redbook.feedIds) &&
redbook.feedIds.length > 0,
);
}
async function waitForSnapshotData(win) {
if (!win.__SIM__?.waitForData) return;
try {
await win.__SIM__.waitForData(['redbook']);
} catch {
// 数据加载失败时仍允许保存当前状态;状态栏会提示后续 restore 的实际结果。
}
}
function readLocalDatetimeInput(id, fallbackTs) {
const raw = String($(id).value || '').trim();
if (!raw) return fallbackTs;
const parsed = new Date(raw).getTime();
return Number.isFinite(parsed) ? parsed : fallbackTs;
}
function setDeviceTimeFlowOptions(mode, value) {
const select = $('studio-device-time-flow');
$('studio-device-time-value-field').hidden = mode !== 'simulated';
const options = mode === 'real'
? [{ value: 'flow', label: 'Flow' }]
: [
{ value: 'paused', label: 'Paused' },
{ value: '1', label: '1x' },
{ value: '10', label: '10x' },
{ value: '100', label: '100x' },
];
select.innerHTML = options
.map((item) => `<option value="${item.value}">${item.label}</option>`)
.join('');
select.value = options.some((item) => item.value === value)
? value
: mode === 'real'
? 'flow'
: '1';
}
function syncLocationCustomVisibility() {
$('studio-device-location-custom').hidden = $('studio-device-location-preset').value !== 'custom';
}
function selectedWechatAvatar() {
const active = document.querySelector('#studio-wechat-avatar-picker .avatar-choice[aria-pressed="true"]');
return active?.dataset?.avatar || 'avatars/avatar_default.jpg';
}
function waitForSimulator(timeoutMs) {
const startedAt = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
const win = getDemoWindow();
if (win?.__SIM__?.getState && win?.__SIM__?.setState && win?.__OS__) {
resolve(win);
return;
}
if (Date.now() - startedAt > (timeoutMs || 12000)) {
reject(new Error('Simulator is not ready yet.'));
return;
}
window.setTimeout(tick, 120);
};
tick();
});
}
async function withSimulator(action) {
try {
const win = await waitForSimulator();
return await action(win, win.__SIM__.getState());
} catch (error) {
setStudioStatus(error?.message || 'Power on the phone first.', 'error');
throw error;
}
}
function syncStudioFromState() {
const win = getDemoWindow();
if (!win?.__SIM__?.getState) return;
const state = win.__SIM__.getState();
const timeApi = win.__SIM_TIME__;
if (timeApi?.getConfig) {
const cfg = timeApi.getConfig();
const mode = cfg.mode === 'simulated' ? 'simulated' : 'real';
$('studio-device-time-mode').value = mode;
const flow = mode === 'real'
? 'flow'
: cfg.flowing === false
? 'paused'
: Number(cfg.speed) >= 100
? '100'
: Number(cfg.speed) >= 10
? '10'
: '1';
setDeviceTimeFlowOptions(mode, flow);
if (!$('studio-device-time-value').value) {
$('studio-device-time-value').value = toLocalDatetimeInput(timeApi.now ? timeApi.now() : getStateTime(state));
}
}
const battery = state?.os?.hardware?.battery;
if (battery && typeof battery === 'object') {
$('studio-device-battery-level').value = String(
clampNumber(battery.percent, 0, 100, 80),
);
$('studio-device-battery-status').value = battery.fastCharging
? 'fast'
: battery.charging
? 'charging'
: 'unplugged';
}
const batterySaver = state?.os?.settings?.global?.batterySaverEnabled;
if (typeof batterySaver === 'boolean') {
$('studio-device-battery-saver').value = batterySaver ? 'on' : 'off';
}
const locationApi = win.__SIM_LOCATION__;
if (locationApi?.getConfig) {
const coords = locationApi.getCoords?.();
if (coords) {
$('studio-device-location-lat').value = String(coords.latitude);
$('studio-device-location-lon').value = String(coords.longitude);
}
syncLocationCustomVisibility();
}
const wechat = state?.apps?.wechat || {};
const contacts = Array.isArray(wechat.contacts) ? wechat.contacts : [];
const select = $('studio-wechat-contact');
const current = select.value;
const options = contacts
.filter((item) => item?.wxid && item?.name)
.map((item) => ({
wxid: String(item.wxid),
name: String(item.name),
}));
select.innerHTML = options.length
? options.map((item) => `<option value="${item.wxid.replace(/"/g, '&quot;')}">${item.name}</option>`).join('')
: '<option value="">陈静</option>';
if (current && options.some((item) => item.wxid === current)) select.value = current;
else {
const chenJing = options.find((item) => item.name === '陈静');
if (chenJing) select.value = chenJing.wxid;
}
if (!$('studio-wechat-time').value) {
$('studio-wechat-time').value = toLocalDatetimeInput(getStateTime(state));
}
}
function switchStudioTab(name) {
studioTabs.forEach((tab) => {
tab.setAttribute('aria-selected', tab.dataset.studioTab === name ? 'true' : 'false');
});
studioPanels.forEach((panel) => {
panel.dataset.active = panel.dataset.studioPanel === name ? 'true' : 'false';
});
const activePanel = studioPanels.find((panel) => panel.dataset.studioPanel === name);
const firstAction = activePanel?.querySelector('[data-studio-action]')?.dataset?.studioAction;
if (firstAction) switchStudioAction(firstAction);
const labels = {
session: 'Reset, save, or restore simulator snapshots in this browser.',
language: 'Switch the simulated phone language. Resets per-app overrides.',
device: 'Choose time, battery, or location for the simulated device.',
wechat: 'Choose message or contact data for WeChat.',
alipay: 'Choose balance or bill data for Alipay.',
sms: 'Choose contact or incoming SMS data.',
railway: 'Add a train ticket/order for 12306.',
};
setStudioStatus(labels[name] || 'Pick a scenario to apply.', 'warn');
}
function switchStudioAction(name) {
studioActionTabs.forEach((tab) => {
tab.setAttribute('aria-selected', tab.dataset.studioAction === name ? 'true' : 'false');
});
studioActionPanels.forEach((panel) => {
panel.dataset.active = panel.dataset.studioActionPanel === name ? 'true' : 'false';
});
}
// ============================================================
// Phone language toggle
// - Switches OS-level locale (settings.global.language) AND
// resets per-app language overrides for the four apps with
// their own override layer (Alipay / Bilibili / RedBook / Map).
// - Without resetting overrides, those apps stay Chinese even
// when the OS is flipped to English (their defaults are
// non-null Chinese values that override the system locale).
// - Persisted in localStorage so the choice survives reloads
// and is re-applied each time the simulator boots.
// ============================================================
// Returns 'en' / 'zh-Hans' for an explicit user choice, or null when the
// visitor has never picked a language. The distinction matters: on first
// boot we must NOT push any patch, otherwise we'd overwrite each app's
// natural default (Map's null=follow-OS, Alipay's 'zh-CN', etc.) with a
// generic 'zh' override.
function readStoredLocale() {
try {
const raw = localStorage.getItem(LOCALE_STORAGE_KEY);
if (raw === 'en' || raw === 'zh-Hans') return raw;
return null;
} catch {
return null;
}
}
function writeStoredLocale(locale) {
try {
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
} catch { /* private mode */ }
}
function updateLanguageUI(locale) {
const display = locale || 'zh-Hans';
const badge = $('state-dock-language-badge');
if (badge) badge.textContent = display === 'en' ? 'EN' : '中';
languageChoices.forEach((btn) => {
btn.setAttribute('aria-checked', btn.dataset.languageChoice === display ? 'true' : 'false');
});
}
function buildLocaleAppPatch(locale) {
if (locale === 'en') {
return {
alipay: { language: 'en' },
bilibili: { settings: { language: 'en' } },
redbook: { settings: { language: 'en-US' } },
map: { settings: { appDisplay: { language: 'en' } } },
};
}
// zh-Hans → write explicit Chinese values. Can't use null here:
// __SIM__.setState's deepMerge treats null as "no change" (see
// os/OSContext.tsx deepMerge), so a null patch can never clear an
// existing 'en' override. Each app's resolve*Locale falls back to
// 'zh-Hans' for anything that isn't the explicit 'en' / 'en-US'
// marker, so writing 'zh' / 'zh-CN' is safe.
return {
alipay: { language: 'zh' },
bilibili: { settings: { language: 'zh' } },
redbook: { settings: { language: 'zh-CN' } },
map: { settings: { appDisplay: { language: 'zh' } } },
};
}
function applyLocaleToSimulator(win, locale) {
if (!win?.__OS__?.locale?.setLocale || !win?.__SIM__?.setState) return;
win.__OS__.locale.setLocale(locale);
win.__SIM__.setState({ apps: buildLocaleAppPatch(locale) }, { deep: true });
// setState marks every patched app in `_benchmarkPatchedApps`, which RedBook
// reads at mount to skip its async entity-data load (see RedBookApp.tsx).
// The language patch is cosmetic — it shouldn't suppress data loading. Drop
// the marker for the four apps we touch so RedBook still hydrates normally.
const patched = win.__SIM__._benchmarkPatchedApps;
if (patched?.delete) {
['redbook', 'alipay', 'bilibili', 'map'].forEach((id) => patched.delete(id));
}
}
function getSelectedLanguageChoice() {
const active = languageChoices.find((btn) => btn.getAttribute('aria-checked') === 'true');
return active?.dataset?.languageChoice === 'en' ? 'en' : 'zh-Hans';
}
function applyLanguage(win) {
const locale = getSelectedLanguageChoice();
writeStoredLocale(locale);
updateLanguageUI(locale);
applyLocaleToSimulator(win, locale);
setStudioStatus(`Phone language set to ${locale === 'en' ? 'English' : '中文'}.`, 'ok');
}
async function resetSession(win) {
const snapshots = readSnapshots();
setStudioStatus('Resetting simulator...', 'warn');
await win.__SIM__.reset();
writeSnapshots(snapshots);
refreshSnapshotSelect();
// __SIM__.reset() does localStorage.clear() inside the iframe (same-origin
// with the parent), which intentionally also wipes our locale key — reset
// means reset. The parent page does NOT reload, so the badge/radio still
// show the pre-reset choice; resync them to the cleared state.
updateLanguageUI(readStoredLocale());
}
async function saveSnapshot(win) {
setStudioStatus('Saving snapshot...', 'warn');
const state = compactSnapshotState(win.__SIM__.getState());
const createdAt = Date.now();
const fallbackName = `Snapshot ${toShortTime(createdAt)}`;
const name = cleanText($('studio-snapshot-name').value, fallbackName, 48);
const snapshot = {
id: `snapshot-${createdAt}-${Math.random().toString(36).slice(2, 8)}`,
name,
createdAt,
state,
timeConfig: win.__SIM_TIME__?.getConfig ? cloneJSON(win.__SIM_TIME__.getConfig()) : null,
locationConfig: win.__SIM_LOCATION__?.getConfig ? cloneJSON(win.__SIM_LOCATION__.getConfig()) : null,
locationCoords: win.__SIM_LOCATION__?.getCoords ? cloneJSON(win.__SIM_LOCATION__.getCoords()) : null,
};
try {
const snapshots = [snapshot, ...readSnapshots().filter((item) => item.id !== snapshot.id)];
writeSnapshots(snapshots);
refreshSnapshotSelect(snapshot.id);
$('studio-snapshot-name').value = `Snapshot ${toShortTime(Date.now())}`;
setStudioStatus(`Snapshot saved: ${name}.`, 'ok');
} catch (error) {
setStudioStatus(error?.message || 'Could not save snapshot in this browser.', 'error');
}
}
async function restoreSnapshot(win) {
const id = $('studio-snapshot-select').value;
const snapshot = readSnapshots().find((item) => item.id === id);
if (!snapshot) {
setStudioStatus('Choose a snapshot to restore.', 'error');
return;
}
const snapshots = readSnapshots();
const appsPatch = cloneJSON(snapshot.state?.apps || {});
const shouldReloadRedBookData = appsPatch.redbook && !isHydratedRedBookSnapshot(appsPatch.redbook);
if (win.__SIM__.resetState) {
await win.__SIM__.resetState();
}
writeSnapshots(snapshots);
refreshSnapshotSelect(id);
win.__SIM__.setState({
os: snapshot.state?.os || {},
apps: appsPatch,
}, { deep: false });
if (shouldReloadRedBookData) {
win.__SIM__._benchmarkPatchedApps?.delete?.('redbook');
await waitForSnapshotData(win);
}
applySnapshotTime(win, snapshot);
applySnapshotLocation(win, snapshot);
syncStudioFromState();
setStudioStatus(`Snapshot restored: ${snapshot.name || 'Snapshot'}.`, 'ok');
}
function deleteSnapshot() {
const id = $('studio-snapshot-select').value;
const snapshots = readSnapshots();
const snapshot = snapshots.find((item) => item.id === id);
if (!snapshot) {
setStudioStatus('Choose a snapshot to delete.', 'error');
return;
}
writeSnapshots(snapshots.filter((item) => item.id !== id));
refreshSnapshotSelect();
setStudioStatus(`Snapshot deleted: ${snapshot.name || 'Snapshot'}.`, 'ok');
}
function applyDeviceTime(win, state) {
const timeApi = win.__SIM_TIME__;
if (!timeApi) {
setStudioStatus('Time controls are not available yet.', 'error');
return;
}
const mode = $('studio-device-time-mode').value;
if (mode === 'real') {
timeApi.setRealTime();
timeApi.setSpeed(1);
setStudioStatus('Device time follows real time.', 'ok');
syncStudioFromState();
return;
}
const timestamp = readLocalDatetimeInput('studio-device-time-value', getStateTime(state));
const flow = $('studio-device-time-flow').value;
const flowing = flow !== 'paused';
const speed = flow === '100' ? 100 : flow === '10' ? 10 : 1;
timeApi.setSimulatedTime(timestamp, flowing);
timeApi.setSpeed(flowing ? speed : 1);
setStudioStatus(`Device time set to simulated ${flowing ? `${speed}x` : 'paused'}.`, 'ok');
syncStudioFromState();
}
function applyDeviceBattery(win) {
const percent = Math.round(clampNumber($('studio-device-battery-level').value, 0, 100, 80));
const status = $('studio-device-battery-status').value;
const battery = {
percent,
charging: status === 'charging' || status === 'fast',
fastCharging: status === 'fast',
};
win.__SIM__.setState({
os: {
hardware: { battery },
settings: {
global: {
batterySaverEnabled: $('studio-device-battery-saver').value === 'on',
},
},
},
}, { deep: true });
setStudioStatus(`Device battery set to ${percent}%.`, 'ok');
syncStudioFromState();
}
function applyDeviceLocation(win) {
const locationApi = win.__SIM_LOCATION__;
if (!locationApi) {
setStudioStatus('Location controls are not available yet.', 'error');
return;
}
const preset = $('studio-device-location-preset').value;
if (preset === 'custom') {
const latitude = clampNumber($('studio-device-location-lat').value, -90, 90, 39.9042);
const longitude = clampNumber($('studio-device-location-lon').value, -180, 180, 116.4074);
locationApi.setSimulatedLocation({ latitude, longitude, accuracy: 100 });
setStudioStatus(`Device location set to ${latitude.toFixed(4)}, ${longitude.toFixed(4)}.`, 'ok');
} else {
locationApi.setSimulatedLocation(preset);
setStudioStatus(`Device location set to ${$('studio-device-location-preset').selectedOptions[0]?.text || preset}.`, 'ok');
}
syncStudioFromState();
}
function getWechatSelection(state) {
const wechat = state.apps?.wechat || {};
const contacts = Array.isArray(wechat.contacts) ? wechat.contacts : [];
const chats = Array.isArray(wechat.chats) ? wechat.chats : [];
const selectedWxid = $('studio-wechat-contact').value;
let contact = contacts.find((item) => item.wxid === selectedWxid);
if (!contact) {
contact = contacts.find((item) => item.name === '陈静') || {
wxid: 'demo_wechat_contact',
name: '陈静',
avatar: wechatAsset('avatars/avatar_88.jpg'),
category: 'D',
gender: '女',
region: '中国 上海',
permissionMode: 'all',
};
}
return { contact, contacts, chats };
}
function insertWechatMessage(win, state) {
const { contact, contacts, chats } = getWechatSelection(state);
const nowTs = readLocalDatetimeInput('studio-wechat-time', getStateTime(state));
const message = {
id: `demo-wx-${nowTs}`,
type: 'text',
content: cleanText($('studio-wechat-message').value, '今晚 7 点开会,记得带材料。', 160),
senderId: contact.wxid,
timestamp: nowTs,
};
const existing = chats.find((item) => item.id === contact.wxid);
const nextChat = existing
? {
...existing,
user: existing.user || { wxid: contact.wxid, name: contact.name, avatar: contact.avatar },
messages: [...(existing.messages || []), message],
}
: {
id: contact.wxid,
user: { wxid: contact.wxid, name: contact.name, avatar: contact.avatar },
messages: [message],
isMuted: false,
isSticky: false,
isAlert: false,
};
const nextContacts = contacts.some((item) => item.wxid === contact.wxid) ? contacts : [contact, ...contacts];
const nextChats = [nextChat, ...chats.filter((item) => item.id !== contact.wxid)];
win.__SIM__.setState({ apps: { wechat: { contacts: nextContacts, chats: nextChats } } }, { deep: true });
setStudioStatus(`WeChat message inserted for ${contact.name}.`, 'ok');
}
function addWechatContact(win, state) {
const wechat = state.apps?.wechat || {};
const contacts = Array.isArray(wechat.contacts) ? wechat.contacts : [];
const name = cleanText($('studio-wechat-new-name').value, '林小雨', 40);
const wxid = cleanText($('studio-wechat-new-wxid').value, `demo_${Date.now()}`, 40);
const signature = cleanText($('studio-wechat-new-signature').value, '明天会更好', 80);
const avatar = wechatAsset(selectedWechatAvatar());
const contact = {
wxid,
name,
avatar,
category: name.slice(0, 1).toUpperCase(),
signature,
region: '中国 上海',
gender: '女',
source: '通过 MobileGym Scenario Studio 添加',
addedTime: '2026年5月',
commonGroups: 0,
memo: '',
isBlacklisted: false,
permissionMode: 'all',
hideMyMoments: false,
hideTheirMoments: false,
aiConfig: { enabled: false },
};
const nextContacts = [contact, ...contacts.filter((item) => item.wxid !== wxid)];
win.__SIM__.setState({ apps: { wechat: { contacts: nextContacts } } }, { deep: true });
setStudioStatus(`WeChat contact added: ${name}.`, 'ok');
syncStudioFromState();
}
function setAlipayBalance(win, state) {
const alipay = state.apps?.alipay || {};
const balance = {
...(alipay.balance || {}),
total: money($('studio-alipay-balance').value, 520.13),
};
win.__SIM__.setState({ apps: { alipay: { balance } } }, { deep: true });
setStudioStatus(`Alipay balance set to ${balance.total}.`, 'ok');
}
function addAlipayBill(win, state) {
const alipay = state.apps?.alipay || {};
const nowTs = getStateTime(state);
const amount = Math.abs(money($('studio-alipay-amount').value, 32));
const direction = $('studio-alipay-direction').value;
const delta = direction === 'income' ? amount : -amount;
const title = cleanText($('studio-alipay-title').value, '星巴克', 60);
const note = cleanText($('studio-alipay-note').value, '拿铁', 80);
const record = {
id: `demo-alipay-${nowTs}`,
counterpartyName: title,
counterpartyAvatar: '',
delta,
timestamp: nowTs,
displayTitle: title,
detailTimeLabel: '支付时间',
paymentMethod: '余额',
transferNote: note,
productDescription: note,
category: direction === 'income' ? 'income' : 'dining',
kind: direction === 'income' ? 'transfer' : 'payment',
rewardPoints: Math.max(1, Math.round(amount)),
orderId: `DEMO${nowTs}`,
merchantOrderId: `MDEMO${nowTs}`,
};
const transferRecords = Array.isArray(alipay.transferRecords) ? alipay.transferRecords : [];
win.__SIM__.setState({
apps: {
alipay: {
transferRecords: [record, ...transferRecords],
},
},
}, { deep: true });
setStudioStatus(`Alipay bill added: ${title}, ${delta > 0 ? '+' : '-'}${amount}.`, 'ok');
}
function saveContactOnly(win) {
const name = cleanText($('studio-contact-name').value, '张三', 40);
const phone = cleanText($('studio-contact-phone').value, '13800138000', 24);
const contactValues = {
displayName: name,
phone,
phones: [{ label: '手机', number: phone, isPrimary: true }],
starred: true,
};
try {
const matched = win.__OS__.content.query(
`content://contacts/contacts?phone=${encodeURIComponent(phone)}`,
).items?.[0];
if (matched?.id) {
win.__OS__.content.update(`content://contacts/contacts/${matched.id}`, contactValues);
setStudioStatus(`Contact updated: ${name} (${phone}).`, 'ok');
} else {
win.__OS__.content.insert('content://contacts/contacts', contactValues);
setStudioStatus(`Contact added: ${name} (${phone}).`, 'ok');
}
} catch (error) {
setStudioStatus(error?.message || 'Could not save contact.', 'error');
}
}
function sendSmsOnly(win) {
const sender = cleanText($('studio-sms-sender').value, '12306', 32);
const body = cleanText($('studio-sms-body').value, '您的验证码是 482913,用于 MobileGym 场景演示,请勿泄露。', 240);
win.__OS__.broadcast.sendBroadcast({
action: 'android.provider.Telephony.SMS_RECEIVED',
extras: {
from: sender,
body,
},
});
setStudioStatus(`Incoming SMS received from ${sender}.`, 'ok');
}
function addRailwayOrder(win, state) {
const railway = state.apps?.railway12306 || {};
const nowTs = getStateTime(state);
const trainNo = cleanText($('studio-railway-train').value, 'G123', 20);
const from = cleanText($('studio-railway-from').value, '北京南', 30);
const to = cleanText($('studio-railway-to').value, '上海虹桥', 30);
const date = cleanText($('studio-railway-date').value, '2026-05-03', 20);
const departTime = cleanText($('studio-railway-depart').value, '09:30', 10);
const arriveTime = cleanText($('studio-railway-arrive').value, '14:48', 10);
const passengerName = cleanText($('studio-railway-passenger').value, '林小雨', 30);
const seatType = cleanText($('studio-railway-seat-type').value, '二等座', 20);
const seatNo = cleanText($('studio-railway-seat-no').value, '03车 08A号', 30);
const price = money($('studio-railway-price').value, 553);
const status = $('studio-railway-status').value;
const orders = Array.isArray(railway.orders) ? railway.orders : [];
const order = {
id: `DEMO${nowTs}`,
trainNo,
fromStation: from,
toStation: to,
departTime,
arriveTime,
date,
status,
createTime: `${date}T09:00:00`,
tickets: [{
passengerName,
ticketType: '成人',
seatType,
seatNo,
price,
}],
};
win.__SIM__.setState({
apps: {
railway12306: {
orders: [order, ...orders],
},
},
}, { deep: true });
setStudioStatus(`12306 order added: ${trainNo} ${from}${to}.`, 'ok');
}
// 天况预设:text/icon 与 QWeather 编码对齐(icon 与 weatherBundles.json 一致)
const WEATHER_CONDITION_PRESETS = {
sunny: { text: '晴', icon: '100' },
cloudy: { text: '多云', icon: '101' },
overcast: { text: '阴', icon: '104' },
'light-rain': { text: '小雨', icon: '305' },
'heavy-rain': { text: '大雨', icon: '308' },
thunderstorm: { text: '雷阵雨', icon: '302' },
'light-snow': { text: '小雪', icon: '400' },
fog: { text: '雾', icon: '501' },
haze: { text: '霾', icon: '502' },
};
// AQI → category/level 映射(与 normalizeAqiLevel 一致:6 级国标)
function aqiClassify(aqi) {
if (aqi <= 50) return { category: '优', level: '1' };
if (aqi <= 100) return { category: '良', level: '2' };
if (aqi <= 150) return { category: '轻度污染', level: '3' };
if (aqi <= 200) return { category: '中度污染', level: '4' };
if (aqi <= 300) return { category: '重度污染', level: '5' };
return { category: '严重污染', level: '6' };
}
function applyWeatherNow(win, state) {
const located = state.apps?.weather?.bundlesByCityId?.located;
if (!located?.bundle) {
setStudioStatus('Located bundle not loaded yet — open Weather first to populate.', 'warn');
return;
}
const conditionKey = $('studio-weather-condition').value;
const condition = WEATHER_CONDITION_PRESETS[conditionKey] || WEATHER_CONDITION_PRESETS.sunny;
const temp = String(Math.round(clampNumber($('studio-weather-temp').value, -50, 60, 20)));
const high = String(Math.round(clampNumber($('studio-weather-high').value, -50, 60, 25)));
const low = String(Math.round(clampNumber($('studio-weather-low').value, -50, 60, 15)));
const aqiNum = Math.round(clampNumber($('studio-weather-aqi').value, 0, 500, 50));
const aqi = String(aqiNum);
const aqiMeta = aqiClassify(aqiNum);
// daily 是数组,deepMerge 把数组当 atomic(整体替换)。要 patch daily[0] 的高低温,
// 必须把当前 daily 整体读出 → 替换 daily[0] → 整体写回。
const daily = (located.bundle.daily || []).map((day, idx) =>
idx === 0 ? { ...day, tempMax: high, tempMin: low } : day,
);
win.__SIM__.setState({
apps: {
weather: {
selectedCityId: 'located',
bundlesByCityId: {
located: {
bundle: {
now: {
temp,
feelsLike: temp,
text: condition.text,
icon: condition.icon,
},
daily,
airQuality: {
aqi,
category: aqiMeta.category,
level: aqiMeta.level,
},
},
},
},
},
},
}, { deep: true });
setStudioStatus(`Weather patched: ${condition.text} ${temp}°C (H${high}/L${low}), AQI ${aqi}.`, 'ok');
syncStudioFromState();
}
// iframe 内 scrollIntoView 会沿祖先 scroll container 链冒泡到父页面,浏览器 focus 时也会自动
// scroll-into-view 跨 iframe 冒泡。同源前提下直接 patch iframe 内的两个 prototype
// - scrollIntoView 改成只在 iframe 内最近的可滚祖先上滚动,到顶就停,不再外冒泡
// - focus 强制带 preventScroll,挡掉自动滚入视口
// 部署约定:paper 与 simulator 必须同 origin(生产 mobilegym.dev/paper + mobilegym.dev/
// dev 用 vite 同时服务 http://localhost:3000/web/index.html + http://localhost:3000/)。
// 不再保留跨域 fallback——非同源环境是配置错误,不是要支持的运行模式。
function patchSimulatorScrollPropagation(iframe) {
const win = iframe.contentWindow;
const doc = iframe.contentDocument;
if (!win || !doc) return;
if (win.__demoScrollPatched) return;
win.__demoScrollPatched = true;
const ElementCtor = win.Element;
const HTMLElementCtor = win.HTMLElement;
if (!ElementCtor || !HTMLElementCtor) return;
const origScrollIntoView = ElementCtor.prototype.scrollIntoView;
ElementCtor.prototype.scrollIntoView = function patchedScrollIntoView(arg) {
try {
const ownerDoc = this.ownerDocument;
const view = ownerDoc?.defaultView;
if (!view) return origScrollIntoView.call(this, arg);
let target = this.parentElement;
while (target && target !== ownerDoc.documentElement) {
const cs = view.getComputedStyle(target);
const oy = cs.overflowY, ox = cs.overflowX;
const scrollY = (oy === 'auto' || oy === 'scroll' || oy === 'overlay') && target.scrollHeight > target.clientHeight;
const scrollX = (ox === 'auto' || ox === 'scroll' || ox === 'overlay') && target.scrollWidth > target.clientWidth;
if (scrollY || scrollX) break;
target = target.parentElement;
}
// 没有 iframe 内的可滚祖先就直接吞掉,不让浏览器走到 documentElement 再向父页冒泡。
if (!target || target === ownerDoc.documentElement) return;
const opts = arg === false
? { block: 'end', inline: 'nearest' }
: arg === true
? { block: 'start', inline: 'nearest' }
: (typeof arg === 'object' && arg) || {};
const block = opts.block || 'start';
const inline = opts.inline || 'nearest';
const behavior = opts.behavior || 'auto';
const elemRect = this.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const offsetTop = elemRect.top - targetRect.top + target.scrollTop;
let nextTop = target.scrollTop;
if (block === 'start') nextTop = offsetTop;
else if (block === 'end') nextTop = offsetTop - target.clientHeight + elemRect.height;
else if (block === 'center') nextTop = offsetTop - target.clientHeight / 2 + elemRect.height / 2;
else { // 'nearest'
if (offsetTop < target.scrollTop) nextTop = offsetTop;
else if (offsetTop + elemRect.height > target.scrollTop + target.clientHeight)
nextTop = offsetTop - target.clientHeight + elemRect.height;
}
const offsetLeft = elemRect.left - targetRect.left + target.scrollLeft;
let nextLeft = target.scrollLeft;
if (inline === 'start') nextLeft = offsetLeft;
else if (inline === 'end') nextLeft = offsetLeft - target.clientWidth + elemRect.width;
else if (inline === 'center') nextLeft = offsetLeft - target.clientWidth / 2 + elemRect.width / 2;
else { // 'nearest'
if (offsetLeft < target.scrollLeft) nextLeft = offsetLeft;
else if (offsetLeft + elemRect.width > target.scrollLeft + target.clientWidth)
nextLeft = offsetLeft - target.clientWidth + elemRect.width;
}
target.scrollTo({
top: Math.max(0, nextTop),
left: Math.max(0, nextLeft),
behavior,
});
} catch {
return origScrollIntoView.call(this, arg);
}
};
const origFocus = HTMLElementCtor.prototype.focus;
HTMLElementCtor.prototype.focus = function patchedFocus(opts) {
return origFocus.call(this, { ...(opts || {}), preventScroll: true });
};
}
function powerOn() {
// 本地预览支持:?sim=http://localhost:3000 指向独立 vite。注意 iframe 与父页必须同源,
// 否则 patch 装不上、滚动会冒泡到父页面。推荐 dev 直接访问 vite 提供的 /web/index.html。
const simSrc = new URLSearchParams(location.search).get('sim') || '/';
frame.innerHTML = '';
const iframe = document.createElement('iframe');
iframe.src = simSrc;
iframe.title = 'MobileGym Live Simulator';
iframe.allow = 'clipboard-write; gyroscope; accelerometer';
iframe.className = 'block w-full h-full border-0';
frame.appendChild(iframe);
iframe.addEventListener('load', () => patchSimulatorScrollPropagation(iframe));
iframe.addEventListener('load', () => {
waitForSimulator()
.then((win) => {
// Only re-apply when the visitor has explicitly picked a language.
// Without an explicit choice, leave each app on its natural default
// (Map's null follows OS, Alipay's 'zh-CN', RedBook's 'zh-CN', etc.).
const stored = readStoredLocale();
if (stored) applyLocaleToSimulator(win, stored);
syncStudioFromState();
setStudioStatus('Simulator ready. Pick a scenario to apply.', 'ok');
})
.catch(() => setStudioStatus('Simulator is still loading. Try again in a moment.', 'warn'));
});
offBtn.classList.remove('hidden');
offBtn.classList.add('inline-flex');
setStudioStatus('Booting simulator — first load fetches app data, speed depends on your connection.', 'warn');
}
function powerOff() {
frame.innerHTML = bootBtnHTML;
// 恢复 boot 按钮的事件监听(innerHTML 重置后 listener 丢失)
document.getElementById('demo-boot-btn').addEventListener('click', powerOn);
offBtn.classList.add('hidden');
offBtn.classList.remove('inline-flex');
setStudioStatus('Power on the phone, then apply a scenario.', 'warn');
}
// ============================================================
// State Popover + Dock plumbing
// - Dock buttons share data-studio-tab with the popover's own
// tab strip, so switchStudioTab keeps both aria-selected in
// sync. Clicking a dock button OPENS the popover and switches
// to that tab.
// - The popover is a card anchored to the right of the dock
// (no fullscreen drawer, no backdrop scrim). Closes via:
// · click anywhere outside the dock + popover
// · click on the close button
// · ESC
// - First interaction marks body[data-state-dock-touched="true"]
// in sessionStorage so the pulse + "↑ Patch state" hint go
// away permanently for the rest of the browsing session.
// ============================================================
const STATE_DOCK_TOUCHED_KEY = 'mobilegym_state_dock_touched_v1';
const stateDrawer = document.getElementById('state-drawer');
const stateDrawerClose = document.getElementById('state-drawer-close');
if (sessionStorage.getItem(STATE_DOCK_TOUCHED_KEY)) {
document.body.dataset.stateDockTouched = 'true';
}
function markStateDockTouched() {
if (document.body.dataset.stateDockTouched === 'true') return;
document.body.dataset.stateDockTouched = 'true';
try { sessionStorage.setItem(STATE_DOCK_TOUCHED_KEY, '1'); } catch { /* private mode */ }
}
function openStateDrawer() {
const wasOpen = stateDrawer.dataset.open === 'true';
stateDrawer.dataset.open = 'true';
stateDrawer.setAttribute('aria-hidden', 'false');
document.body.dataset.stateDrawerOpen = 'true';
markStateDockTouched();
// On narrow viewports the popover stacks below the phone in a
// column flow — make sure it scrolls into view so the user
// doesn't have to manually scroll. Skip when it was already open
// (just switching tabs) to avoid yanking the page on every click.
if (!wasOpen && window.innerWidth < 1280) {
window.requestAnimationFrame(() => {
stateDrawer.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
}
function closeStateDrawer() {
stateDrawer.dataset.open = 'false';
stateDrawer.setAttribute('aria-hidden', 'true');
delete document.body.dataset.stateDrawerOpen;
}
studioTabs.forEach((tab) => {
tab.addEventListener('click', () => {
switchStudioTab(tab.dataset.studioTab);
// Dock tabs additionally open the popover (drawer-internal tabs
// already work without opening since the popover is visible).
if (tab.dataset.stateDockTab) openStateDrawer();
});
});
stateDrawerClose?.addEventListener('click', closeStateDrawer);
// Outside-click dismiss. Skip if click landed on a dock tab (which
// would re-open with a new tab) or inside the popover itself.
document.addEventListener('click', (event) => {
if (stateDrawer.dataset.open !== 'true') return;
if (event.target.closest('.state-dock-tab')) return;
if (event.target.closest('#state-drawer')) return;
closeStateDrawer();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && stateDrawer.dataset.open === 'true') {
closeStateDrawer();
}
});
studioActionTabs.forEach((tab) => {
tab.addEventListener('click', () => switchStudioAction(tab.dataset.studioAction));
});
$('studio-device-time-mode').addEventListener('change', () => {
setDeviceTimeFlowOptions($('studio-device-time-mode').value, null);
});
$('studio-device-location-preset').addEventListener('change', syncLocationCustomVisibility);
refreshSnapshotSelect();
$('studio-snapshot-name').value = `Snapshot ${toShortTime(Date.now())}`;
document.querySelectorAll('#studio-wechat-avatar-picker .avatar-choice').forEach((button) => {
button.addEventListener('click', () => {
document.querySelectorAll('#studio-wechat-avatar-picker .avatar-choice').forEach((item) => {
item.setAttribute('aria-pressed', item === button ? 'true' : 'false');
});
});
});
$('studio-reset-session').addEventListener('click', () => withSimulator(resetSession));
$('studio-save-snapshot').addEventListener('click', () => withSimulator(saveSnapshot));
$('studio-restore-snapshot').addEventListener('click', () => withSimulator(restoreSnapshot));
$('studio-delete-snapshot').addEventListener('click', deleteSnapshot);
$('studio-insert-wechat-message').addEventListener('click', () => withSimulator(insertWechatMessage));
$('studio-add-wechat-contact').addEventListener('click', () => withSimulator(addWechatContact));
$('studio-apply-device-time').addEventListener('click', () => withSimulator(applyDeviceTime));
$('studio-apply-device-battery').addEventListener('click', () => withSimulator(applyDeviceBattery));
$('studio-apply-device-location').addEventListener('click', () => withSimulator(applyDeviceLocation));
$('studio-set-alipay-balance').addEventListener('click', () => withSimulator(setAlipayBalance));
$('studio-add-alipay-bill').addEventListener('click', () => withSimulator(addAlipayBill));
$('studio-save-contact').addEventListener('click', () => withSimulator(saveContactOnly));
$('studio-send-sms').addEventListener('click', () => withSimulator(sendSmsOnly));
$('studio-add-railway-order').addEventListener('click', () => withSimulator(addRailwayOrder));
$('studio-apply-weather-now').addEventListener('click', () => withSimulator(applyWeatherNow));
languageChoices.forEach((btn) => {
btn.addEventListener('click', () => {
languageChoices.forEach((other) => {
other.setAttribute('aria-checked', other === btn ? 'true' : 'false');
});
});
});
$('studio-apply-language').addEventListener('click', () => withSimulator(applyLanguage));
updateLanguageUI(readStoredLocale());
document.getElementById('demo-boot-btn').addEventListener('click', powerOn);
offBtn.addEventListener('click', powerOff);
})();