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,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