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:
co-authored by
Claude Opus 4.7
parent
aff5ee7633
commit
39c1b5164d
@@ -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
|
||||
|
||||
@@ -64,5 +64,12 @@
|
||||
<string>mqqopensdkapiV4</string>
|
||||
<string>snssdk1128</string>
|
||||
</array>
|
||||
<!--
|
||||
NSPhotoLibraryAddUsageDescription — Phase 4.D 抖音分享需要保存截图 / 远端图
|
||||
到相册让用户在抖音 app 里选取(msext DouyinShareManager.m 同款流程)。
|
||||
用 addOnly 权限级别(仅写不读),iOS 14+ 弹窗对用户更友好。
|
||||
-->
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要将分享内容保存到相册,以便您在抖音中选取分享</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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 都调起失败")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user