From 187e73a5490b2eb2898f2ffc458fb1e1749f401c Mon Sep 17 00:00:00 2001 From: lanterngamescn Date: Fri, 26 Jun 2026 08:19:21 +0800 Subject: [PATCH] =?UTF-8?q?M3(T-M2-04/T-M3-13):=20LocalUploadServer=20?= =?UTF-8?q?=E6=88=AA=E5=9B=BE=E4=B8=8A=E4=BC=A0=E5=88=86=E4=BA=AB=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补「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) --- common/Index.ets | 2 +- common/src/main/ets/event/ShareEvents.ets | 13 + .../src/main/ets/providers/ShareProvider.ets | 70 ++++- .../src/main/ets/upload/LocalUploadServer.ets | 286 +++++++++++++++++- 4 files changed, 352 insertions(+), 19 deletions(-) diff --git a/common/Index.ets b/common/Index.ets index 5a1f563..6436503 100644 --- a/common/Index.ets +++ b/common/Index.ets @@ -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'; diff --git a/common/src/main/ets/event/ShareEvents.ets b/common/src/main/ets/event/ShareEvents.ets index c6d61f4..6a24d4a 100644 --- a/common/src/main/ets/event/ShareEvents.ets +++ b/common/src/main/ets/event/ShareEvents.ets @@ -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 载荷。 */ diff --git a/feature_capabilities/src/main/ets/providers/ShareProvider.ets b/feature_capabilities/src/main/ets/providers/ShareProvider.ets index b615c36..e5fe545 100644 --- a/feature_capabilities/src/main/ets/providers/ShareProvider.ets +++ b/feature_capabilities/src/main/ets/providers/ShareProvider.ets @@ -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); }); } diff --git a/platform/src/main/ets/upload/LocalUploadServer.ets b/platform/src/main/ets/upload/LocalUploadServer.ets index 5be39bb..ae2990a 100644 --- a/platform/src/main/ets/upload/LocalUploadServer.ets +++ b/platform/src/main/ets/upload/LocalUploadServer.ets @@ -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:,路径 /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 { - // TODO(M3): @ohos.net.socket 绑定 127.0.0.1:,接收截图上传 - 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=&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 { - // 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; } }