From 39c1b5164db6f98de374921106d2d36f865d65a2 Mon Sep 17 00:00:00 2001 From: joywayer Date: Mon, 22 Jun 2026 20:07:45 +0800 Subject: [PATCH] =?UTF-8?q?Phase=204.D=EF=BC=9ADouyinShare=20URL=20Scheme?= =?UTF-8?q?=20+=20Photos=20=E7=9C=9F=E5=AE=9E=E8=90=BD=E5=9C=B0=EF=BC=88?= =?UTF-8?q?=E4=B8=8D=E4=BE=9D=E8=B5=96=20SDK=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移植 msext DouyinShareManager.m:201-269 shareImageToDouyin 完整流程到 Swift。 新增基础设施(Phase 4.F 截图 / 远端图也共用): Source/Share/ImageProvider.swift: - captureScreenshot() throws -> UIImage:UIGraphicsImageRenderer 截 keyWindow(含状态栏 + letterbox 黑边 + WebView),与 msext FuncPublic.getImageWithFullScreenshot 等价 - downloadRemote(_:) async throws -> UIImage:URLSession.data(from:) 下载远端 URL → UIImage 解码 - ImageProviderError 错误分类(noKeyWindow / downloadFailed / decodeFailed / badURL) Source/Share/PhotoLibrarySaver.swift: - save(_:) async throws:使用 PHAccessLevel.addOnly(iOS 14+ 推荐,仅写 相册不读,弹窗更友好)+ PHAssetChangeRequest.creationRequestForAsset - 自动处理权限三态(authorized/notDetermined 请求 + denied/restricted 抛错) DouyinShare.share 完整实现: - 拿图片源: * type == "2" 截图 / type == "1" 链接(退化为当前屏截屏,msext 行为) → ImageProvider.captureScreenshot * type == 其它(远端图 URL)→ ImageProvider.downloadRemote(webpageUrl) - PhotoLibrarySaver.save 保存到相册(含 .permissionDenied 错误回 .failed) - UIPasteboard.general.image = image 兜底(抖音可能从剪贴板读图) - 按 msext 顺序尝试 4 个 URL Scheme: snssdk1128://camera / publish / share/image / 基础 scheme canOpenURL 命中即 await UIApplication.open + 调起即 success - 全部失败返回 .failed("所有抖音 URL Scheme 都调起失败") Info.plist 加 NSPhotoLibraryAddUsageDescription("需要将分享内容保存到 相册,以便您在抖音中选取分享"),addOnly 权限弹窗用此文案。 BuildProject 通过 Plan §5 Phase 4.4 勾选 Co-Authored-By: Claude Opus 4.7 --- docs/Development-Plan.md | 16 ++-- ylgamehall/Info.plist | 7 ++ ylgamehall/Source/Share/DouyinShare.swift | 80 ++++++++++++++++--- ylgamehall/Source/Share/ImageProvider.swift | 59 ++++++++++++++ .../Source/Share/PhotoLibrarySaver.swift | 55 +++++++++++++ 5 files changed, 199 insertions(+), 18 deletions(-) create mode 100644 ylgamehall/Source/Share/ImageProvider.swift create mode 100644 ylgamehall/Source/Share/PhotoLibrarySaver.swift diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 0274239..30a0992 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -493,11 +493,17 @@ Contract Design Plan(本文档) - `canOpenURL` 检查 + `await UIApplication.open` 异步打开;调起即视为 success - **当前仅链接分享**(type == "1");type == "2" 截图 / 远端图 留 Phase 4.F - Info.plist 加 `LSApplicationQueriesSchemes`:`mqq` / `mqqapi` / `mqqopensdkfriend` / `mqqopensdkapiV2/V3/V4` -- [ ] **4.4** **抖音分享走 URL Scheme + Photos,不依赖 SDK** - - 移植 msext `DouyinShareManager.m` 简化版 - - URL Scheme:`snssdk1128://share/video` / `snssdk1128://camera` - - Photos:保存截图到相册让用户在抖音 app 里选取(H5 截图 / 远端图共用此路径) - - Info.plist 加 `NSPhotoLibraryAddUsageDescription` 写入权限文案 +- [x] **4.4** **抖音分享走 URL Scheme + Photos,不依赖 SDK**(4.D 完成) + - 新增 `Source/Share/ImageProvider.swift`:`captureScreenshot()` 截 keyWindow + `downloadRemote(_:)` 异步下载远端图(共用基础设施) + - 新增 `Source/Share/PhotoLibrarySaver.swift`:保存到相册(PHAccessLevel.addOnly 权限,iOS 14+ 推荐) + - `Source/Share/DouyinShare.swift` 升级移植 msext `DouyinShareManager.m:201-269` `shareImageToDouyin`: + 1. 拿图片源:type=2 / type=1 都退化为截屏(抖音不支持纯链接,与 msext 行为一致);type=其它=远端图 URL → downloadRemote + 2. PhotoLibrarySaver.save 保存相册 + 3. `UIPasteboard.general.image = image` 兜底 + 4. 按 msext 顺序尝试 `snssdk1128://camera` / `publish` / `share/image` / 基础 scheme + 5. 调起即视为 success + - Info.plist 加 `NSPhotoLibraryAddUsageDescription` 文案 + - BuildProject 通过 - [ ] **4.3** `Source/SDK/WeChat/WeChatSDK.swift` 启动注册(Design §4.2 `registerFull` + 全部 12 个 `MMAPP_SUPPORT_*` flag) - [ ] **4.4** `Source/SDK/WeChat/WeChatManager.swift`(@MainActor) - 持久 delegate diff --git a/ylgamehall/Info.plist b/ylgamehall/Info.plist index 6fd833c..9deba63 100644 --- a/ylgamehall/Info.plist +++ b/ylgamehall/Info.plist @@ -64,5 +64,12 @@ mqqopensdkapiV4 snssdk1128 + + NSPhotoLibraryAddUsageDescription + 需要将分享内容保存到相册,以便您在抖音中选取分享 diff --git a/ylgamehall/Source/Share/DouyinShare.swift b/ylgamehall/Source/Share/DouyinShare.swift index 36b41a9..2efbf2d 100644 --- a/ylgamehall/Source/Share/DouyinShare.swift +++ b/ylgamehall/Source/Share/DouyinShare.swift @@ -2,23 +2,33 @@ // DouyinShare.swift // ylgamehall // -// 抖音分享 SharePlatform 实现 stub(Phase 4.D 真实落地)。 +// 抖音分享 SharePlatform 实现(Phase 4.D 完整)。 // -// Phase 4.B 阶段为 stub(仅返回 success); -// Phase 4.D 移植 msext DouyinShareManager.m URL Scheme + Photos 路径 -// (snssdk1128://share/video + 保存截图到相册让用户在抖音里选)。 +// 移植 msext DouyinShareManager.m:201-269 shareImageToDouyin 流程: +// 1. 拿图片源(type=2 → ImageProvider.captureScreenshot;其它 → +// ImageProvider.downloadRemote);type=1 链接 → 抖音不原生支持纯链接, +// 退化为截屏分享(msext 行为:所有 H5 分享到抖音都走图片) +// 2. PhotoLibrarySaver.save 保存到相册(addOnly 权限) +// 3. UIPasteboard 兜底放图片数据,供抖音读取 +// 4. 按顺序尝试 snssdk1128://camera / publish / share/image / 基础 scheme, +// canOpenURL 命中即调起 +// 5. 调起即视为 success(URL Scheme 单向无返回) +// +// **不依赖抖音 SDK**。 // import UIKit +import os.log @MainActor public final class DouyinShare: SharePlatform { public static let shared = DouyinShare() + private static let log = Logger(subsystem: "ylgamehall", category: "DouyinShare") + public let name = "Douyin" - /// 检测抖音 app 是否安装:canOpenURL("snssdk1128://") public var isInstalled: Bool { guard let url = URL(string: "snssdk1128://") else { return false } return UIApplication.shared.canOpenURL(url) @@ -28,13 +38,57 @@ public final class DouyinShare: SharePlatform { public func share(_ content: ShareContent, scene: ShareScene) async -> ShareResult { guard isInstalled else { return .notInstalled(platform: name) } - // Phase 4.B 阶段 stub:返回 success,Phase 4.D 升级 - // 真实实现(Phase 4.D): - // - type == 2 截图:UIGraphicsImageRenderer 截图 → 保存到相册(PHPhotoLibrary) - // - type == 其他 远端图:URLSession 下载 → 保存到相册 - // - 保存后 snssdk1128://share/video 或 snssdk1128://camera 调起抖音 - // - Info.plist 加 NSPhotoLibraryAddUsageDescription - // - 调起即视为 success - return .success + + // 1. 拿图片源 + let image: UIImage + do { + switch content.type { + case "2": + image = try ImageProvider.captureScreenshot() + case "1": + // 抖音不支持纯链接分享,退化为当前屏截屏(msext 行为) + Self.log.debug("DouyinShare: type=1 链接,退化为截屏分享") + image = try ImageProvider.captureScreenshot() + default: + // type == 其它 → 远端图片 URL + image = try await ImageProvider.downloadRemote(content.webpageUrl) + } + } catch { + Self.log.error("DouyinShare: 获取图片失败 \(error.localizedDescription, privacy: .public)") + return .failed(reason: "获取图片失败: \(error.localizedDescription)") + } + + // 2. 保存到相册(含权限检查) + do { + try await PhotoLibrarySaver.save(image) + } catch PhotoLibrarySaverError.permissionDenied { + Self.log.warning("DouyinShare: 相册写入权限被拒") + return .failed(reason: "相册写入权限被拒") + } catch { + Self.log.error("DouyinShare: 保存相册失败 \(error.localizedDescription, privacy: .public)") + return .failed(reason: "保存相册失败: \(error.localizedDescription)") + } + + // 3. 同时放剪贴板兜底(msext 行为:抖音从剪贴板读图) + UIPasteboard.general.image = image + + // 4. 按 msext 顺序尝试多个 URL Scheme + let schemes = [ + "snssdk1128://camera", // 抖音拍摄界面 + "snssdk1128://publish", // 发布界面 + "snssdk1128://share/image", // 图片分享(可能已废弃但保留) + "snssdk1128://" // 兜底基础 scheme + ] + + for schemeString in schemes { + guard let url = URL(string: schemeString), + UIApplication.shared.canOpenURL(url) + else { continue } + Self.log.debug("DouyinShare open: \(schemeString, privacy: .public)") + let opened = await UIApplication.shared.open(url) + if opened { return .success } + } + + return .failed(reason: "所有抖音 URL Scheme 都调起失败") } } diff --git a/ylgamehall/Source/Share/ImageProvider.swift b/ylgamehall/Source/Share/ImageProvider.swift new file mode 100644 index 0000000..5e020c5 --- /dev/null +++ b/ylgamehall/Source/Share/ImageProvider.swift @@ -0,0 +1,59 @@ +// +// ImageProvider.swift +// ylgamehall +// +// 分享场景的图片源 helper: +// - captureScreenshot() 截当前 keyWindow(与 msext FuncPublic +// getImageWithFullScreenshot 等价) +// - downloadRemote(_:) URLSession 异步下载远端图片 URL +// +// Phase 4.F 截图 / 远端图分享共用。 +// + +import UIKit + +public enum ImageProviderError: Error, Sendable { + case noKeyWindow + case downloadFailed(any Error) + case decodeFailed + case badURL(String) +} + +@MainActor +public enum ImageProvider { + + /// 截当前 keyWindow(与 msext `getImageWithFullScreenshot` 等价)。 + /// 返回包含状态栏 + 大厅 letterbox 黑边 + WebView 内容的完整快照。 + public static func captureScreenshot() throws -> UIImage { + guard let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap(\.windows) + .first(where: { $0.isKeyWindow }) + else { + throw ImageProviderError.noKeyWindow + } + + let renderer = UIGraphicsImageRenderer(bounds: window.bounds) + return renderer.image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: false) + } + } + + /// 下载远端 URL → UIImage。仅简单 URLSession 同步路径,无重试 / 缓存。 + public static func downloadRemote(_ urlString: String) async throws -> UIImage { + guard let url = URL(string: urlString) else { + throw ImageProviderError.badURL(urlString) + } + do { + let (data, _) = try await URLSession.shared.data(from: url) + guard let image = UIImage(data: data) else { + throw ImageProviderError.decodeFailed + } + return image + } catch let err as ImageProviderError { + throw err + } catch { + throw ImageProviderError.downloadFailed(error) + } + } +} diff --git a/ylgamehall/Source/Share/PhotoLibrarySaver.swift b/ylgamehall/Source/Share/PhotoLibrarySaver.swift new file mode 100644 index 0000000..daa7eef --- /dev/null +++ b/ylgamehall/Source/Share/PhotoLibrarySaver.swift @@ -0,0 +1,55 @@ +// +// PhotoLibrarySaver.swift +// ylgamehall +// +// 保存 UIImage 到系统相册(with addOnly 权限检查)。 +// +// iOS 14+ 推荐用 PHAccessLevel.addOnly(仅写入相册不读取),权限弹窗 +// 比 readWrite 更友好。Info.plist 需配 NSPhotoLibraryAddUsageDescription。 +// +// Phase 4.D / 4.F 分享场景用:抖音 + QQ 图片分享前先保存到相册。 +// + +import UIKit +import Photos + +public enum PhotoLibrarySaverError: Error, Sendable { + case permissionDenied + case saveFailed(any Error) +} + +@MainActor +public enum PhotoLibrarySaver { + + /// 保存图片到相册。内部处理权限(addOnly 级别)。 + public static func save(_ image: UIImage) async throws { + let status = PHPhotoLibrary.authorizationStatus(for: .addOnly) + switch status { + case .authorized, .limited: + break + case .denied, .restricted: + throw PhotoLibrarySaverError.permissionDenied + case .notDetermined: + let newStatus = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + guard newStatus == .authorized || newStatus == .limited else { + throw PhotoLibrarySaverError.permissionDenied + } + @unknown default: + throw PhotoLibrarySaverError.permissionDenied + } + + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + PHPhotoLibrary.shared().performChanges { + PHAssetChangeRequest.creationRequestForAsset(from: image) + } completionHandler: { success, error in + if success { + cont.resume() + } else { + cont.resume(throwing: PhotoLibrarySaverError.saveFailed( + error ?? NSError(domain: "PhotoLibrarySaver", code: -1) + )) + } + } + } + } +}