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:
lanterngamescn
2026-06-26 08:19:21 +08:00
co-authored by Claude Opus 4.8
parent cb3673cb1b
commit 187e73a549
4 changed files with 352 additions and 19 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ export { BridgeTracer } from './src/main/ets/log/BridgeTracer';
export { EventBus, EventPayload } from './src/main/ets/event/EventBus'; export { EventBus, EventPayload } from './src/main/ets/event/EventBus';
export { NavEvents, OpenGenericWebPayload, SwitchGamePayload, BackGamePayload } export { NavEvents, OpenGenericWebPayload, SwitchGamePayload, BackGamePayload }
from './src/main/ets/event/NavEvents'; from './src/main/ets/event/NavEvents';
export { ShareEvents, SharePanelRequest, SharePanelResult } export { ShareEvents, SharePanelRequest, SharePanelResult, PhotoUploadPayload }
from './src/main/ets/event/ShareEvents'; from './src/main/ets/event/ShareEvents';
export { DIContainer, Factory } from './src/main/ets/di/DIContainer'; export { DIContainer, Factory } from './src/main/ets/di/DIContainer';
export { ErrorCenter, ErrorStrategy, ErrorSink } from './src/main/ets/error/ErrorCenter'; export { ErrorCenter, ErrorStrategy, ErrorSink } from './src/main/ets/error/ErrorCenter';
+13
View File
@@ -10,6 +10,19 @@
export class ShareEvents { export class ShareEvents {
/** 请求弹出分享面板。 */ /** 请求弹出分享面板。 */
static readonly SHOW_PANEL: string = 'share.showPanel'; 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 载荷。 */ /** SHOW_PANEL 载荷。 */
@@ -24,7 +24,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
import { util } from '@kit.ArkTS'; import { util } from '@kit.ArkTS';
import { BridgeController } from 'feature_bridge'; import { BridgeController } from 'feature_bridge';
import { InboundHandlers, OutboundHandlers, SharetypeBean, ShareSuccessResp } from 'contracts'; 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 { FileSystem } from 'platform';
import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider'; import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider';
import { WeChatApi } from '../wx/WeChatApi'; import { WeChatApi } from '../wx/WeChatApi';
@@ -39,6 +39,8 @@ export class ShareProvider implements CapabilityProvider {
private ctx: CapabilityContext | undefined = undefined; private ctx: CapabilityContext | undefined = undefined;
/** 截图落盘 + resultEvent 命名计数器(实例自增,避免 Math.random 串扰)。 */ /** 截图落盘 + resultEvent 命名计数器(实例自增,避免 Math.random 串扰)。 */
private seq: number = 0; private seq: number = 0;
/** PHOTO_UPLOAD 订阅退订函数(onDestroy 调用,防双槽实例泄漏/串扰)。 */
private photoUploadCancel: (() => void) | undefined = undefined;
constructor() { constructor() {
ShareProvider.instanceCount += 1; ShareProvider.instanceCount += 1;
@@ -51,6 +53,55 @@ export class ShareProvider implements CapabilityProvider {
bridge.registerHandler(InboundHandlers.FriendsShare, (data: string, _cb: (resp: string) => void) => { bridge.registerHandler(InboundHandlers.FriendsShare, (data: string, _cb: (resp: string) => void) => {
this.onShare(data); 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 → 发起面板请求(一对一回投)。 */ /** 入站:校验 bean → 发起面板请求(一对一回投)。 */
@@ -134,7 +185,12 @@ export class ShareProvider implements CapabilityProvider {
} }
private wechatScene(bean: SharetypeBean): number { 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 { 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, private wechatImage(ctx: common.UIAbilityContext, wx: WeChatApi, filePath: string,
bean: SharetypeBean, reportType: number): void { bean: SharetypeBean, reportType: number): void {
this.wechatImageByFriend(ctx, wx, filePath, reportType);
}
/** 微信图片分享(友/圈由 friendType 决定);分享面板与 H5 POST 上传两条链路共用。
* friendType 同时作为 sharesuccess 回传的 type1 好友 / 2 朋友圈)。 */
private wechatImageByFriend(ctx: common.UIAbilityContext, wx: WeChatApi, filePath: string, friendType: number): void {
const req: SendMessageToWXReq = new SendMessageToWXReq(); const req: SendMessageToWXReq = new SendMessageToWXReq();
req.scene = this.wechatScene(bean); req.scene = this.wechatSceneByFriend(friendType);
const msg: WXMediaMessage = new WXMediaMessage(); const msg: WXMediaMessage = new WXMediaMessage();
const img: WXImageObject = new WXImageObject(); const img: WXImageObject = new WXImageObject();
img.uri = fileUri.getUriFromPath(filePath); // 沙箱文件 file uriSDK 支持 jpeg/png img.uri = fileUri.getUriFromPath(filePath); // 沙箱文件 file uriSDK 支持 jpeg/png
msg.mediaObject = img; msg.mediaObject = img;
req.message = msg; req.message = msg;
wx.sendShare(ctx, req, (resp) => { 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)。截图分享链路用 * 本机上传服务(契约 §7 / §9,框架 §6.3)。对齐原 Android OkHttpPhotoServer + savehandler
* 容器在首次进度 100% 时把本机地址经桥 `setPostUrl` 推给 H5,H5 截图后 POST 到此端点。 * 起本机 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 无截图分享, * 实现:@ohos.net.socket TCPSocketServerHarmonyOS 无内置 HTTP server,用 TCP 手解极简 HTTP/1.1)。
* 不会有实际上传)。**真正的本机 TCP/HTTP 服务在 M3 ShareProvider 落地**@ohos.net.socket), * - listen 127.0.0.1:8099,失败 +1 重试若干次,记录实际端口(baseUrl 用实际端口)。
* 届时 start() 绑定本机端口、接收 multipart 截图并落盘/转交分享 SDK * - 每连接累积字节缓冲;按 \r\n\r\n 分隔请求行+headers 与 body;按 Content-Length 读完整 body
* - 仅处理 POST /testurlbody 取 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 { export class LocalUploadServer {
private static readonly log: Logger = Logger.tag('LocalUploadServer'); private static readonly log: Logger = Logger.tag('LocalUploadServer');
/** 占位端口,M3 实现真实服务时改为动态绑定后的实际端口。 */ private static readonly HOST: string = '127.0.0.1';
private port: number = 8099; 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> { async start(): Promise<void> {
// TODO(M3): @ohos.net.socket 绑定 127.0.0.1:<port>,接收截图上传 if (this.bound) {
LocalUploadServer.log.i(`start (stub) baseUrl=${this.baseUrl()}`); 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 作为 base64type 缺省 ''。
* 兼容 AndroidH5 可能传 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> { 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 无显式 closeAPI 仅提供 listen);解订阅即可停止接收新连接。
this.server = undefined;
this.connectCb = undefined;
} }
/** 推给 H5 的上传地址(对齐 Android `http://<本机IP>:<端口>/testurl`)。 */ /** 推给 H5 的上传地址(对齐 Android `http://<本机IP>:<端口>/testurl`)。 */
baseUrl(): string { 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;
} }
} }