feat: migrate platform login scenes and native login flows

This commit is contained in:
2026-09-06 09:06:00 +08:00
parent f2a948f059
commit 695d3be25c
140 changed files with 7599 additions and 4397 deletions
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "c1b8189d-1134-4ff3-9426-d2668fd9ab6f",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "0693ece5-bed1-4b21-8ae7-7393c2eee8a7",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4deb8dec-916d-4dda-b709-ac5f38fa0253",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -70,7 +70,7 @@ function sample(random: () => number, key: string): number {
return sampleLocalRandom(random, (reason, cause) => wrap(key, 'random', reason, cause));
}
function generate(random: () => number, key: string): LoginAccountIdentity {
export function generate(random: () => number, key: string): LoginAccountIdentity {
const id = Math.floor(sample(random, key) * 100000);
const avatar = AVATARS[Math.floor(sample(random, key) * AVATARS.length)]!;
const sex = Math.floor(sample(random, key) * 2) + 1;
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "bb7c9b58-c08f-4dc9-b8d1-57c2b20f9c28",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "8499ef07-6051-4d8f-aca6-11996e448b7f",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,169 @@
import type { LoginAccountIdentity, DeviceLoginMode } from '../../protocol/contracts/login-contract.ts';
import type { LoginPolicy } from '../../presentation/login-policy.ts';
import { generate } from '../local/visitor-account.ts';
import { readPhoneCode } from './phone-code.ts';
export interface LoginNativeSettings {
accreditlogin(provider: number): void;
getcompareCode(): number | string;
}
interface Options {
storage: {getItem(key:string):string|null;setItem(key:string,value:string):void};
settings?:LoginNativeSettings; random():number; now():number;
gameid:string|number;agentid:string|number;policy:LoginPolicy;
submit(account:LoginAccountIdentity,device:DeviceLoginMode):void;tip(message:string):void;
}
/** MD5 is the legacy nickname derivation, not a security primitive. */
export function nicknameHash(input:string):string {
const bytes=Array.from(new TextEncoder().encode(input));const bitLength=bytes.length*8;
bytes.push(128);while(bytes.length%64!==56)bytes.push(0);
for(let i=0;i<8;i++)bytes.push(i<4?(bitLength>>>(8*i))&255:0);
const shifts=[7,12,17,22,5,9,14,20,4,11,16,23,6,10,15,21];
let state=[0x67452301,0xefcdab89,0x98badcfe,0x10325476];
for(let offset=0;offset<bytes.length;offset+=64){
const words=Array.from({length:16},(_,j)=>bytes[offset+j*4]|bytes[offset+j*4+1]<<8|bytes[offset+j*4+2]<<16|bytes[offset+j*4+3]<<24);
let [a,b,c,d]=state;
for(let i=0;i<64;i++){
const round=Math.floor(i/16);let f:number,g:number;
if(round===0){f=(b&c)|(~b&d);g=i;}else if(round===1){f=(d&b)|(~d&c);g=(5*i+1)%16;}
else if(round===2){f=b^c^d;g=(3*i+5)%16;}else{f=c^(b|~d);g=(7*i)%16;}
const sum=(a+f+Math.floor(Math.abs(Math.sin(i+1))*4294967296)+words[g])|0;
const shift=shifts[round*4+i%4];const next=(b+((sum<<shift)|(sum>>>(32-shift))))|0;
a=d;d=c;c=b;b=next;
}
state=state.map((v,i)=>(v+[a,b,c,d][i])|0);
}
return state.map(v=>[0,8,16,24].map(s=>((v>>>s)&255).toString(16).padStart(2,'0')).join('')).join('');
}
function record(value:unknown):Record<string,unknown>{
if(typeof value==='string')value=JSON.parse(value);
if(!value||typeof value!=='object'||Array.isArray(value))throw Error('Invalid sharelogin account');
return value as Record<string,unknown>;
}
function accountFrom(value:unknown):LoginAccountIdentity {
const data=record(value);
for(const field of ['openid','headimgurl','nickname','city','Province'])if(typeof data[field]!=='string')throw Error(`Invalid sharelogin.${field}`);
if(typeof data.unionid!=='string'&&typeof data.unionid!=='number')throw Error('Invalid sharelogin.unionid');
const sex=data.sex?data.sex:1; // Func.sharelogin explicitly owns this default.
if(typeof sex!=='number'||!Number.isFinite(sex))throw Error('Invalid sharelogin.sex');
if(!data.nickname&&!data.headimgurl)throw Error('授权失败,请重新授权!');
return {openid:data.openid as string,avatar:data.headimgurl as string,nickname:data.nickname as string,
city:data.city as string,province:data.Province as string,unionid:data.unionid as string|number,sex};
}
/** Exact native/cached account boundary; UI never invents account or server fields. */
/** 账户统一入口:协议确认、缓存选择、原生授权及提交模式均由此处管理。 */
export class LoginAccounts {
readonly versionState:0|1;
private hiddenClicks=0;
/** 每次启动默认同意;按钮、缓存登录及原生回调读取同一份状态。 */
private agreementAccepted=true;
get agreed():boolean{return this.agreementAccepted;}
setAgreement(accepted:boolean):void{
if(accepted&&!this.agreementAccepted)this.options.tip('');
this.agreementAccepted=accepted;
}
/** 未同意时只提示,不调用授权、不写缓存、不提交请求。 */
requireAgreement():boolean{
if(this.agreementAccepted)return true;
this.options.tip('未同意协议');return false;
}
constructor(private readonly options:Options){
// Browser source explicitly uses the legacy getVersionState default of zero.
const state=options.settings?Number(options.settings.getcompareCode()):0;
if(state!==0&&state!==1)throw Error('Invalid getcompareCode result');this.versionState=state;
}
private read(key:string,cookie:boolean):Record<string,unknown>|null {
if(cookie&&this.versionState===0){
const expiry=this.options.storage.getItem('tsgame_validtime');
if(expiry===null)return null;
if(!Number.isFinite(Number(expiry)))throw Error('Invalid tsgame_validtime');
if(this.options.now()>=Number(expiry))return null;
}
const raw=this.options.storage.getItem(key);return raw===null?null:record(raw);
}
private save(account:LoginAccountIdentity,key:string,telphone?:string){
const data={openid:account.openid,headimgurl:account.avatar,nickname:account.nickname,sex:account.sex,
city:account.city,Province:account.province,unionid:account.unionid,...(telphone===undefined?{}:{telphone})};
this.options.storage.setItem('tsgame_validtime',String(this.options.now()+48*60*60*1000));
this.options.storage.setItem(key,JSON.stringify(data));
}
/** 保留原工程缓存优先级,游客开关先于手机模式判断。 */
cached():LoginAccountIdentity|null {
if(this.versionState!==0)return null;
const policy=this.options.policy;
const data=this.read(policy.showVisitor?'tsgame_visitorinfo':policy.deviceLogin?'tsgame_devicePlayer':'tsgame_wxinfo',true);
return data===null?null:accountFrom(data);
}
autoLogin():void {
if(!this.requireAgreement())return;
const account=this.cached();if(account===null)throw Error('No cached login');
// showVisitor selects the visitor cache above, even when the phone button is enabled.
if(!this.options.policy.showVisitor&&this.options.policy.deviceLogin){
const data=this.read('tsgame_devicePlayer',true)!;
if(typeof data.telphone!=='string')throw Error('Cached device account missing telphone');
this.options.submit(account,{enabled:true,telphone:data.telphone});
}else this.options.submit(account,{enabled:false});
}
authorize(provider:'wechat'|'qq'):void {
if(!this.requireAgreement())return;
if(!this.options.settings){this.options.tip('当前宿主未提供原生登录授权接口');return;}
if(provider==='wechat')this.options.storage.setItem(`tsgame_playerid_${this.options.gameid}${this.options.agentid}`,'');
this.options.settings.accreditlogin(provider==='wechat'?2:1);
}
acceptNative(data:unknown,channel:'global'|'bridge'):void {
if(!this.requireAgreement())return;
const account=accountFrom(data);
this.save(account,channel==='global'&&this.options.policy.showVisitor?'tsgame_visitorinfo':'tsgame_wxinfo');
this.options.submit(account,{enabled:false});
}
/** 仅本次手机登录要求 tel,不能依据按钮显示配置要求游客响应携带手机号。 */
loginSucceeded(value:unknown,mode:DeviceLoginMode):void {
const data=record(value);
if(data.state!==0)throw Error('Cannot cache an unsuccessful login');
if(typeof data.playerid!=='string'&&(typeof data.playerid!=='number'||!Number.isFinite(data.playerid)))throw Error('Invalid login playerid');
// Button availability is not the authentication mode of this request.
if(mode.enabled){
if(typeof data.tel!=='string')throw Error('Device login response missing tel');
const account=accountFrom({...data,headimgurl:data.avatar,Province:data.province});
this.save(account,'tsgame_devicePlayer',data.tel);
this.options.storage.setItem('tsgame_dLogin','1');
}
this.options.storage.setItem(`tsgame_playerid_${this.options.gameid}${this.options.agentid}`,String(data.playerid));
}
visitor():void {
if(!this.requireAgreement())return;
const cached=this.read('tsgame_visitorinfo',false);
const account=cached===null?generate(this.options.random,'tsgame_visitorinfo'):accountFrom(cached);
this.save(account,this.options.policy.showVisitor?'tsgame_visitorinfo':'tsgame_wxinfo');
this.options.submit(account,{enabled:false});
}
hiddenFirst():void{this.hiddenClicks++;}
phonePanelState(){return readPhoneCode(this.options.storage.getItem('sgame_phoneCode'),this.options.now());}
hiddenSecond():void{if(this.hiddenClicks>=5&&this.options.policy.hiddenVisitor){this.hiddenClicks=0;this.visitor();}}
private deviceAccount(phone?:string):LoginAccountIdentity {
const cached=this.read('tsgame_devicePlayer',true);if(cached)return accountFrom(cached);
let unionid:string;
if(phone!==undefined)unionid=`phone_${phone}`;
else {
const info=this.read('tsgame_phoneInfo1',false);
if(info&&typeof info.PhoneIMEI==='string'&&info.PhoneIMEI)unionid=info.PhoneIMEI;
else {
const sets=['0123456789','qwertyuioplkjhgfdsazxcvbnm','QWERTYUIOPLKJHGFDSAZXCVBNM'];
unionid=Array.from({length:25},()=>{const chars=sets[Math.round(this.options.random()*2)];return chars[Math.ceil(this.options.random()*100000000)%chars.length];}).join('');
this.options.storage.setItem('tsgame_phoneInfo1',JSON.stringify({...info,PhoneIMEI:unionid}));
}
}
const {avatarUrl,avatarRange}=this.options.policy;
return {openid:unionid,unionid,nickname:`User_${nicknameHash(unionid).slice(0,6)}`,
avatar:avatarUrl?`${avatarUrl}${avatarRange[0]+Math.round(this.options.random()*(avatarRange[1]-avatarRange[0]))}.jpg`:'',
sex:Math.floor(this.options.random()*2)+1,city:'China',province:phone===undefined?'HongKong':'ShangHai'};
}
device():void{if(!this.requireAgreement())return;this.options.submit(this.deviceAccount(),{enabled:false});}
phone(phone:string,code:string):void {
if(!this.requireAgreement())return;
if(!phone||!code){this.options.tip('手机号与验证码均不能为空!');return;}
this.options.submit(this.deviceAccount(phone),{enabled:true,telphone:phone,smmcode:code});
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "57d7618f-3e88-4036-83de-96a6e63be657",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "27e9345c-5f75-4829-be9f-174141dec6e6",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "df3fb4b3-bb92-40f7-b661-05a158dec617",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
/** Logic.readPhoneCode: sgame_phoneCode owns the phone number and 30-second expiry. */
export function readPhoneCode(raw:string|null,now:number):{phoneNumber:string;restTime:number}|null {
if(raw===null)return null;
const data=JSON.parse(raw);
if(!data||typeof data!=='object'||typeof data.phoneNumber!=='string')throw Error('Invalid phoneCode.phoneNumber');
if(!Object.prototype.hasOwnProperty.call(data,'codeTime')||!['string','number'].includes(typeof data.codeTime)||!Number.isFinite(Number(data.codeTime)))throw Error('Invalid phoneCode.codeTime');
const elapsed=now-Number(data.codeTime);
return {phoneNumber:data.phoneNumber,restTime:elapsed>30000?0:Math.trunc(30-elapsed/1000)};
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "bb46066c-73af-4603-9f7a-5579a2ad030b",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "588b5341-c0f6-4eef-bf31-b47c815db2b0",
"files": [],
"subMetas": {},
"userData": {}
}