Phase 4.D:DouyinShare URL Scheme + Photos 真实落地(不依赖 SDK)

移植 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 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 20:07:45 +08:00
co-authored by Claude Opus 4.7
parent aff5ee7633
commit 39c1b5164d
5 changed files with 199 additions and 18 deletions
+11 -5
View File
@@ -493,11 +493,17 @@ Contract Design Plan(本文档)
- `canOpenURL` 检查 + `await UIApplication.open` 异步打开;调起即视为 success - `canOpenURL` 检查 + `await UIApplication.open` 异步打开;调起即视为 success
- **当前仅链接分享**type == "1");type == "2" 截图 / 远端图 留 Phase 4.F - **当前仅链接分享**type == "1");type == "2" 截图 / 远端图 留 Phase 4.F
- Info.plist 加 `LSApplicationQueriesSchemes``mqq` / `mqqapi` / `mqqopensdkfriend` / `mqqopensdkapiV2/V3/V4` - Info.plist 加 `LSApplicationQueriesSchemes``mqq` / `mqqapi` / `mqqopensdkfriend` / `mqqopensdkapiV2/V3/V4`
- [ ] **4.4** **抖音分享走 URL Scheme + Photos,不依赖 SDK** - [x] **4.4** **抖音分享走 URL Scheme + Photos,不依赖 SDK**4.D 完成)
- 移植 msext `DouyinShareManager.m` 简化版 - 新增 `Source/Share/ImageProvider.swift``captureScreenshot()` 截 keyWindow + `downloadRemote(_:)` 异步下载远端图(共用基础设施)
- URL Scheme`snssdk1128://share/video` / `snssdk1128://camera` - 新增 `Source/Share/PhotoLibrarySaver.swift`:保存到相册(PHAccessLevel.addOnly 权限,iOS 14+ 推荐)
- Photos:保存截图到相册让用户在抖音 app 里选取(H5 截图 / 远端图共用此路径) - `Source/Share/DouyinShare.swift` 升级移植 msext `DouyinShareManager.m:201-269` `shareImageToDouyin`
- Info.plist 加 `NSPhotoLibraryAddUsageDescription` 写入权限文案 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.3** `Source/SDK/WeChat/WeChatSDK.swift` 启动注册(Design §4.2 `registerFull` + 全部 12 个 `MMAPP_SUPPORT_*` flag
- [ ] **4.4** `Source/SDK/WeChat/WeChatManager.swift`@MainActor - [ ] **4.4** `Source/SDK/WeChat/WeChatManager.swift`@MainActor
- 持久 delegate - 持久 delegate
+7
View File
@@ -64,5 +64,12 @@
<string>mqqopensdkapiV4</string> <string>mqqopensdkapiV4</string>
<string>snssdk1128</string> <string>snssdk1128</string>
</array> </array>
<!--
NSPhotoLibraryAddUsageDescription — Phase 4.D 抖音分享需要保存截图 / 远端图
到相册让用户在抖音 app 里选取(msext DouyinShareManager.m 同款流程)。
用 addOnly 权限级别(仅写不读),iOS 14+ 弹窗对用户更友好。
-->
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要将分享内容保存到相册,以便您在抖音中选取分享</string>
</dict> </dict>
</plist> </plist>
+67 -13
View File
@@ -2,23 +2,33 @@
// DouyinShare.swift // DouyinShare.swift
// ylgamehall // ylgamehall
// //
// SharePlatform stubPhase 4.D // SharePlatform Phase 4.D
// //
// Phase 4.B stub success // msext DouyinShareManager.m:201-269 shareImageToDouyin
// Phase 4.D msext DouyinShareManager.m URL Scheme + Photos // 1. type=2 ImageProvider.captureScreenshot
// snssdk1128://share/video + // ImageProvider.downloadRemotetype=1
// 退msext H5
// 2. PhotoLibrarySaver.save addOnly
// 3. UIPasteboard
// 4. snssdk1128://camera / publish / share/image / scheme
// canOpenURL
// 5. successURL Scheme
//
// ** SDK**
// //
import UIKit import UIKit
import os.log
@MainActor @MainActor
public final class DouyinShare: SharePlatform { public final class DouyinShare: SharePlatform {
public static let shared = DouyinShare() public static let shared = DouyinShare()
private static let log = Logger(subsystem: "ylgamehall", category: "DouyinShare")
public let name = "Douyin" public let name = "Douyin"
/// app canOpenURL("snssdk1128://")
public var isInstalled: Bool { public var isInstalled: Bool {
guard let url = URL(string: "snssdk1128://") else { return false } guard let url = URL(string: "snssdk1128://") else { return false }
return UIApplication.shared.canOpenURL(url) return UIApplication.shared.canOpenURL(url)
@@ -28,13 +38,57 @@ public final class DouyinShare: SharePlatform {
public func share(_ content: ShareContent, scene: ShareScene) async -> ShareResult { public func share(_ content: ShareContent, scene: ShareScene) async -> ShareResult {
guard isInstalled else { return .notInstalled(platform: name) } guard isInstalled else { return .notInstalled(platform: name) }
// Phase 4.B stub successPhase 4.D
// Phase 4.D // 1.
// - type == 2 UIGraphicsImageRenderer PHPhotoLibrary let image: UIImage
// - type == URLSession do {
// - snssdk1128://share/video snssdk1128://camera switch content.type {
// - Info.plist NSPhotoLibraryAddUsageDescription case "2":
// - success image = try ImageProvider.captureScreenshot()
return .success 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 都调起失败")
} }
} }
@@ -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)
}
}
}
@@ -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<Void, Error>) 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)
))
}
}
}
}
}