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)
+ ))
+ }
+ }
+ }
+ }
+}