M3(T-M2-04/T-M3-13): LocalUploadServer 截图上传分享链路落地
补「H5 主动 POST 截图上传分享」链路,对齐原工程 OkHttpPhotoServer + savehandler:
- LocalUploadServer 从骨架实现为真实本机 HTTP server(@ohos.net.socket TCPSocketServer):
127.0.0.1:8099 起逐端口重试 listen;手解极简 HTTP/1.1(\r\n\r\n 分隔 + Content-Length
累积读全 body);仅 POST /testurl,body 取 base64(form name=/raw 兼容)+type → emit
PHOTO_UPLOAD → 回 200;非 /testurl 回 404;16MB 上限;异常 log 不崩溃。
baseUrl 反映实际端口。注:TCPSocketServer 无显式 close,stop 用 off('connect')+释放引用
- common ShareEvents.PHOTO_UPLOAD + PhotoUploadPayload
- ShareProvider:订阅 PHOTO_UPLOAD(仅激活槽处理,避免双实例重复分享)→ 落盘 → 微信图片
分享(type=="1"好友/其他朋友圈)→ sharesuccess;onDestroy 退订;抽 wechatImageByFriend/
wechatSceneByFriend 供面板截图与 POST 上传两条链路共用
真机验证项:H5 真实 POST body 格式、127.0.0.1 端口可达、微信开放平台注册
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cb3673cb1b
commit
187e73a549
+1
-1
@@ -6,7 +6,7 @@ export { BridgeTracer } from './src/main/ets/log/BridgeTracer';
|
||||
export { EventBus, EventPayload } from './src/main/ets/event/EventBus';
|
||||
export { NavEvents, OpenGenericWebPayload, SwitchGamePayload, BackGamePayload }
|
||||
from './src/main/ets/event/NavEvents';
|
||||
export { ShareEvents, SharePanelRequest, SharePanelResult }
|
||||
export { ShareEvents, SharePanelRequest, SharePanelResult, PhotoUploadPayload }
|
||||
from './src/main/ets/event/ShareEvents';
|
||||
export { DIContainer, Factory } from './src/main/ets/di/DIContainer';
|
||||
export { ErrorCenter, ErrorStrategy, ErrorSink } from './src/main/ets/error/ErrorCenter';
|
||||
|
||||
@@ -10,6 +10,19 @@
|
||||
export class ShareEvents {
|
||||
/** 请求弹出分享面板。 */
|
||||
static readonly SHOW_PANEL: string = 'share.showPanel';
|
||||
/**
|
||||
* H5 主动 POST 截图到本机上传服务(LocalUploadServer /testurl)后,由 server 解析出图片+type
|
||||
* 经此事件投给 ShareProvider 走微信图片分享(对齐 Android OkHttpPhotoServer + savehandler)。
|
||||
*/
|
||||
static readonly PHOTO_UPLOAD: string = 'share.photoUpload';
|
||||
}
|
||||
|
||||
/** PHOTO_UPLOAD 载荷(H5 POST 上传的截图)。 */
|
||||
export interface PhotoUploadPayload {
|
||||
/** 图片 base64(可能带 data: 前缀,也可能是纯 base64)。 */
|
||||
imageBase64: string;
|
||||
/** 好友/朋友圈语义(与分享面板 sharefriend 同义):"1" 好友 / 其他 朋友圈。 */
|
||||
type: string;
|
||||
}
|
||||
|
||||
/** SHOW_PANEL 载荷。 */
|
||||
|
||||
@@ -24,7 +24,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { util } from '@kit.ArkTS';
|
||||
import { BridgeController } from 'feature_bridge';
|
||||
import { InboundHandlers, OutboundHandlers, SharetypeBean, ShareSuccessResp } from 'contracts';
|
||||
import { EventBus, Logger, ShareEvents, SharePanelRequest, SharePanelResult } from 'common';
|
||||
import { EventBus, Logger, ShareEvents, SharePanelRequest, SharePanelResult, PhotoUploadPayload } from 'common';
|
||||
import { FileSystem } from 'platform';
|
||||
import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider';
|
||||
import { WeChatApi } from '../wx/WeChatApi';
|
||||
@@ -39,6 +39,8 @@ export class ShareProvider implements CapabilityProvider {
|
||||
private ctx: CapabilityContext | undefined = undefined;
|
||||
/** 截图落盘 + resultEvent 命名计数器(实例自增,避免 Math.random 串扰)。 */
|
||||
private seq: number = 0;
|
||||
/** PHOTO_UPLOAD 订阅退订函数(onDestroy 调用,防双槽实例泄漏/串扰)。 */
|
||||
private photoUploadCancel: (() => void) | undefined = undefined;
|
||||
|
||||
constructor() {
|
||||
ShareProvider.instanceCount += 1;
|
||||
@@ -51,6 +53,55 @@ export class ShareProvider implements CapabilityProvider {
|
||||
bridge.registerHandler(InboundHandlers.FriendsShare, (data: string, _cb: (resp: string) => void) => {
|
||||
this.onShare(data);
|
||||
});
|
||||
// 第二条链路:H5 主动 POST 截图到 LocalUploadServer → emit PHOTO_UPLOAD → 微信图片分享。
|
||||
this.photoUploadCancel = EventBus.on(ShareEvents.PHOTO_UPLOAD, (p) => this.onPhotoUpload(p));
|
||||
}
|
||||
|
||||
/** 容器销毁:退订 PHOTO_UPLOAD(双槽各一实例,须 per-subscriber 退订,勿 offAll)。 */
|
||||
onDestroy(): void {
|
||||
if (this.photoUploadCancel !== undefined) {
|
||||
this.photoUploadCancel();
|
||||
this.photoUploadCancel = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 POST 截图上传分享:仅当本槽桥激活时处理(非激活槽忽略,避免双实例对同一上传重复分享)。
|
||||
* type 语义同 sharefriend:"1" 好友 → WXSceneSession/回传 type=1;其他 → 朋友圈/回传 type=2。
|
||||
* 落盘成功走微信图片分享;微信未装 reportResult(3, ...);落盘失败仅 log(无网页可降级)。
|
||||
*/
|
||||
private onPhotoUpload(payload?: Object): void {
|
||||
const ctx = this.ctx;
|
||||
const bridge = this.bridge;
|
||||
if (ctx === undefined || bridge === undefined) {
|
||||
return;
|
||||
}
|
||||
if (!bridge.isActive()) {
|
||||
return; // 非激活槽不处理,保证同一上传仅激活槽分享一次
|
||||
}
|
||||
if (payload === undefined) {
|
||||
return;
|
||||
}
|
||||
const up = payload as PhotoUploadPayload;
|
||||
if (up.imageBase64 === '') {
|
||||
this.log.w('photo upload empty image');
|
||||
return;
|
||||
}
|
||||
const friendType: number = up.type === '1' ? 1 : 2;
|
||||
const wx: WeChatApi = WeChatApi.getInstance();
|
||||
if (!wx.isWXInstalled()) {
|
||||
this.log.i('WeChat not installed (photo upload); report cancel');
|
||||
this.reportResult(3, friendType);
|
||||
return;
|
||||
}
|
||||
// saveDataUrlToFile 对无 data: 前缀的纯 base64 也兼容(indexOf(',')<0 → 整串当 base64)
|
||||
const filePath: string = this.saveDataUrlToFile(ctx.uiAbilityContext, up.imageBase64);
|
||||
if (filePath === '') {
|
||||
this.log.w('photo upload save failed');
|
||||
this.reportResult(3, friendType);
|
||||
return;
|
||||
}
|
||||
this.wechatImageByFriend(ctx.uiAbilityContext, wx, filePath, friendType);
|
||||
}
|
||||
|
||||
/** 入站:校验 bean → 发起面板请求(一对一回投)。 */
|
||||
@@ -134,7 +185,12 @@ export class ShareProvider implements CapabilityProvider {
|
||||
}
|
||||
|
||||
private wechatScene(bean: SharetypeBean): number {
|
||||
return bean.sharefriend === '1' ? SendMessageToWXReq.WXSceneSession : SendMessageToWXReq.WXSceneTimeline;
|
||||
return this.wechatSceneByFriend(bean.sharefriend === '1' ? 1 : 2);
|
||||
}
|
||||
|
||||
/** friendType: 1 好友(WXSceneSession) / 2 朋友圈(WXSceneTimeline)。 */
|
||||
private wechatSceneByFriend(friendType: number): number {
|
||||
return friendType === 1 ? SendMessageToWXReq.WXSceneSession : SendMessageToWXReq.WXSceneTimeline;
|
||||
}
|
||||
|
||||
private wechatWebPage(ctx: common.UIAbilityContext, wx: WeChatApi, bean: SharetypeBean, reportType: number): void {
|
||||
@@ -154,15 +210,21 @@ export class ShareProvider implements CapabilityProvider {
|
||||
|
||||
private wechatImage(ctx: common.UIAbilityContext, wx: WeChatApi, filePath: string,
|
||||
bean: SharetypeBean, reportType: number): void {
|
||||
this.wechatImageByFriend(ctx, wx, filePath, reportType);
|
||||
}
|
||||
|
||||
/** 微信图片分享(友/圈由 friendType 决定);分享面板与 H5 POST 上传两条链路共用。
|
||||
* friendType 同时作为 sharesuccess 回传的 type(1 好友 / 2 朋友圈)。 */
|
||||
private wechatImageByFriend(ctx: common.UIAbilityContext, wx: WeChatApi, filePath: string, friendType: number): void {
|
||||
const req: SendMessageToWXReq = new SendMessageToWXReq();
|
||||
req.scene = this.wechatScene(bean);
|
||||
req.scene = this.wechatSceneByFriend(friendType);
|
||||
const msg: WXMediaMessage = new WXMediaMessage();
|
||||
const img: WXImageObject = new WXImageObject();
|
||||
img.uri = fileUri.getUriFromPath(filePath); // 沙箱文件 file uri,SDK 支持 jpeg/png
|
||||
msg.mediaObject = img;
|
||||
req.message = msg;
|
||||
wx.sendShare(ctx, req, (resp) => {
|
||||
this.reportResult(resp.errCode === ErrCode.ERR_OK ? 2 : 3, reportType);
|
||||
this.reportResult(resp.errCode === ErrCode.ERR_OK ? 2 : 3, friendType);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,289 @@
|
||||
/**
|
||||
* 本机上传端点(契约 §7 / §9,框架 §6.3)。截图分享链路用:
|
||||
* 容器在首次进度 100% 时把本机地址经桥 `setPostUrl` 推给 H5,H5 截图后 POST 到此端点。
|
||||
* 本机上传服务(契约 §7 / §9,框架 §6.3)。对齐原 Android OkHttpPhotoServer + savehandler:
|
||||
* 起本机 HTTP server 监听 127.0.0.1:<port>,路径 /testurl;地址经桥 setPostUrl 下发 H5
|
||||
* (下发在 WebSlot.onFirstProgress)。H5 截图后把图片 base64 POST 到 /testurl,
|
||||
* server 解析出 base64 + type → EventBus.emit(ShareEvents.PHOTO_UPLOAD) → ShareProvider 走微信图片分享。
|
||||
*
|
||||
* ⚠ 当前为骨架:`baseUrl()` 返回占位地址供容器按契约推送 `setPostUrl`(M2 无截图分享,
|
||||
* 不会有实际上传)。**真正的本机 TCP/HTTP 服务在 M3 ShareProvider 落地**(@ohos.net.socket),
|
||||
* 届时 start() 绑定本机端口、接收 multipart 截图并落盘/转交分享 SDK。
|
||||
* 实现:@ohos.net.socket TCPSocketServer(HarmonyOS 无内置 HTTP server,用 TCP 手解极简 HTTP/1.1)。
|
||||
* - listen 127.0.0.1:8099,失败 +1 重试若干次,记录实际端口(baseUrl 用实际端口)。
|
||||
* - 每连接累积字节缓冲;按 \r\n\r\n 分隔请求行+headers 与 body;按 Content-Length 读完整 body。
|
||||
* - 仅处理 POST /testurl:body 取 base64(优先 form-urlencoded name=,否则整个 raw body)+ type → emit → 回 200 "ok"。
|
||||
* - 非 /testurl 回 404。所有 socket 异常 log + 不崩溃(绑定失败则功能降级、baseUrl 返回占位)。
|
||||
*/
|
||||
import { Logger } from 'common';
|
||||
import { socket } from '@kit.NetworkKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { util } from '@kit.ArkTS';
|
||||
import { EventBus, Logger, ShareEvents, PhotoUploadPayload } from 'common';
|
||||
|
||||
/** 单连接的累积解析状态。 */
|
||||
class ConnState {
|
||||
buf: Uint8Array = new Uint8Array(0);
|
||||
headerEnd: number = -1; // \r\n\r\n 在 buf 中的结束偏移(body 起点);-1=未到
|
||||
contentLength: number = -1; // 解析到的 Content-Length;-1=未知
|
||||
requestLine: string = ''; // "POST /testurl HTTP/1.1"
|
||||
handled: boolean = false; // 已处理并响应,避免重复
|
||||
}
|
||||
|
||||
export class LocalUploadServer {
|
||||
private static readonly log: Logger = Logger.tag('LocalUploadServer');
|
||||
/** 占位端口,M3 实现真实服务时改为动态绑定后的实际端口。 */
|
||||
private port: number = 8099;
|
||||
private static readonly HOST: string = '127.0.0.1';
|
||||
private static readonly BASE_PORT: number = 8099;
|
||||
private static readonly MAX_PORT_TRIES: number = 10;
|
||||
private static readonly MAX_BODY: number = 16 * 1024 * 1024; // 16MB 上限,防异常占用
|
||||
|
||||
/** 启动本机服务(M2 占位:不绑定端口)。 */
|
||||
private server: socket.TCPSocketServer | undefined = undefined;
|
||||
private port: number = LocalUploadServer.BASE_PORT;
|
||||
private bound: boolean = false;
|
||||
private connectCb: ((conn: socket.TCPSocketConnection) => void) | undefined = undefined;
|
||||
|
||||
/** 启动本机服务:构造 server,从 BASE_PORT 起逐端口尝试 listen,成功即记录实际端口。 */
|
||||
async start(): Promise<void> {
|
||||
// TODO(M3): @ohos.net.socket 绑定 127.0.0.1:<port>,接收截图上传
|
||||
LocalUploadServer.log.i(`start (stub) baseUrl=${this.baseUrl()}`);
|
||||
if (this.bound) {
|
||||
return;
|
||||
}
|
||||
const server: socket.TCPSocketServer = socket.constructTCPSocketServerInstance();
|
||||
this.server = server;
|
||||
for (let i = 0; i < LocalUploadServer.MAX_PORT_TRIES; i++) {
|
||||
const tryPort: number = LocalUploadServer.BASE_PORT + i;
|
||||
const addr: socket.NetAddress = { address: LocalUploadServer.HOST, port: tryPort, family: 1 };
|
||||
try {
|
||||
await server.listen(addr);
|
||||
this.port = tryPort;
|
||||
this.bound = true;
|
||||
LocalUploadServer.log.i(`listening on ${this.baseUrl()}`);
|
||||
this.attachConnect(server);
|
||||
return;
|
||||
} catch (e) {
|
||||
const err = e as BusinessError;
|
||||
LocalUploadServer.log.w(`listen ${tryPort} failed: code=${err.code} ${err.message}; retry next`);
|
||||
}
|
||||
}
|
||||
LocalUploadServer.log.e('all listen attempts failed; upload server degraded');
|
||||
}
|
||||
|
||||
/** 停止本机服务(M3 实现真实服务后关闭 socket)。 */
|
||||
private attachConnect(server: socket.TCPSocketServer): void {
|
||||
const cb = (conn: socket.TCPSocketConnection): void => this.onConnection(conn);
|
||||
this.connectCb = cb;
|
||||
try {
|
||||
server.on('connect', cb);
|
||||
server.on('error', (err: BusinessError) =>
|
||||
LocalUploadServer.log.w(`server error: code=${err.code} ${err.message}`));
|
||||
} catch (e) {
|
||||
LocalUploadServer.log.w(`attach connect failed: ${(e as BusinessError).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private onConnection(conn: socket.TCPSocketConnection): void {
|
||||
const st: ConnState = new ConnState();
|
||||
const onMsg = (info: socket.SocketMessageInfo): void => {
|
||||
try {
|
||||
this.onMessage(conn, st, info.message);
|
||||
} catch (e) {
|
||||
LocalUploadServer.log.w(`onMessage failed: ${(e as Error).message}`);
|
||||
this.respondAndClose(conn, 500, 'error');
|
||||
}
|
||||
};
|
||||
try {
|
||||
conn.on('message', onMsg);
|
||||
conn.on('close', () => LocalUploadServer.log.d(`conn ${conn.clientId} closed`));
|
||||
} catch (e) {
|
||||
LocalUploadServer.log.w(`attach conn handlers failed: ${(e as BusinessError).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private onMessage(conn: socket.TCPSocketConnection, st: ConnState, chunk: ArrayBuffer): void {
|
||||
if (st.handled) {
|
||||
return;
|
||||
}
|
||||
// 累积字节
|
||||
const incoming: Uint8Array = new Uint8Array(chunk);
|
||||
const merged: Uint8Array = new Uint8Array(st.buf.length + incoming.length);
|
||||
merged.set(st.buf, 0);
|
||||
merged.set(incoming, st.buf.length);
|
||||
st.buf = merged;
|
||||
if (st.buf.length > LocalUploadServer.MAX_BODY) {
|
||||
this.respondAndClose(conn, 413, 'too large');
|
||||
st.handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 找 header 结束(\r\n\r\n)
|
||||
if (st.headerEnd < 0) {
|
||||
const idx: number = LocalUploadServer.indexOfCrlfCrlf(st.buf);
|
||||
if (idx < 0) {
|
||||
return; // headers 未收全,等后续 chunk
|
||||
}
|
||||
st.headerEnd = idx + 4;
|
||||
const headerText: string = LocalUploadServer.bytesToLatin1(st.buf.subarray(0, idx));
|
||||
this.parseHeaders(st, headerText);
|
||||
}
|
||||
|
||||
// 仅处理 POST /testurl
|
||||
const method: string = st.requestLine.split(' ')[0] ?? '';
|
||||
const path: string = st.requestLine.split(' ')[1] ?? '';
|
||||
if (method.toUpperCase() !== 'POST' || !LocalUploadServer.matchTestUrl(path)) {
|
||||
this.respondAndClose(conn, 404, 'not found');
|
||||
st.handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 按 Content-Length 读完整 body(未声明则以单次收到的为准)
|
||||
const bodyAvail: number = st.buf.length - st.headerEnd;
|
||||
if (st.contentLength >= 0 && bodyAvail < st.contentLength) {
|
||||
return; // body 未收全
|
||||
}
|
||||
const bodyLen: number = st.contentLength >= 0 ? st.contentLength : bodyAvail;
|
||||
const bodyBytes: Uint8Array = st.buf.subarray(st.headerEnd, st.headerEnd + bodyLen);
|
||||
const body: string = LocalUploadServer.bytesToLatin1(bodyBytes);
|
||||
st.handled = true;
|
||||
|
||||
const parsed: PhotoUploadPayload = LocalUploadServer.extractPayload(body);
|
||||
EventBus.emit(ShareEvents.PHOTO_UPLOAD, parsed);
|
||||
this.respondAndClose(conn, 200, 'ok');
|
||||
}
|
||||
|
||||
private parseHeaders(st: ConnState, headerText: string): void {
|
||||
const lines: string[] = headerText.split('\r\n');
|
||||
st.requestLine = lines.length > 0 ? lines[0] : '';
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line: string = lines[i];
|
||||
const colon: number = line.indexOf(':');
|
||||
if (colon <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key: string = line.substring(0, colon).trim().toLowerCase();
|
||||
if (key === 'content-length') {
|
||||
const v: number = parseInt(line.substring(colon + 1).trim(), 10);
|
||||
if (!isNaN(v) && v >= 0) {
|
||||
st.contentLength = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 body 提取截图 base64 + type。
|
||||
* - form-urlencoded:含 '&' 或 'key=' 形式时,取 name= 字段为图片、type= 字段为场景(URL 解码)。
|
||||
* - 否则整个 raw body 作为 base64,type 缺省 ''。
|
||||
* 兼容 Android:H5 可能传 form(name=<base64>&type=1) 或 raw body。
|
||||
*/
|
||||
private static extractPayload(body: string): PhotoUploadPayload {
|
||||
let image: string = '';
|
||||
let type: string = '';
|
||||
const looksForm: boolean = body.indexOf('name=') >= 0 || body.indexOf('type=') >= 0 || body.indexOf('&') >= 0;
|
||||
if (looksForm) {
|
||||
const pairs: string[] = body.split('&');
|
||||
for (const pair of pairs) {
|
||||
const eq: number = pair.indexOf('=');
|
||||
if (eq < 0) {
|
||||
continue;
|
||||
}
|
||||
const k: string = pair.substring(0, eq);
|
||||
const v: string = pair.substring(eq + 1);
|
||||
if (k === 'name' || k === 'image' || k === 'img' || k === 'photo' || k === 'data') {
|
||||
image = LocalUploadServer.urlDecode(v);
|
||||
} else if (k === 'type') {
|
||||
type = LocalUploadServer.urlDecode(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (image === '') {
|
||||
// 非 form 或未取到字段:整个 body 作为 base64(去掉可能的换行)
|
||||
image = body.trim();
|
||||
}
|
||||
return { imageBase64: image, type };
|
||||
}
|
||||
|
||||
private static urlDecode(s: string): string {
|
||||
try {
|
||||
// application/x-www-form-urlencoded:'+' 表示空格
|
||||
return decodeURIComponent(s.replace(/\+/g, ' '));
|
||||
} catch (e) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private respondAndClose(conn: socket.TCPSocketConnection, status: number, text: string): void {
|
||||
const reason: string = status === 200 ? 'OK' : (status === 404 ? 'Not Found' : 'Error');
|
||||
const resp: string = `HTTP/1.1 ${status} ${reason}\r\n`
|
||||
+ 'Content-Type: text/plain; charset=utf-8\r\n'
|
||||
+ `Content-Length: ${LocalUploadServer.utf8Len(text)}\r\n`
|
||||
+ 'Connection: close\r\n'
|
||||
+ 'Access-Control-Allow-Origin: *\r\n'
|
||||
+ '\r\n'
|
||||
+ text;
|
||||
const opt: socket.TCPSendOptions = { data: resp, encoding: 'UTF-8' };
|
||||
conn.send(opt).then(() => {
|
||||
conn.close().catch((e: BusinessError) =>
|
||||
LocalUploadServer.log.d(`conn close after send: ${e.message}`));
|
||||
}).catch((e: BusinessError) => {
|
||||
LocalUploadServer.log.w(`send response failed: ${e.message}`);
|
||||
conn.close().catch((ee: BusinessError) => LocalUploadServer.log.d(`conn close: ${ee.message}`));
|
||||
});
|
||||
}
|
||||
|
||||
/** 停止本机服务(关闭 server)。 */
|
||||
async stop(): Promise<void> {
|
||||
// TODO(M3): 关闭 socket
|
||||
const server = this.server;
|
||||
this.bound = false;
|
||||
if (server === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (this.connectCb !== undefined) {
|
||||
server.off('connect', this.connectCb);
|
||||
}
|
||||
} catch (e) {
|
||||
LocalUploadServer.log.d(`off connect: ${(e as BusinessError).message}`);
|
||||
}
|
||||
// TCPSocketServer 无显式 close(API 仅提供 listen);解订阅即可停止接收新连接。
|
||||
this.server = undefined;
|
||||
this.connectCb = undefined;
|
||||
}
|
||||
|
||||
/** 推给 H5 的上传地址(对齐 Android `http://<本机IP>:<端口>/testurl`)。 */
|
||||
baseUrl(): string {
|
||||
return `http://127.0.0.1:${this.port}/testurl`;
|
||||
return `http://${LocalUploadServer.HOST}:${this.port}/testurl`;
|
||||
}
|
||||
|
||||
// —— 字节工具 ——
|
||||
|
||||
/** 在字节流中找 "\r\n\r\n" 起始下标(找不到返回 -1)。 */
|
||||
private static indexOfCrlfCrlf(buf: Uint8Array): number {
|
||||
for (let i = 0; i + 3 < buf.length; i++) {
|
||||
if (buf[i] === 13 && buf[i + 1] === 10 && buf[i + 2] === 13 && buf[i + 3] === 10) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 字节按 Latin-1(每字节一字符)转字符串——base64/ASCII 文本安全,且与 byteLength 一一对应。 */
|
||||
private static bytesToLatin1(bytes: Uint8Array): string {
|
||||
let s: string = '';
|
||||
const len: number = bytes.length;
|
||||
// 分块拼接,避免超长 apply 调用栈问题
|
||||
const CHUNK: number = 8192;
|
||||
for (let i = 0; i < len; i += CHUNK) {
|
||||
const end: number = Math.min(i + CHUNK, len);
|
||||
let part: string = '';
|
||||
for (let j = i; j < end; j++) {
|
||||
part += String.fromCharCode(bytes[j]);
|
||||
}
|
||||
s += part;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** path 是否命中 /testurl(忽略 query)。 */
|
||||
private static matchTestUrl(path: string): boolean {
|
||||
const q: number = path.indexOf('?');
|
||||
const clean: string = q >= 0 ? path.substring(0, q) : path;
|
||||
return clean === '/testurl' || clean === '/testurl/';
|
||||
}
|
||||
|
||||
/** 文本的 UTF-8 字节长度(响应 Content-Length 用)。 */
|
||||
private static utf8Len(text: string): number {
|
||||
return new util.TextEncoder().encodeInto(text).length;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user