الأدلة
In-app chat (WebView)
Embed the full chat widget in any mobile or desktop app with a WebView, full features, auto-updating.
The fastest way to add customer support inside a native app is to load our hosted full-screen widget page in a WebView. You get the exact same widget as the website, Home, Messages, Help center, Search, emoji, attachments, timestamps, read receipts, resolve, and it updates automatically. Once integrated, the app never needs another release for widget changes. This is the same approach Crisp and Intercom use on mobile.
Quick start
Point a WebView at this URL. For a logged-in user you must include externalId and email (and ideally name), otherwise the agent only ever sees an anonymous visitor. URL-encode every value:
https://<你的域名>/api/widget-embed?appId=YOUR_APP_ID&externalId=USER_ID&email=USER_EMAIL&name=USER_NAMEOnly when the user is not logged in do you drop to the anonymous form:
https://<你的域名>/api/widget-embed?appId=YOUR_APP_ID⚠️ Agent shows an "anonymous visitor" with no email? That is the #1 integration mistake: your WebView URL is missing email/externalId. The server can't invent them, the app must pass them. If the visitor's id looks like anon-…, nothing was passed.
JavaScript / any WebView (non-Flutter)
Electron, desktop shells, React Native, or a native WKWebView where you set the URL yourself, build the URL in JS. URLSearchParams encodes every value for you:
// 打开客服 WebView 前,用当前登录用户拼出地址。
function buildSupportUrl(domain, user) {
const params = new URLSearchParams({
appId: 'YOUR_APP_ID',
platform: 'windows', // 强烈建议:决定这个访客能看到哪些帮助文档
// ios / macos_appstore / macos / android / windows / web
// locale: userSelectedLanguage, // 可选:App 有语言开关时才传,并加 forceLocale: '1'
// 不传 = 跟随设备语言(覆盖 App Store 的 50 种本地化)
// —— 登录用户务必传这几项,否则后台是「匿名访客」——
externalId: user.id, // 你系统里的用户唯一 ID
email: user.email, // 用户邮箱(关键)
name: user.name || '', // 用户名(可选)
// hmac: user.hmac, // 可选:由你服务端算,防止冒充(见下方 HMAC)
// attrs: JSON.stringify({ 套餐: user.plan, 到期日: user.expireAt }), // 可选:自定义资料
});
return `https://${domain}/api/widget-embed?${params.toString()}`;
}
// 用法:把返回的 url 交给 WebView 加载(替换掉原来只有 ?appId=... 的地址)。
const url = buildSupportUrl('singchatweb.com', currentUser);
myWebView.loadURL(url); // Electron: win.loadURL(url);原生:注入该 URLIntegration steps (for the app developer)
- Add three dependencies to
pubspec.yaml:webview_flutter,http,shared_preferences. - Copy the complete
support_service.dartfrom the section below intolib/(the 5 anti-block domains are already filled in). - Call it from your support button, and always pass the logged-in user , otherwise the agent sees an anonymous visitor with no name or email:
// 在「联系客服」按钮里,务必带上当前登录的用户 + 尽量多的资料(越详细,客服越好处理)。 // 下面这些字段名换成你们 user 对象里的真实字段;用不到的删掉即可。 await openSupport(context, user: SupportUser( externalId: currentUser.id, // VPN 用户唯一 ID email: currentUser.email, name: currentUser.nickname, hmac: hmacFromYourServer, // 强烈建议:由你服务端算,防冒充 appVersion: appVersion, // App 版本(package_info_plus 取,或写死) deviceModel: deviceModel, // 设备型号(device_info_plus 取) attributes: { '套餐': currentUser.plan, // 会员/套餐 '会员状态': currentUser.isActive ? '有效' : '已过期', '到期日': currentUser.expireDate, '剩余流量': currentUser.dataLeft, '是否试用': currentUser.isTrial ? '是' : '否', '注册时间': currentUser.registeredAt, '支付方式': currentUser.payMethod, '当前节点': currentUser.currentNode, // VPN 排障常用 }, )); - Rebuild and ship one app release.
Already integrated an earlier version? Just replace the file with the latest one below and add the user: argument to your existing openSupport call.
Flutter (webview_flutter)
Add webview_flutter: ^4.x to pubspec.yaml, then:
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class SupportPage extends StatefulWidget {
const SupportPage({super.key});
@override
State<SupportPage> createState() => _SupportPageState();
}
class _SupportPageState extends State<SupportPage> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
// 登录用户务必带上 externalId + email,否则后台是「匿名访客」(没有姓名/邮箱)。
// 未登录时才只传 appId + locale。Uri.https 会自动做 URL 编码。
final uri = Uri.https('singchatweb.com', '/api/widget-embed', {
'appId': 'YOUR_APP_ID',
'platform': Platform.isIOS ? 'ios' : 'android', // 强烈建议:决定可见的帮助文档
// 不传 locale = 跟随设备语言(已覆盖 App Store 的 50 种本地化)。
// App 自己有语言开关时才传,并同时加 'forceLocale': '1':
// 'locale': appSettings.selectedLanguage,
'externalId': user.id, // 你系统里的用户唯一 ID —— 关键
'email': user.email, // 用户邮箱 —— 关键
'name': user.name, // 可选
// 'hmac': hmacFromYourServer, // 可选:由你服务端算,防冒充
});
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.white)
..loadRequest(uri);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Support')),
// Full-screen: drop the appBar and use your own close button.
body: SafeArea(child: WebViewWidget(controller: _controller)),
);
}
}
// Open it from wherever your "Support" button lives:
// Navigator.push(context,
// MaterialPageRoute(builder: (_) => const SupportPage()));For iOS/Android WebViews in other stacks (Swift WKWebView, KotlinWebView, React Native react-native-webview) the idea is identical: load the URL, enable JavaScript.
Native iOS & Android
No SDK to install , you build the same URL and hand it to the WebView. Note the Mac case: an App Store build and a DMG build are the same operating system, so the value has to come from a build flag, not from a runtime check.
// ── iOS (Swift / WKWebView) ────────────────────────────────
// Mac Catalyst / macOS 版把 platform 换成 macos_appstore 或 macos。
#if targetEnvironment(macCatalyst)
let platform = "macos_appstore" // 上架版;官网 DMG 版传 "macos"
#else
let platform = "ios" // iPhone 与 iPad 同属 ios
#endif
var comps = URLComponents(string: "https://\(domain)/api/widget-embed")!
comps.queryItems = [
.init(name: "appId", value: "YOUR_APP_ID"),
.init(name: "platform", value: platform),
.init(name: "externalId", value: user.id), // 登录用户务必传
.init(name: "email", value: user.email), // 登录用户务必传
.init(name: "name", value: user.name),
// App 自己有语言开关时才传这两项:
// .init(name: "locale", value: settings.language),
// .init(name: "forceLocale", value: "1"),
]
webView.load(URLRequest(url: comps.url!)) // URLComponents 自动做 URL 编码
// ── Android (Kotlin / WebView) ─────────────────────────────
val url = Uri.parse("https://$domain/api/widget-embed")
.buildUpon()
.appendQueryParameter("appId", "YOUR_APP_ID")
.appendQueryParameter("platform", "android")
.appendQueryParameter("externalId", user.id)
.appendQueryParameter("email", user.email)
.appendQueryParameter("name", user.name)
// .appendQueryParameter("locale", settings.language)
// .appendQueryParameter("forceLocale", "1")
.build()
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true // 挂件用 localStorage 记住访客
webView.loadUrl(url.toString())URL parameters
appId(required), your inbox App ID.locale, UI language. Omit it in almost every case , the widget then follows the device language reported by the WebView, which is what users expect. The chrome is translated into all 50 App Store Connect localizations; anything outside that list falls back to English. Pass it only when your app has its own in-app language switcher , then send the language the user picked, plusforceLocale=1so it wins over the device setting. Never hard-code a value: a hard-codedzh-Hansis why a Japanese app can end up showing a Chinese widget.platform(strongly recommended), which build the visitor is on. It decides which help articles they see and whether the AI answers under App Store rules. Omit it and everyone counts asweb, so nothing is filtered. Values:ios(iPhone and iPad),macos_appstore(Mac App Store build),macos(direct DMG build),android,windows,web.externalId, your app's unique user id. Passing it lets the agent recognize the user and merge their history.email,name,avatarUrl, profile shown to agents.hmac, identity signature (see below). Optional.
Hiding content per platform (App Store compliance)
App Store rule 3.1.1 does not allow an iOS app to show external purchase, subscription, or referral content. Every help article and FAQ therefore carries a per-platform visibility setting , open the Help Center or FAQ page in your dashboard and toggle the platform chips on that item. Visitors on a selected platform stop seeing it in the list, cannot open it by direct link, and the AI stops retrieving it, so the content never reaches the answer either.
Two things decide whether this works at all. First, your app has to sendplatform , the filter has nothing to match on otherwise. Second, the value has to reflect the build, not just the operating system:
- iPad is not separate. It runs the same iOS app under the same rules, so
ioscovers iPhone and iPad together. - Mac has two builds. A Mac App Store build is bound by rule 3.1.1; a DMG you ship from your own site is not. They are the same operating system, so nothing at runtime can tell them apart , decide it at build time (an Xcode flag, for example) and send
macos_appstoreormacosaccordingly. Get it wrong and either the store build exposes subscription content during review, or the DMG build hides half your help center for no reason.
Changes take effect immediately , the setting lives in your dashboard, not in the app, so you never ship a release to change what is visible.
Verified identity (HMAC, optional)
To prove an externalId really is your logged-in user (and stop impersonation), pass an HMAC signature. Compute it on your server , never put the secret key in the app.
hmac = HMAC-SHA256(secretKey, externalId), output as lowercase hex.- Payload is the
externalId(oremailif you have no id). secretKeyis your inbox secret (one per inbox, get it from your inbox settings). Withouthmacthe visitor is treated as unverified.
import { createHmac } from 'node:crypto';
// SECRET_KEY lives only on your server, never ship it inside the app.
const hmac = createHmac('sha256', SECRET_KEY)
.update(externalId) // exactly the externalId you pass to the widget
.digest('hex');
// Return { externalId, email, hmac } to the app, which appends them to the URL.Platform notes
- Android: keep the
INTERNETpermission; intercept the back button so it goes back inside the WebView first. Recentwebview_fluttersupports<input type="file">for attachments. - iOS: HTTPS works with no ATS exception. Image upload is a built-in widget feature:
WKWebViewopens the native photo picker on its own, so you build nothing. Choosing from the photo library needs no permission. Only addNSCameraUsageDescriptiontoInfo.plistif you want the "Take Photo" camera option, otherwise the app crashes when a user taps it.
Staying reachable in censored networks
If your users are on a network that blocks the support domain, the WebView will not load , and note this is a reachability problem, not a WebView one: a native SDK hits the same domains and fails identically. Handle it the way VPN apps handle their own entry servers.
- Ship a seed list of backup domains. On open, probe
GET /api/pingon each and use the first that returns 200. - Never remember just one domain, re-probe the union of the seed list and the last-saved pool on every launch, and fetch the list with a cache-buster (
?t=…) so a blocked domain never sticks. - All blocked? Show a native fallback(email / Telegram / your site) plus a "connect the VPN, then retry" hint, never a blank screen.
- Once any domain works, refresh the live pool from
GET /api/support-endpointsso you can rotate domains without an app release. - The realtime channel follows whichever domain loaded the widget, so a working mirror carries the whole stack (page, API, and WebSocket).
- At the native layer, resolve via DoH or pinned IPs to dodge DNS poisoning (you already do this for the VPN). When the VPN is connected, the widget loads through the tunnel regardless.
import 'dart:convert';
import 'package:http/http.dart' as http;
// Seed list is baked into the app. Keep several DIFFERENT domains and keep at
// least one alive long-term, this is your bootstrap, like a VPN's entry servers.
const seedDomains = ['singchat.org', 'support-alt-1.com', 'support-alt-2.com'];
// ALWAYS re-probe on launch, never trust a single "remembered" domain, or a
// blocked one will stick forever. Probe the union of seed + last-saved pool.
Future<String?> pickReachableDomain(List<String> domains) async {
for (final d in domains) {
try {
final r = await http.get(Uri.https(d, '/api/ping'))
.timeout(const Duration(seconds: 4));
if (r.statusCode == 200) return d; // first that answers wins
} catch (_) {/* blocked / unreachable, try the next */}
}
return null; // everything blocked
}
Future<void> openSupportResilient(List<String> savedPool) async {
final domains = {...seedDomains, ...savedPool}.toList(); // union, de-duped
final domain = await pickReachableDomain(domains);
if (domain == null) {
showNativeFallback(); // email / Telegram / your site + "connect the VPN, then retry"
return;
}
openSupport(Uri.https(domain, '/api/widget-embed', {'appId': 'YOUR_APP_ID'}));
// Refresh the pool from the REACHABLE domain, with a cache-buster so you never
// get a stale list. Persist it for next launch (merged with the seed list).
try {
final r = await http.get(Uri.https(domain, '/api/support-endpoints',
{'t': DateTime.now().millisecondsSinceEpoch.toString()}));
final list = (jsonDecode(r.body)['endpoints'] as List).cast<String>();
await persistPool(list);
} catch (_) {}
}The same thing in plain JavaScript (Electron / desktop / RN). Note the identity params (externalId/email/hmac) are identical on every mirror domain, the HMAC signs the externalId, not the domain, so rotating domains never breaks identity, just append the same params to whichever domain is reachable:
// 种子域名(顺序=优先探测顺序;保留几个不同域名,至少一个长期可达)。
const SEED_DOMAINS = ['singchatly.com', 'singchatweb.com', 'singchatapp.com', 'singchatapi.com', 'singchat.org'];
// 逐个探 /api/ping,用第一个能通的。每次都重探,绝不记死一个被墙的。
async function pickReachableDomain(domains) {
for (const d of domains) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 4000);
const r = await fetch(`https://${d}/api/ping`, { signal: ctrl.signal });
clearTimeout(t);
if (r.ok) return d; // 第一个应答的即用
} catch { /* 连不上 → 试下一个 */ }
}
return null; // 全部连不上
}
// 入口:在「联系客服」按钮里调用。
async function openSupport(user) {
const saved = JSON.parse(localStorage.getItem('singchat_pool') || '[]');
const domains = [...new Set([...SEED_DOMAINS, ...saved])]; // 种子 ∪ 上次持久化,去重
const domain = await pickReachableDomain(domains);
if (!domain) { showNativeFallback(); return; } // 全被墙 → 兜底(邮箱/Telegram)
// 关键:身份参数(externalId/email/name/hmac)在任何镜像域名下都一样,直接拼上。
myWebView.loadURL(buildSupportUrl(domain, user)); // buildSupportUrl 见上一段
// 后台从可达域名刷新完整域名池(带 ?t= 防缓存),持久化供下次探测。
try {
const r = await fetch(`https://${domain}/api/support-endpoints?t=${Date.now()}`);
const hosts = (await r.json()).endpoints.map((u) => new URL(u).host);
localStorage.setItem('singchat_pool', JSON.stringify(hosts));
} catch {}
}Copy-paste file (everything above, 5 domains included)
Prefer to drop in a single file? This is the whole thing, domain probing, auto-switch when a domain is blocked, native fallback, and the full-screen WebView, with the 5 domains pre-filled. Add the three dependencies, drop it into lib/, and call openSupport(context) from your support button.
// support_service.dart,SingChat 客服接入(含抗封锁多域名探测 + 兜底)。复制即用。
//
// 1) pubspec.yaml 加依赖:
// webview_flutter: ^4.7.0
// http: ^1.2.0
// shared_preferences: ^2.2.0
// 2) 把本文件放进 lib/ 下。
// 3) 在你的「联系客服」按钮点击回调里调用: await openSupport(context);
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:webview_flutter/webview_flutter.dart';
// 你的收件箱 App ID
const _appId = 'sl_web_bc11b2c3';
// 种子域名清单(顺序=优先探测顺序;保留几个不同域名,至少一个长期可达)
const _seedDomains = [
'singchatly.com',
'singchatweb.com',
'singchatapp.com',
'singchatapi.com',
'singchat.org',
];
/// 登录用户资料 , 传给客服后台,让访客显示姓名/邮箱/套餐/设备,而不是「匿名访客」。
/// 越详细越好:attributes 里想记什么就传什么(套餐、到期日、剩余流量、是否试用…)。
class SupportUser {
const SupportUser({
this.externalId,
this.email,
this.name,
this.hmac,
this.appVersion,
this.deviceModel,
this.attributes,
});
final String? externalId; // 你系统里的用户唯一 ID
final String? email;
final String? name;
final String? hmac; // 由你服务端算:HMAC-SHA256(收件箱密钥, externalId),防冒充
final String? appVersion; // App 版本(后台「App版本」列)
final String? deviceModel; // 设备型号(后台「设备型号」列)
final Map<String, String>? attributes; // 自定义资料,如 {'套餐':'Rich','到期日':'2026-12-01'}
}
/// 入口:在「联系客服」按钮里调用。自动挑一个能连通的域名打开客服;全被墙则弹兜底。
///
/// ⚠️ 已登录用户务必传 user,否则客服后台看到的是匿名访客(没有姓名/邮箱)。越详细越好:
/// await openSupport(context, user: SupportUser(
/// externalId: currentUser.id, email: currentUser.email, name: currentUser.nickname,
/// hmac: hmacFromYourServer, // 强烈建议,防止冒充他人
/// appVersion: appVersion, deviceModel: deviceModel,
/// attributes: {
/// '套餐': currentUser.plan, '会员状态': currentUser.isActive ? '有效' : '已过期',
/// '到期日': currentUser.expireDate, '剩余流量': currentUser.dataLeft,
/// '是否试用': currentUser.isTrial ? '是' : '否', '当前节点': currentUser.currentNode,
/// },
/// ));
/// locale 留空 = 跟随设备语言(已覆盖 App Store 的 50 种本地化,其余回退英文)。
Future<void> openSupport(BuildContext context,
{String? locale, SupportUser? user}) async {
final domain = await _pickReachableDomain();
if (domain == null) {
_showFallback(context); // 全部被墙 → 原生兜底
return;
}
if (context.mounted) {
Navigator.push(context, MaterialPageRoute(
builder: (_) => _SupportPage(domain: domain, locale: locale, user: user)));
}
_refreshPool(domain); // 后台刷新域名池,供下次探测(免发版轮换)
}
/// 合并「种子 ∪ 上次持久化清单」,逐个探 /api/ping,用第一个能通的。每次都重探,绝不记死一个。
Future<String?> _pickReachableDomain() async {
final saved = await _loadPool();
final domains = <String>{..._seedDomains, ...saved}.toList();
for (final d in domains) {
try {
final r = await http.get(Uri.https(d, '/api/ping'))
.timeout(const Duration(seconds: 4));
if (r.statusCode == 200) return d; // 第一个应答的即用
} catch (_) {/* 连不上(被墙/超时)→ 试下一个 */}
}
return null; // 全部连不上
}
/// 从可达域名刷新完整域名池(带 ?t= 防缓存),持久化供下次启动。
Future<void> _refreshPool(String domain) async {
try {
final t = DateTime.now().millisecondsSinceEpoch.toString();
final r = await http.get(Uri.https(domain, '/api/support-endpoints', {'t': t}))
.timeout(const Duration(seconds: 6));
final hosts = (jsonDecode(r.body)['endpoints'] as List)
.cast<String>().map((u) => Uri.parse(u).host).toList();
await _savePool(hosts);
} catch (_) {}
}
Future<List<String>> _loadPool() async {
final p = await SharedPreferences.getInstance();
return p.getStringList('singchat_pool') ?? const [];
}
Future<void> _savePool(List<String> hosts) async {
final p = await SharedPreferences.getInstance();
await p.setStringList('singchat_pool', hosts);
}
/// 全被墙时的兜底(把邮箱 / Telegram 换成你们自己的)。
void _showFallback(BuildContext context) {
showDialog(context: context, builder: (_) => AlertDialog(
title: const Text('暂时连不上在线客服'),
content: const Text('''请先连接 VPN 后重试,或通过以下方式联系我们:
邮箱:support@singlinkvpn.com
Telegram:@your_support'''),
actions: [TextButton(
onPressed: () => Navigator.pop(context), child: const Text('知道了'))],
));
}
/// 全屏 WebView 客服页 , 加载即拥有网页版全部功能。
/// ⚠️ Mac 必须区分上架版与 DMG 直装版:前者受 App Store 3.1.1 约束、后者不受,
/// 而两者是同一个系统、运行期分辨不出来。用编译期常量决定:
/// flutter build macos --dart-define=MAC_APP_STORE=true
const _isMacAppStore = bool.fromEnvironment('MAC_APP_STORE');
String _platformName() {
if (Platform.isIOS) return 'ios'; // iPhone 与 iPad 同属 ios
if (Platform.isAndroid) return 'android';
if (Platform.isMacOS) return _isMacAppStore ? 'macos_appstore' : 'macos';
if (Platform.isWindows) return 'windows';
return 'web';
}
class _SupportPage extends StatefulWidget {
const _SupportPage({required this.domain, this.locale, this.user});
final String domain;
final String? locale;
final SupportUser? user;
@override
State<_SupportPage> createState() => _SupportPageState();
}
class _SupportPageState extends State<_SupportPage> {
late final WebViewController _c;
@override
void initState() {
super.initState();
final u = widget.user;
final params = <String, String>{
'appId': _appId,
if (widget.locale != null) 'locale': widget.locale!,
'platform': _platformName(), // 决定可见的帮助文档 + AI 是否受 App Store 约束
if (u?.externalId != null) 'externalId': u!.externalId!,
if (u?.email != null) 'email': u!.email!,
if (u?.name != null) 'name': u!.name!,
if (u?.hmac != null) 'hmac': u!.hmac!,
if (u?.appVersion != null) 'appVersion': u!.appVersion!,
if (u?.deviceModel != null) 'deviceModel': u!.deviceModel!,
if (u?.attributes != null && u!.attributes!.isNotEmpty)
'attrs': jsonEncode(u.attributes),
};
final uri = Uri.https(widget.domain, '/api/widget-embed', params);
_c = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.white)
..loadRequest(uri);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('在线客服')),
body: SafeArea(child: WebViewWidget(controller: _c)),
);
}Prefer a native UI?
If you need a hand-built native chat surface instead of a WebView, use the logic-only clients on the Mobile SDKs page. For most apps the WebView above is faster to ship and always full-featured.