(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, '>')
.replace(/"/g, '"');
}
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 = '';
return;
}
select.innerHTML = snapshots.map((item) => {
const created = Number.isFinite(Number(item.createdAt)) ? toShortTime(Number(item.createdAt)) : '--:--';
return ``;
}).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) => ``)
.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) => ``).join('')
: '';
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);
})();