From 0a70ce47cba2a60038c79cd6375e1ada416d2ffa Mon Sep 17 00:00:00 2001 From: Abner <22141172+Silentely@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:19:04 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(simyo):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=20eSIM=20=E6=9B=B4=E6=8D=A2=E6=B5=81=E7=A8=8B=EF=BC=88?= =?UTF-8?q?=E4=BB=85=20EMAIL=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修 handleApiResponse:登录/查询体无 result.success 时正确判定成功 - 设备更换:先 GET settings/simcard,再 POST EMAIL 申请;等待验证码时跳过重复下单 - 登录识别 mfaStatus,未关闭 MFA 时明确阻断 - bad session 单独文案;UA 更新为 iOS 18.2 / iPhone12,8 --- env.example | 8 +- netlify.toml | 14 +-- server.js | 6 +- src/js/modules/i18n-data.js | 18 ++- src/simyo/js/modules/api-config.js | 107 ++++++++++------- src/simyo/js/modules/auth-handler.js | 71 +++++++---- src/simyo/js/modules/client-identity.js | 12 +- src/simyo/js/modules/device-change-handler.js | 112 ++++++++++++++---- src/simyo/js/modules/esim-service.js | 5 +- src/simyo/js/modules/state-manager.js | 8 ++ src/simyo/js/simyo-app.js | 12 +- src/simyo/simyo_proxy_server.js | 6 +- tests/simyo/api-config.test.js | 33 +++++- tests/simyo/auth-handler.test.js | 36 +++++- tests/simyo/device-change-handler.test.js | 84 +++++++++++-- tests/test_simyo_esim.html | 4 +- 16 files changed, 397 insertions(+), 139 deletions(-) diff --git a/env.example b/env.example index a4ff786..542b415 100644 --- a/env.example +++ b/env.example @@ -41,13 +41,13 @@ ACCESS_KEY= SIMYO_CLIENT_TOKEN= # 可选:固定 X-Device-ID(UUID,官方 App 每个请求必带;未设则进程内自动生成) # SIMYO_DEVICE_ID= -# 可选:覆盖默认客户端身份(默认对齐 iOS 4.28.0 / iOS 27.0 / iPhone16,1 抓包) +# 可选:覆盖默认客户端身份(默认对齐 iOS 4.28.0 / iOS 18.2 / iPhone12,8 抓包) # 与 src/simyo/js/modules/client-identity.js 保持同步 # SIMYO_CLIENT_VERSION=4.28.0 # SIMYO_CLIENT_PLATFORM=ios -# SIMYO_IOS_VERSION=27.0 -# SIMYO_DEVICE_MODEL=iPhone16,1 -# SIMYO_USER_AGENT=MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1) +# SIMYO_IOS_VERSION=18.2 +# SIMYO_DEVICE_MODEL=iPhone12,8 +# SIMYO_USER_AGENT=MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8) # Giffgaff OAuth token exchange 回调 URI # 注意:该变量只影响服务端 token exchange;前端授权跳转 URI 仍在 src/giffgaff/js/modules/api-config.js 中配置。 diff --git a/netlify.toml b/netlify.toml index de27f7d..72794a7 100644 --- a/netlify.toml +++ b/netlify.toml @@ -35,22 +35,14 @@ # API代理重定向(用于解决CORS问题) # 通用 Simyo API 代理 (webapi - 新版API) -# User-Agent 覆盖为官方 iOS 4.28.0 抓包值(浏览器无法可靠设置 UA) +# User-Agent 覆盖为官方 iOS 4.28.0 eSIM 更换抓包值(浏览器无法可靠设置 UA) # 与 src/simyo/js/modules/client-identity.js 保持同步 [[redirects]] from = "/api/simyo/*" to = "https://appapi.simyo.nl/webapi/api/v1/:splat" status = 200 force = true - headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"} - -# v2 API 代理(用于特定端点如可用验证方式查询) -[[redirects]] - from = "/api/simyo/esim.availableValidationMethods" - to = "https://appapi.simyo.nl/webapi/api/v2/esim.availableValidationMethods" - status = 200 - force = true - headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"} + headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)"} # 保留旧版 simyoapi 路径的兼容性(如果需要) [[redirects]] @@ -58,7 +50,7 @@ to = "https://appapi.simyo.nl/simyoapi/api/v1/:splat" status = 200 force = true - headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"} + headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)"} [[headers]] # 限制 Simyo 代理仅允许指定站点跨域访问 diff --git a/server.js b/server.js index 0d67fef..5e7e530 100644 --- a/server.js +++ b/server.js @@ -29,11 +29,11 @@ const { parseOrigins, isAllowedOrigin: _isAllowedOrigin, resolveCorsOrigin: _res const origins = parseOrigins(process.env.ALLOWED_ORIGIN); const isAllowedOrigin = (origin) => _isAllowedOrigin(origin, origins); const getCorsOrigin = (origin) => _resolveCorsOrigin(origin, origins); -// 与 src/simyo/js/modules/client-identity.js 保持同步(官方 iOS 4.28.0 抓包) +// 与 src/simyo/js/modules/client-identity.js 保持同步(官方 iOS 4.28.0 eSIM 更换 HAR) const DEFAULT_SIMYO_CLIENT_PLATFORM = 'ios'; const DEFAULT_SIMYO_CLIENT_VERSION = '4.28.0'; -const DEFAULT_SIMYO_IOS_VERSION = '27.0'; -const DEFAULT_SIMYO_DEVICE_MODEL = 'iPhone16,1'; +const DEFAULT_SIMYO_IOS_VERSION = '18.2'; +const DEFAULT_SIMYO_DEVICE_MODEL = 'iPhone12,8'; // 版本号与括号之间为两个空格 const DEFAULT_SIMYO_USER_AGENT = `MijnSimyoFT/${DEFAULT_SIMYO_CLIENT_VERSION} (iOS ${DEFAULT_SIMYO_IOS_VERSION}; ${DEFAULT_SIMYO_DEVICE_MODEL})`; diff --git a/src/js/modules/i18n-data.js b/src/js/modules/i18n-data.js index cf10600..153775c 100644 --- a/src/js/modules/i18n-data.js +++ b/src/js/modules/i18n-data.js @@ -558,9 +558,12 @@ export const TRANSLATIONS = { 'simyo.errors.requirePassword': '请输入密码', 'simyo.errors.invalidCodeFormat': '请输入6位数字验证码', 'simyo.device.applyFailed': '设备更换请求失败', - 'simyo.device.applySuccess': '验证码已成功发送到邮箱', - 'simyo.device.applySuccessAlreadyOrdered': '验证码已发送到邮箱(您的账户已有eSIM配置,系统正在处理设备更换)', + 'simyo.device.applySuccess': '验证码已成功发送到邮箱,请查收后在下方输入', + 'simyo.device.applySuccessAlreadyOrdered': '已有进行中的 eSIM 更换订单,验证码应已发送到邮箱。请直接输入验证码,无需重复申请', + 'simyo.device.readyForDownload': '验证已完成,可直接获取 eSIM 二维码 / LPA', + 'simyo.device.statusFailed': '查询 eSIM 状态失败', 'simyo.device.log.applyFailed': '设备更换请求失败:', + 'simyo.device.log.statusFailed': '查询 simcard 状态失败,将继续尝试申请:', 'simyo.device.queryFailed': '查询可用验证方式失败', 'simyo.device.querySuccess': '查询成功', 'simyo.device.log.queryFailed': '查询可用验证方式失败:', @@ -573,6 +576,7 @@ export const TRANSLATIONS = { 'simyo.device.log.verifyFailed': '验证码验证失败:', 'simyo.auth.loginMissingToken': '登录失败,未获取到session token', 'simyo.auth.loginSuccess': '登录成功', + 'simyo.auth.mfaRequired': '账号已开启登录二次验证(MFA:{status} / {method})。本工具暂不支持在线完成 MFA。请先在官方 Mijn Simyo App 关闭 MFA,或完成验证后再试', 'simyo.auth.log.loginFailed': '登录失败:', 'simyo.esim.errors.notFound': '未找到有效的eSIM信息', 'simyo.esim.status.fetchSuccess': 'eSIM信息获取成功', @@ -589,6 +593,7 @@ export const TRANSLATIONS = { 'simyo.api.error.missingDeviceId': '缺少设备标识(X-Device-ID)。请刷新页面后重试', 'simyo.api.error.invalidCredentials': '手机号或密码不正确,请检查后重试', 'simyo.api.error.sessionExpired': '登录已失效,请重新登录', + 'simyo.api.error.badSession': '会话无效或未完成登录验证(bad session)。请重新登录;若登录后立刻收到验证码,请先在官方 App 关闭 MFA 或完成验证后再使用本工具', 'simyo.api.error.rateLimited': '请求过于频繁,请稍后再试', 'simyo.api.error.badRequest': '请求参数有误,请检查输入后重试', 'simyo.api.error.unauthorized': '未授权或登录已过期,请重新登录', @@ -864,9 +869,12 @@ export const TRANSLATIONS = { 'simyo.errors.requirePassword': 'Enter your password.', 'simyo.errors.invalidCodeFormat': 'Enter the six-digit verification code.', 'simyo.device.applyFailed': 'Device change request failed.', - 'simyo.device.applySuccess': 'Verification code sent to your email successfully.', - 'simyo.device.applySuccessAlreadyOrdered': 'Code sent to email (You already have an eSIM. Processing device change...)', + 'simyo.device.applySuccess': 'Verification code sent to your email. Enter it below.', + 'simyo.device.applySuccessAlreadyOrdered': 'An eSIM change is already in progress; the code should be in your email. Enter it below without requesting again.', + 'simyo.device.readyForDownload': 'Verification complete. You can fetch the eSIM QR / LPA now.', + 'simyo.device.statusFailed': 'Failed to query eSIM status.', 'simyo.device.log.applyFailed': 'New eSIM request failed:', + 'simyo.device.log.statusFailed': 'simcard status query failed; will still try apply:', 'simyo.device.queryFailed': 'Failed to query available validation methods.', 'simyo.device.querySuccess': 'Query succeeded.', 'simyo.device.log.queryFailed': 'Query validation methods failed:', @@ -879,6 +887,7 @@ export const TRANSLATIONS = { 'simyo.device.log.verifyFailed': 'Verification failed:', 'simyo.auth.loginMissingToken': 'Login succeeded but no session token was returned.', 'simyo.auth.loginSuccess': 'Signed in successfully.', + 'simyo.auth.mfaRequired': 'Login MFA is enabled on this account ({status} / {method}). This tool cannot complete MFA online. Disable MFA in the official Mijn Simyo app, or finish verification there first.', 'simyo.auth.log.loginFailed': 'Login failed:', 'simyo.esim.errors.notFound': 'No valid eSIM profile found.', 'simyo.esim.status.fetchSuccess': 'eSIM details retrieved.', @@ -895,6 +904,7 @@ export const TRANSLATIONS = { 'simyo.api.error.missingDeviceId': 'Missing device id (X-Device-ID). Please refresh the page and try again.', 'simyo.api.error.invalidCredentials': 'Incorrect phone number or password. Please check and try again.', 'simyo.api.error.sessionExpired': 'Your session has expired. Please sign in again.', + 'simyo.api.error.badSession': 'Invalid or incomplete session (bad session). Sign in again. If you receive a code immediately after login, disable MFA in the official Mijn Simyo app or complete verification first.', 'simyo.api.error.rateLimited': 'Too many requests. Please wait a moment and try again.', 'simyo.api.error.badRequest': 'Invalid request. Please check your input and try again.', 'simyo.api.error.unauthorized': 'Unauthorized or session expired. Please sign in again.', diff --git a/src/simyo/js/modules/api-config.js b/src/simyo/js/modules/api-config.js index 9567f9c..bfd7842 100644 --- a/src/simyo/js/modules/api-config.js +++ b/src/simyo/js/modules/api-config.js @@ -5,6 +5,10 @@ * 请求头字段对齐官方 Mijn Simyo iOS 抓包: * X-Client-Token / X-Client-Platform / X-Client-Version / X-Device-ID / User-Agent * 版本与 UA 见 client-identity.js(单一事实来源) + * + * eSIM 更换主路径(HAR 2026-07-22,仅 EMAIL): + * POST /sessions → GET /settings/simcard → POST /settings/simcard + * → POST /esim/verify-code → GET /esim/get-by-customer */ import { isNetlifyEnvironment } from './utils.js'; @@ -34,7 +38,6 @@ function generateUuidV4() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID().toUpperCase(); } - // 无 crypto.randomUUID 时的回退(仍保证 RFC4122 形态) const bytes = new Uint8Array(16); if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { crypto.getRandomValues(bytes); @@ -61,7 +64,6 @@ function generateUuidV4() { /** * 获取或创建稳定的 X-Device-ID - * 官方 App 每个请求都携带 UUID 形态的设备 ID;缺失会返回 400 missing X-Device-ID * @returns {string} */ export function getOrCreateDeviceId() { @@ -85,7 +87,7 @@ export function getOrCreateDeviceId() { } /** - * API端点配置 + * API端点配置(对齐官方 webapi 路径) */ export function getApiEndpoints() { const isNetlify = isNetlifyEnvironment(); @@ -93,30 +95,27 @@ export function getApiEndpoints() { const localBase = isBrowserServed ? '' : 'http://localhost:3000'; return { - // 认证相关 login: isNetlify ? '/api/simyo/sessions' : `${localBase}/api/simyo/sessions`, - // eSIM相关 - getEsim: isNetlify - ? '/api/simyo/esim/get-by-customer' - : `${localBase}/api/simyo/esim/get-by-customer`, - - // 设备更换相关 + // GET 查询 / POST 申请 共用 settings/simcard + simcard: isNetlify + ? '/api/simyo/settings/simcard' + : `${localBase}/api/simyo/settings/simcard`, + // 兼容旧命名 applyNewEsim: isNetlify ? '/api/simyo/settings/simcard' : `${localBase}/api/simyo/settings/simcard`, + verifyCode: isNetlify ? '/api/simyo/esim/verify-code' : `${localBase}/api/simyo/esim/verify-code`, - // 新增:查询可用的验证方式 (v2 API) - availableValidationMethods: isNetlify - ? '/api/simyo/esim.availableValidationMethods' - : `${localBase}/api/simyo/esim.availableValidationMethods`, + getEsim: isNetlify + ? '/api/simyo/esim/get-by-customer' + : `${localBase}/api/simyo/esim/get-by-customer`, - // 安装确认 confirmInstall: isNetlify ? '/api/simyo/esim/reorder-profile-installed' : `${localBase}/api/simyo/esim/reorder-profile-installed` @@ -148,10 +147,8 @@ export function createHeaders(includeSession = false, sessionToken = '') { /** * 将 Simyo / 代理返回的原始错误映射为用户可读文案 - * 优先匹配已知业务短语,其次按 HTTP 状态码,最后保留原文或通用失败 - * - * @param {number} status - HTTP 状态码(业务体错误可传 0) - * @param {object|string|null} data - 响应体或原始字符串 + * @param {number} status + * @param {object|string|null} data * @returns {string} */ export function mapSimyoErrorMessage(status, data) { @@ -167,7 +164,16 @@ export function mapSimyoErrorMessage(status, data) { const raw = rawParts.filter((p) => p != null && String(p).trim() !== '').map(String).join(' '); const normalized = raw.toLowerCase(); - // 已知业务错误(Simyo 原文 / 代理透传) + // 会话类(须先于笼统 unauthorized / credentials) + if ( + normalized.includes('bad session') || + (normalized.includes('unauthoris') && normalized.includes('session')) || + (normalized.includes('unauthorized') && normalized.includes('session')) || + (normalized.includes('session') && (normalized.includes('expired') || normalized.includes('invalid'))) + ) { + return t('simyo.api.error.badSession'); + } + if (normalized.includes('missing x-device-id') || normalized.includes('x-device-id')) { return t('simyo.api.error.missingDeviceId'); } @@ -175,19 +181,14 @@ export function mapSimyoErrorMessage(status, data) { normalized.includes('invalid credentials') || normalized.includes('invalid password') || normalized.includes('authentication failed') || - normalized.includes('unauthorized') || normalized.includes('wrong password') ) { return t('simyo.api.error.invalidCredentials'); } - if (normalized.includes('session') && (normalized.includes('expired') || normalized.includes('invalid'))) { - return t('simyo.api.error.sessionExpired'); - } if (normalized.includes('too many') || normalized.includes('rate limit') || normalized.includes('throttl')) { return t('simyo.api.error.rateLimited'); } - // HTTP 状态码语义(含历史上 426 客户端版本过旧) const statusMap = { 400: 'simyo.api.error.badRequest', 401: 'simyo.api.error.unauthorized', @@ -202,10 +203,8 @@ export function mapSimyoErrorMessage(status, data) { 504: 'simyo.api.error.timeout' }; if (status && statusMap[status]) { - // 有状态码映射时,优先友好文案;附带简短原文便于排查(过长则截断) const friendly = t(statusMap[status]); if (raw && raw.length > 0 && raw.length <= 80 && !/^HTTP\s+\d+/i.test(raw)) { - // 避免重复拼接已是友好文案的情况 if (!friendly.includes(raw) && normalized !== friendly.toLowerCase()) { return `${friendly}(${raw})`; } @@ -214,7 +213,6 @@ export function mapSimyoErrorMessage(status, data) { } if (raw) { - // 纯 HTTP NNN 形态的原文也换成通用失败 if (/^HTTP\s+\d+$/i.test(raw.trim())) { return t('simyo.api.error.genericWithStatus', { status: raw.replace(/\D/g, '') || status || '?' }); } @@ -227,9 +225,44 @@ export function mapSimyoErrorMessage(status, data) { return t('simyo.api.error.generic'); } +/** + * 判断 Simyo result 体是否表示业务成功 + * 官方登录/查询常无 result.success,仅有 sessionToken / eSimStatus 等字段 + * @param {object} result + * @returns {boolean} + */ +export function isSimyoResultSuccessful(result) { + if (!result || typeof result !== 'object') { + return false; + } + if (result.success === false) { + return false; + } + if (result.success === true) { + return true; + } + // 登录 + if (result.sessionToken) { + return true; + } + // 取 eSIM + if (result.activationCode) { + return true; + } + // simcard 状态查询 + if (result.eSimStatus || result.canCreateESim || result.canCreateSimcard) { + return true; + } + // 申请更换:reason + remainingNumberOfTries + if (result.reason != null || result.remainingNumberOfTries != null) { + return true; + } + return false; +} + /** * 处理API响应 - * @param {Response} response - Fetch API响应对象 + * @param {Response} response */ export async function handleApiResponse(response) { const contentType = response.headers.get('content-type') || ''; @@ -238,7 +271,6 @@ export async function handleApiResponse(response) { try { data = await response.json(); } catch (e) { - // JSON 解析失败(空体/坏体),回退为空对象 data = {}; } } else { @@ -246,32 +278,27 @@ export async function handleApiResponse(response) { data = text ? { message: text } : {}; } - // HTTP 错误:映射为用户可读中文/英文,避免只显示「HTTP 426」 if (!response.ok) { throw new Error(mapSimyoErrorMessage(response.status, data)); } - // 非 JSON 响应直接返回文本内容 if (!contentType.includes('application/json')) { return { success: true, result: data }; } - // 统一兼容三种返回格式: - // 1. 本地旧代理包装: { success, result, message } - // 2. 直通 Simyo: { result: {...} } - // 3. Simyo 展平成功结构: { success: true, ... } + // 本地代理包装: { success, result, message } if (data.success === true && data.result) { return data; } + // 直通 Simyo: { result: {...} } if (data.result) { - const nestedSuccess = data.result.success; - // 业务失败(HTTP 200 但 success=false / 无 success 字段)也走友好文案 - if (nestedSuccess === false) { + if (data.result.success === false) { throw new Error(mapSimyoErrorMessage(0, data)); } + const ok = isSimyoResultSuccessful(data.result); return { - success: nestedSuccess === true, + success: ok, result: data.result, message: data.message || data.result.message || data.result.reason }; diff --git a/src/simyo/js/modules/auth-handler.js b/src/simyo/js/modules/auth-handler.js index b6c16b3..0f98fed 100644 --- a/src/simyo/js/modules/auth-handler.js +++ b/src/simyo/js/modules/auth-handler.js @@ -1,6 +1,6 @@ /** * Simyo认证处理模块 - * 负责账户登录认证 + * 负责账户登录认证;识别 mfaStatus,未关闭 MFA 时阻断后续业务 */ import { stateManager } from './state-manager.js'; @@ -9,6 +9,29 @@ import { validatePhoneNumber } from './utils.js'; import { t } from '../../../js/modules/i18n.js'; import Logger from '../../../js/modules/logger.js'; +/** + * 判断登录返回的 mfaStatus 是否允许继续业务 + * 官方 HAR:DISABLED_BY_CUSTOMER 表示客户关闭 MFA + * @param {string} mfaStatus + * @returns {boolean} + */ +export function isMfaDisabledOrComplete(mfaStatus) { + if (mfaStatus == null || mfaStatus === '') { + return true; + } + const s = String(mfaStatus).toUpperCase(); + return ( + s === 'DISABLED_BY_CUSTOMER' || + s === 'DISABLED' || + s === 'NONE' || + s === 'OFF' || + s === 'OK' || + s === 'VERIFIED' || + s === 'COMPLETED' || + s === 'NOT_REQUIRED' + ); +} + export class AuthHandler { constructor() { this.apiEndpoints = getApiEndpoints(); @@ -16,12 +39,11 @@ export class AuthHandler { /** * 登录Simyo账户 - * @param {string} phoneNumber - 手机号 - * @param {string} password - 密码 - * @returns {Promise} 登录结果 + * @param {string} phoneNumber + * @param {string} password + * @returns {Promise} */ async login(phoneNumber, password) { - // 验证手机号格式 if (!validatePhoneNumber(phoneNumber)) { throw new Error(t('simyo.errors.invalidPhone')); } @@ -41,24 +63,42 @@ export class AuthHandler { }); const data = await handleApiResponse(response); - - // 提取session token - const sessionToken = data.result && data.result.sessionToken; + const result = data.result || {}; + const sessionToken = result.sessionToken; if (!sessionToken) { throw new Error(data.message || t('simyo.auth.loginMissingToken')); } - // 仅保存会话令牌与手机号;密码不进入状态 + const mfaStatus = result.mfaStatus || ''; + const mfaMethod = result.mfaMethod || ''; + const methodHint = result.methodHint || ''; + + // 先落会话,便于诊断;MFA 未关闭则禁止进入设备更换 stateManager.setState({ sessionToken: sessionToken, - phoneNumber: phoneNumber + phoneNumber: phoneNumber, + mfaStatus: mfaStatus, + mfaMethod: mfaMethod }); + if (!isMfaDisabledOrComplete(mfaStatus)) { + const err = new Error( + t('simyo.auth.mfaRequired', { + status: mfaStatus, + method: mfaMethod || methodHint || '—' + }) + ); + err.code = 'MFA_REQUIRED'; + err.mfaStatus = mfaStatus; + throw err; + } + return { success: true, message: t('simyo.auth.loginSuccess'), - sessionToken: sessionToken + sessionToken: sessionToken, + mfaStatus: mfaStatus }; } catch (error) { Logger.error(t('simyo.auth.log.loginFailed'), error); @@ -66,22 +106,13 @@ export class AuthHandler { } } - /** - * 检查登录状态 - * @returns {boolean} 是否已登录 - */ isLoggedIn() { return !!stateManager.get('sessionToken'); } - /** - * 获取当前session token - * @returns {string} session token - */ getSessionToken() { return stateManager.get('sessionToken'); } } -// 创建单例实例 export const authHandler = new AuthHandler(); diff --git a/src/simyo/js/modules/client-identity.js b/src/simyo/js/modules/client-identity.js index ff43897..95876bc 100644 --- a/src/simyo/js/modules/client-identity.js +++ b/src/simyo/js/modules/client-identity.js @@ -1,13 +1,13 @@ /** * Simyo 官方 App 客户端身份(单一事实来源) * - * 对齐 Mijn Simyo iOS 抓包(2026-07-22 HAR): + * 对齐 Mijn Simyo iOS 抓包(2026-07-22 eSIM 更换 HAR): * - App 版本: 4.28.0 - * - API User-Agent: MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1) + * - API User-Agent: MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8) * (版本号与括号之间为两个空格) * - 平台: ios - * - 设备型号: iPhone16,1 - * - 系统版本: 27.0 + * - 设备型号: iPhone12,8 + * - 系统版本: 18.2 * * 修改此处后请同步: * - server.js 中的 DEFAULT_SIMYO_* 常量 @@ -26,10 +26,10 @@ export const SIMYO_CLIENT_PLATFORM = 'ios'; export const SIMYO_CLIENT_TOKEN = 'e77b7e2f43db41bb95b17a2a11581a38'; /** 抓包中的 iOS 系统版本 */ -export const SIMYO_IOS_VERSION = '27.0'; +export const SIMYO_IOS_VERSION = '18.2'; /** 抓包中的设备型号(identifier) */ -export const SIMYO_DEVICE_MODEL = 'iPhone16,1'; +export const SIMYO_DEVICE_MODEL = 'iPhone12,8'; /** * appapi 请求使用的 User-Agent diff --git a/src/simyo/js/modules/device-change-handler.js b/src/simyo/js/modules/device-change-handler.js index bfbb4a5..53bd7a9 100644 --- a/src/simyo/js/modules/device-change-handler.js +++ b/src/simyo/js/modules/device-change-handler.js @@ -1,6 +1,7 @@ /** * Simyo设备更换处理模块 - * 负责设备更换的完整流程 + * 对齐官方 App HAR(仅 EMAIL 验证): + * GET settings/simcard → POST settings/simcard(EMAIL) → POST esim/verify-code */ import { stateManager } from './state-manager.js'; @@ -9,41 +10,109 @@ import { validateVerificationCode } from './utils.js'; import { t } from '../../../js/modules/i18n.js'; import Logger from '../../../js/modules/logger.js'; +/** 官方 eSimStatus 状态 */ +export const ESIM_STATUS = { + START_REQUEST: 'ESIM_START_REQUEST', + WAITING_FOR_VALIDATION_CODE: 'ESIM_REQUEST_WAITING_FOR_VALIDATION_CODE', + READY_FOR_DOWNLOAD: 'ESIM_REQUEST_READY_FOR_DOWNLOAD' +}; + export class DeviceChangeHandler { constructor() { this.apiEndpoints = getApiEndpoints(); } /** - * 请求设备更换(支持邮箱验证) - * @param {string} validationMethod - 验证方式 (默认: EMAIL) - * @returns {Promise} 请求结果 + * 读取当前 session token,未登录则抛错 + * @returns {string} */ - async applyNewEsim(validationMethod = 'EMAIL') { + requireSessionToken() { const sessionToken = stateManager.get('sessionToken'); - if (!sessionToken) { throw new Error(t('simyo.errors.requireLogin')); } + return sessionToken; + } + + /** + * GET /settings/simcard — 查询 eSIM 订单状态 + * @returns {Promise} + */ + async getSimcardStatus() { + const sessionToken = this.requireSessionToken(); + const response = await fetch(this.apiEndpoints.simcard, { + method: 'GET', + headers: createHeaders(true, sessionToken) + }); + const data = await handleApiResponse(response); + if (!data.result) { + throw new Error(data.message || t('simyo.device.statusFailed')); + } + return data.result; + } + + /** + * 请求设备更换(固定 EMAIL,对齐官方 body) + * 会先 GET 状态:已在等待验证码 / 可下载时避免重复下单 + * @returns {Promise} + */ + async applyNewEsim() { + const sessionToken = this.requireSessionToken(); try { + // 1) 查询当前状态 + let status = null; + try { + status = await this.getSimcardStatus(); + } catch (statusError) { + Logger.warn(t('simyo.device.log.statusFailed'), statusError); + // 查询失败仍尝试 POST,兼容旧代理 + } + + if (status) { + if (status.eSimStatus === ESIM_STATUS.WAITING_FOR_VALIDATION_CODE) { + return { + success: true, + message: t('simyo.device.applySuccessAlreadyOrdered'), + remainingTries: status.remainingNumberOfTries, + eSimStatus: status.eSimStatus, + reason: status.canCreateESim || 'AlreadyOrderedSimcardEsim', + alreadyPending: true + }; + } + if (status.eSimStatus === ESIM_STATUS.READY_FOR_DOWNLOAD) { + return { + success: true, + message: t('simyo.device.readyForDownload'), + remainingTries: status.remainingNumberOfTries, + eSimStatus: status.eSimStatus, + readyForDownload: true + }; + } + } + + // 2) POST 申请(仅 EMAIL) const response = await fetch(this.apiEndpoints.applyNewEsim, { method: 'POST', headers: createHeaders(true, sessionToken), body: JSON.stringify({ - initialValidationMethod: validationMethod, + initialValidationMethod: 'EMAIL', esim: true }) }); const data = await handleApiResponse(response); - if (!data.success || !data.result) { + if (!data.result) { throw new Error(data.message || t('simyo.device.applyFailed')); } - // 根据reason字段选择不同的提示消息 - let message = data.message || t('simyo.device.applySuccess'); + // 官方:success:true + reason Available | AlreadyOrderedSimcardEsim + if (data.result.success === false) { + throw new Error(data.message || data.result.reason || t('simyo.device.applyFailed')); + } + + let message = t('simyo.device.applySuccess'); if (data.result.reason === 'AlreadyOrderedSimcardEsim') { message = t('simyo.device.applySuccessAlreadyOrdered'); } @@ -51,7 +120,9 @@ export class DeviceChangeHandler { return { success: true, message: message, - remainingTries: data.result.remainingNumberOfTries + remainingTries: data.result.remainingNumberOfTries, + reason: data.result.reason, + eSimStatus: ESIM_STATUS.WAITING_FOR_VALIDATION_CODE }; } catch (error) { Logger.error(t('simyo.device.log.applyFailed'), error); @@ -60,16 +131,12 @@ export class DeviceChangeHandler { } /** - * 验证验证码 - * @param {string} validationCode - 验证码 - * @returns {Promise} 验证结果 + * 验证邮箱验证码 POST /esim/verify-code + * @param {string} validationCode + * @returns {Promise} */ async verifyCode(validationCode) { - const sessionToken = stateManager.get('sessionToken'); - - if (!sessionToken) { - throw new Error(t('simyo.errors.requireLogin')); - } + const sessionToken = this.requireSessionToken(); if (!validateVerificationCode(validationCode)) { throw new Error(t('simyo.errors.invalidCodeFormat')); @@ -86,17 +153,17 @@ export class DeviceChangeHandler { const data = await handleApiResponse(response); - if (!data.success) { + if (!data.result || data.result.success === false) { throw new Error(data.message || t('simyo.device.verifyFailed')); } - // 保存验证码到状态 stateManager.set('validationCode', validationCode); return { success: true, message: data.message || t('simyo.device.verifySuccess'), - remainingTries: data.result && data.result.remainingNumberOfTries + remainingTries: data.result.remainingNumberOfTries, + eSimStatus: ESIM_STATUS.READY_FOR_DOWNLOAD }; } catch (error) { Logger.error(t('simyo.device.log.verifyFailed'), error); @@ -105,5 +172,4 @@ export class DeviceChangeHandler { } } -// 创建单例实例 export const deviceChangeHandler = new DeviceChangeHandler(); diff --git a/src/simyo/js/modules/esim-service.js b/src/simyo/js/modules/esim-service.js index 2db5ac6..995fa9a 100644 --- a/src/simyo/js/modules/esim-service.js +++ b/src/simyo/js/modules/esim-service.js @@ -32,8 +32,9 @@ export class EsimService { }); const data = await handleApiResponse(response); - - if (!data.success || !data.result || !data.result.activationCode) { + + // 官方 HAR:{ result: { activationCode, success: true, errorCode: 0 } } + if (!data.result || !data.result.activationCode) { throw new Error(data.message || t('simyo.esim.errors.notFound')); } diff --git a/src/simyo/js/modules/state-manager.js b/src/simyo/js/modules/state-manager.js index f3144fc..de47619 100644 --- a/src/simyo/js/modules/state-manager.js +++ b/src/simyo/js/modules/state-manager.js @@ -13,6 +13,8 @@ export class StateManager { // 认证相关(密码仅在登录请求时使用,不进入状态) sessionToken: "", phoneNumber: "", + mfaStatus: "", + mfaMethod: "", // eSIM相关 activationCode: "", @@ -96,6 +98,8 @@ export class StateManager { const sessionData = { sessionToken: this.state.sessionToken, phoneNumber: this.state.phoneNumber, + mfaStatus: this.state.mfaStatus, + mfaMethod: this.state.mfaMethod, activationCode: this.state.activationCode, validationCode: this.state.validationCode, isDeviceChange: this.state.isDeviceChange, @@ -130,6 +134,8 @@ export class StateManager { ...this.state, sessionToken: data.sessionToken || "", phoneNumber: data.phoneNumber || "", + mfaStatus: data.mfaStatus || "", + mfaMethod: data.mfaMethod || "", activationCode: data.activationCode || "", validationCode: data.validationCode || "", isDeviceChange: typeof data.isDeviceChange === 'boolean' ? data.isDeviceChange : false, @@ -154,6 +160,8 @@ export class StateManager { this.state = { sessionToken: "", phoneNumber: "", + mfaStatus: "", + mfaMethod: "", activationCode: "", validationCode: "", isDeviceChange: false, diff --git a/src/simyo/js/simyo-app.js b/src/simyo/js/simyo-app.js index 281497a..5ec2990 100644 --- a/src/simyo/js/simyo-app.js +++ b/src/simyo/js/simyo-app.js @@ -295,11 +295,18 @@ class SimyoApp { Logger.log('[Simyo] 调用 deviceChangeHandler.applyNewEsim()...'); const result = await deviceChangeHandler.applyNewEsim(); - Logger.log('[Simyo] 设备更换请求成功:', result.message); + Logger.log('[Simyo] 设备更换请求成功:', result.message, result.eSimStatus || ''); uiController.showStatus(statusEl, result.message || t('simyo.app.status.applySuccess'), "success"); - // 新版流程使用邮箱验证码:引导用户直接输入验证码 + // 已验证完成:可直接跳过验证码进入获取 eSIM + if (result.readyForDownload) { + await delay(1500); + uiController.skipDeviceChange(); + return; + } + + // 邮箱验证码:引导用户输入(含已下单仅等待验证码) const codeInput = document.getElementById('validationCodeInput'); const verifyBtn = document.getElementById('verifyCodeBtn'); if (codeInput) { @@ -308,7 +315,6 @@ class SimyoApp { if (verifyBtn) verifyBtn.disabled = !hasValidCode; } - // 显示下一步提示 if (result.nextStep) { await delay(3000); uiController.showStatus(statusEl, result.nextStep, "info"); diff --git a/src/simyo/simyo_proxy_server.js b/src/simyo/simyo_proxy_server.js index aac17a3..2520ad7 100644 --- a/src/simyo/simyo_proxy_server.js +++ b/src/simyo/simyo_proxy_server.js @@ -33,11 +33,11 @@ function isAllowedTarget(urlStr) { } } -// Simyo API配置(与 client-identity.js 同步:iOS 4.28.0 / iOS 27.0 / iPhone16,1) +// Simyo API配置(与 client-identity.js 同步:iOS 4.28.0 / iOS 18.2 / iPhone12,8) const crypto = require('crypto'); const SIMYO_CLIENT_VERSION = process.env.SIMYO_CLIENT_VERSION || '4.28.0'; -const SIMYO_IOS_VERSION = process.env.SIMYO_IOS_VERSION || '27.0'; -const SIMYO_DEVICE_MODEL = process.env.SIMYO_DEVICE_MODEL || 'iPhone16,1'; +const SIMYO_IOS_VERSION = process.env.SIMYO_IOS_VERSION || '18.2'; +const SIMYO_DEVICE_MODEL = process.env.SIMYO_DEVICE_MODEL || 'iPhone12,8'; const SIMYO_USER_AGENT = process.env.SIMYO_USER_AGENT || `MijnSimyoFT/${SIMYO_CLIENT_VERSION} (iOS ${SIMYO_IOS_VERSION}; ${SIMYO_DEVICE_MODEL})`; diff --git a/tests/simyo/api-config.test.js b/tests/simyo/api-config.test.js index 995feb5..a977f3d 100644 --- a/tests/simyo/api-config.test.js +++ b/tests/simyo/api-config.test.js @@ -22,17 +22,17 @@ import { const UUID_RE = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/; /** 官方抓包 UA:版本号后恰好两个空格 */ -const OFFICIAL_UA = 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)'; +const OFFICIAL_UA = 'MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)'; describe('Simyo client-identity', () => { - it('版本 / 设备 / UA 应与 4.28.0 抓包完全一致', () => { + it('版本 / 设备 / UA 应与 4.28.0 eSIM 更换抓包一致', () => { expect(SIMYO_CLIENT_VERSION).toBe('4.28.0'); expect(SIMYO_CLIENT_PLATFORM).toBe('ios'); - expect(SIMYO_IOS_VERSION).toBe('27.0'); - expect(SIMYO_DEVICE_MODEL).toBe('iPhone16,1'); + expect(SIMYO_IOS_VERSION).toBe('18.2'); + expect(SIMYO_DEVICE_MODEL).toBe('iPhone12,8'); expect(SIMYO_USER_AGENT).toBe(OFFICIAL_UA); // 版本号与括号之间必须是两个空格(不是一个) - expect(SIMYO_USER_AGENT).toMatch(/^MijnSimyoFT\/4\.28\.0 {2}\(iOS 27\.0; iPhone16,1\)$/); + expect(SIMYO_USER_AGENT).toMatch(/^MijnSimyoFT\/4\.28\.0 {2}\(iOS 18\.2; iPhone12,8\)$/); expect(simyoClientIdentity.userAgent).toBe(OFFICIAL_UA); }); }); @@ -85,6 +85,12 @@ describe('Simyo mapSimyoErrorMessage / handleApiResponse', () => { expect(msg).not.toBe('missing X-Device-ID'); }); + it('bad session 应映射为会话无效,而非账号密码错误', () => { + const msg = mapSimyoErrorMessage(401, { message: 'Unauthorised - bad session' }); + expect(msg.toLowerCase()).toMatch(/session|会话|mfa|bad session/); + expect(msg).not.toMatch(/密码不正确|incorrect phone number or password/i); + }); + it('401 凭据错误原文应优先识别为账号密码错误', () => { const msg = mapSimyoErrorMessage(401, { message: 'Invalid credentials' }); expect(msg.toLowerCase()).toMatch(/password|密码|phone|手机号|incorrect|不正确/); @@ -100,6 +106,23 @@ describe('Simyo mapSimyoErrorMessage / handleApiResponse', () => { await expect(handleApiResponse(response)).rejects.toThrow(/升级|upgrade|客户端|client version|refresh|刷新/i); }); + it('handleApiResponse 官方登录体(无 result.success)应视为成功', async () => { + const response = { + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + result: { + sessionToken: 'abc', + mfaStatus: 'DISABLED_BY_CUSTOMER' + } + }) + }; + const data = await handleApiResponse(response); + expect(data.success).toBe(true); + expect(data.result.sessionToken).toBe('abc'); + }); + it('handleApiResponse 成功路径仍返回 result', async () => { const response = { ok: true, diff --git a/tests/simyo/auth-handler.test.js b/tests/simyo/auth-handler.test.js index dcb22a5..8c6a028 100644 --- a/tests/simyo/auth-handler.test.js +++ b/tests/simyo/auth-handler.test.js @@ -4,7 +4,7 @@ * Simyo AuthHandler 冒烟测试 */ -import { authHandler } from '../../src/simyo/js/modules/auth-handler.js'; +import { authHandler, isMfaDisabledOrComplete } from '../../src/simyo/js/modules/auth-handler.js'; import { stateManager } from '../../src/simyo/js/modules/state-manager.js'; describe('Simyo AuthHandler', () => { @@ -24,12 +24,17 @@ describe('Simyo AuthHandler', () => { }); it('登录成功应写入 token/手机号,且不保留 password', async () => { + // 官方 HAR 形态:无顶层 success,result 内无 success 字段 global.fetch.mockResolvedValueOnce({ ok: true, headers: { get: () => 'application/json' }, json: async () => ({ - success: true, - result: { sessionToken: 'sess-abc' } + result: { + sessionToken: 'sess-abc', + mfaStatus: 'DISABLED_BY_CUSTOMER', + mfaMethod: '', + methodHint: '' + } }) }); @@ -39,11 +44,30 @@ describe('Simyo AuthHandler', () => { expect(result.sessionToken).toBe('sess-abc'); expect(stateManager.get('sessionToken')).toBe('sess-abc'); expect(stateManager.get('phoneNumber')).toBe('0612345678'); + expect(stateManager.get('mfaStatus')).toBe('DISABLED_BY_CUSTOMER'); expect(stateManager.get('password')).toBeUndefined(); expect(Object.prototype.hasOwnProperty.call(stateManager.getState(), 'password')).toBe(false); expect(authHandler.isLoggedIn()).toBe(true); }); + it('MFA 未关闭时应阻断并提示', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ + result: { + sessionToken: 'sess-mfa', + mfaStatus: 'REQUIRED', + mfaMethod: 'EMAIL', + methodHint: 'email' + } + }) + }); + + await expect(authHandler.login('0612345678', 'secret')).rejects.toThrow(/MFA|二次验证|mfa/i); + expect(stateManager.get('sessionToken')).toBe('sess-mfa'); + }); + it('响应缺少 sessionToken 应抛错', async () => { global.fetch.mockResolvedValueOnce({ ok: true, @@ -58,4 +82,10 @@ describe('Simyo AuthHandler', () => { await expect(authHandler.login('0612345678', 'secret')).rejects.toThrow(); expect(stateManager.get('sessionToken')).toBe(''); }); + + it('isMfaDisabledOrComplete 识别官方 DISABLED_BY_CUSTOMER', () => { + expect(isMfaDisabledOrComplete('DISABLED_BY_CUSTOMER')).toBe(true); + expect(isMfaDisabledOrComplete('REQUIRED')).toBe(false); + expect(isMfaDisabledOrComplete('')).toBe(true); + }); }); diff --git a/tests/simyo/device-change-handler.test.js b/tests/simyo/device-change-handler.test.js index 5b91909..4347925 100644 --- a/tests/simyo/device-change-handler.test.js +++ b/tests/simyo/device-change-handler.test.js @@ -1,8 +1,8 @@ /** - * Simyo 设备更换处理模块集成测试 + * Simyo 设备更换处理模块集成测试(对齐 HAR 状态机,仅 EMAIL) */ -import { deviceChangeHandler } from '../../src/simyo/js/modules/device-change-handler.js'; +import { deviceChangeHandler, ESIM_STATUS } from '../../src/simyo/js/modules/device-change-handler.js'; import { stateManager } from '../../src/simyo/js/modules/state-manager.js'; describe('Simyo DeviceChangeHandler 集成覆盖', () => { @@ -12,27 +12,40 @@ describe('Simyo DeviceChangeHandler 集成覆盖', () => { global.fetch.mockReset(); }); - it('应完成设备更换主流程:applyNewEsim -> verifyCode', async () => { - const jsonHeaders = { get: (name) => name === 'content-type' ? 'application/json' : null }; + it('应完成设备更换主流程:GET simcard → POST simcard → verifyCode', async () => { + const jsonHeaders = { get: (name) => (name === 'content-type' ? 'application/json' : null) }; global.fetch + // GET status .mockResolvedValueOnce({ headers: jsonHeaders, ok: true, json: async () => ({ - success: true, result: { - message: 'apply success', + eSimStatus: ESIM_STATUS.START_REQUEST, + canCreateESim: 'Available', remainingNumberOfTries: 3 } }) }) + // POST apply .mockResolvedValueOnce({ headers: jsonHeaders, ok: true, json: async () => ({ - success: true, result: { - message: 'verify success', + success: true, + reason: 'Available', + remainingNumberOfTries: 3 + } + }) + }) + // verify + .mockResolvedValueOnce({ + headers: jsonHeaders, + ok: true, + json: async () => ({ + result: { + success: true, remainingNumberOfTries: 2 } }) @@ -42,18 +55,69 @@ describe('Simyo DeviceChangeHandler 集成覆盖', () => { const verifyResult = await deviceChangeHandler.verifyCode('123456'); expect(applyResult.success).toBe(true); + expect(applyResult.reason).toBe('Available'); expect(verifyResult.success).toBe(true); expect(stateManager.get('validationCode')).toBe('123456'); - expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenCalledTimes(3); expect(global.fetch).toHaveBeenNthCalledWith( 1, '/api/simyo/settings/simcard', - expect.objectContaining({ method: 'POST' }) + expect.objectContaining({ method: 'GET' }) ); expect(global.fetch).toHaveBeenNthCalledWith( 2, + '/api/simyo/settings/simcard', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ initialValidationMethod: 'EMAIL', esim: true }) + }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 3, '/api/simyo/esim/verify-code', expect.objectContaining({ method: 'POST' }) ); }); + + it('已在等待验证码时应跳过重复 POST', async () => { + const jsonHeaders = { get: (name) => (name === 'content-type' ? 'application/json' : null) }; + global.fetch.mockResolvedValueOnce({ + headers: jsonHeaders, + ok: true, + json: async () => ({ + result: { + eSimStatus: ESIM_STATUS.WAITING_FOR_VALIDATION_CODE, + canCreateESim: 'AlreadyOrderedSimcardEsim', + remainingNumberOfTries: 2 + } + }) + }); + + const applyResult = await deviceChangeHandler.applyNewEsim(); + expect(applyResult.success).toBe(true); + expect(applyResult.alreadyPending).toBe(true); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + '/api/simyo/settings/simcard', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('已 READY_FOR_DOWNLOAD 时应提示可直接获取 eSIM', async () => { + const jsonHeaders = { get: (name) => (name === 'content-type' ? 'application/json' : null) }; + global.fetch.mockResolvedValueOnce({ + headers: jsonHeaders, + ok: true, + json: async () => ({ + result: { + eSimStatus: ESIM_STATUS.READY_FOR_DOWNLOAD, + remainingNumberOfTries: 2 + } + }) + }); + + const applyResult = await deviceChangeHandler.applyNewEsim(); + expect(applyResult.readyForDownload).toBe(true); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/test_simyo_esim.html b/tests/test_simyo_esim.html index 372fba9..251ecfe 100644 --- a/tests/test_simyo_esim.html +++ b/tests/test_simyo_esim.html @@ -540,8 +540,8 @@ 'X-Client-Platform': 'ios', 'X-Client-Version': '4.28.0', 'X-Device-ID': 'E766D17B-BDF5-43B8-8807-35E43BA129E2', - // 官方抓包:版本号后两个空格 + iOS 27.0 + iPhone16,1 - 'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)', + // 官方 eSIM 更换抓包:版本号后两个空格 + iOS 18.2 + iPhone12,8 + 'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)', 'Content-Type': 'application/json', 'Accept': 'application/json', 'Connection': 'keep-alive',