feat: iOS/macOS 双平台打包支持
- Package.swift: 重构为 MiniPlayerCore(共享库) + MiniPlayer(macOS) + MiniPlayeriOS(iOS) 三 target - 所有源文件添加 #if os(macOS) / #if os(iOS) 条件编译 - HLSRecorder.swift: iOS 纯 Swift HLS 录制器(下载TS+合并+AVAssetExportSession转MP4) - scripts/build-macos.sh: macOS 打包脚本 (.app + .dmg) - scripts/build-ios.sh: iOS 打包脚本 (xcodegen + xcodebuild archive → .ipa) - .gitignore: 排除构建产物
This commit is contained in:
parent
103b994c60
commit
d22a5037b8
7
.gitignore
vendored
7
.gitignore
vendored
@ -3,3 +3,10 @@ build/
|
||||
*.xcodeproj
|
||||
.DS_Store
|
||||
MiniPlayer.app/
|
||||
|
||||
# Build artifacts
|
||||
*.dmg
|
||||
MiniPlayer.app/
|
||||
.build-ios/
|
||||
*.xcodeproj/
|
||||
project.yml
|
||||
|
||||
@ -13,13 +13,24 @@ let package = Package(
|
||||
.package(path: "../StreamRecorder")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "MiniPlayer",
|
||||
.target(
|
||||
name: "MiniPlayerCore",
|
||||
dependencies: [
|
||||
.product(name: "SwiftBricks", package: "swiftbricks"),
|
||||
.product(name: "StreamRecorderKit", package: "StreamRecorder")
|
||||
.product(name: "StreamRecorderKit", package: "StreamRecorder", condition: .when(platforms: [.macOS]))
|
||||
],
|
||||
path: "Sources",
|
||||
resources: [.process("Resources")]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "MiniPlayer",
|
||||
dependencies: ["MiniPlayerCore"],
|
||||
path: "SourcesMac"
|
||||
),
|
||||
.executableTarget(
|
||||
name: "MiniPlayeriOS",
|
||||
dependencies: ["MiniPlayerCore"],
|
||||
path: "SourcesiOS"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
168
Sources/HLSRecorder.swift
Normal file
168
Sources/HLSRecorder.swift
Normal file
@ -0,0 +1,168 @@
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// iOS HLS 录制器 — 下载 HLS 流片段并合并为 MP4
|
||||
@MainActor
|
||||
final class HLSRecorder: ObservableObject {
|
||||
@Published var isRecording = false
|
||||
@Published var durationText = "00:00"
|
||||
|
||||
var onRecordingSaved: ((URL) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
|
||||
private var recordTask: Task<Void, Never>?
|
||||
private var startTime: Date?
|
||||
private var timer: Timer?
|
||||
|
||||
func startRecording(url: URL) {
|
||||
guard !isRecording else { return }
|
||||
isRecording = true
|
||||
startTime = Date()
|
||||
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self, let start = self.startTime else { return }
|
||||
let elapsed = Int(Date().timeIntervalSince(start))
|
||||
self.durationText = String(format: "%02d:%02d:%02d", elapsed/3600, (elapsed%3600)/60, elapsed%60)
|
||||
}
|
||||
}
|
||||
|
||||
recordTask = Task {
|
||||
do {
|
||||
let outputURL = try await downloadAndMergeHLS(url: url)
|
||||
await MainActor.run {
|
||||
self.isRecording = false
|
||||
self.timer?.invalidate()
|
||||
self.onRecordingSaved?(outputURL)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.isRecording = false
|
||||
self.timer?.invalidate()
|
||||
self.onError?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
recordTask?.cancel()
|
||||
recordTask = nil
|
||||
isRecording = false
|
||||
timer?.invalidate()
|
||||
}
|
||||
|
||||
private func downloadAndMergeHLS(url: URL) async throws -> URL {
|
||||
// 1. Download m3u8
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
guard let playlist = String(data: data, encoding: .utf8) else {
|
||||
throw RecorderError.invalidPlaylist
|
||||
}
|
||||
|
||||
// 2. Check if master playlist - if so, pick first variant
|
||||
let lines = playlist.components(separatedBy: .newlines)
|
||||
var isMaster = false
|
||||
var variantURL: URL?
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("#EXT-X-STREAM-INF") { isMaster = true }
|
||||
if isMaster && !trimmed.hasPrefix("#") && !trimmed.isEmpty {
|
||||
variantURL = resolveURL(trimmed, baseURL: url)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If master, re-download variant playlist
|
||||
let mediaPlaylist: String
|
||||
let baseURL: URL
|
||||
if let vURL = variantURL {
|
||||
let (vData, _) = try await URLSession.shared.data(from: vURL)
|
||||
guard let vStr = String(data: vData, encoding: .utf8) else { throw RecorderError.invalidPlaylist }
|
||||
mediaPlaylist = vStr
|
||||
baseURL = vURL.deletingLastPathComponent()
|
||||
} else {
|
||||
mediaPlaylist = playlist
|
||||
baseURL = url.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
// 3. Parse segment URLs
|
||||
let mLines = mediaPlaylist.components(separatedBy: .newlines)
|
||||
var segmentURLs: [URL] = []
|
||||
for line in mLines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.hasPrefix("#") && !trimmed.isEmpty {
|
||||
segmentURLs.append(resolveURL(trimmed, baseURL: baseURL))
|
||||
}
|
||||
}
|
||||
|
||||
guard !segmentURLs.isEmpty else { throw RecorderError.noSegments }
|
||||
|
||||
// 4. Download all segments to temp dir
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("MiniPlayer_rec_\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
|
||||
var segmentFiles: [URL] = []
|
||||
for (i, segURL) in segmentURLs.enumerated() {
|
||||
let localFile = tempDir.appendingPathComponent("seg_\(String(format: "%04d", i)).ts")
|
||||
let (segData, _) = try await URLSession.shared.data(from: segURL)
|
||||
try segData.write(to: localFile)
|
||||
segmentFiles.append(localFile)
|
||||
}
|
||||
|
||||
// 5. Merge segments into single MPEG-TS file
|
||||
let mergedTS = tempDir.appendingPathComponent("merged.ts")
|
||||
let output = try FileHandle(forWritingTo: mergedTS)
|
||||
for segFile in segmentFiles {
|
||||
let segData = try Data(contentsOf: segFile)
|
||||
output.write(segData)
|
||||
}
|
||||
output.closeFile()
|
||||
|
||||
// 6. Remux TS → MP4 using AVAssetExportSession
|
||||
let outputMP4 = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4")
|
||||
|
||||
let asset = AVURLAsset(url: mergedTS)
|
||||
if let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) {
|
||||
exportSession.outputURL = outputMP4
|
||||
exportSession.outputFileType = .mp4
|
||||
await exportSession.export()
|
||||
if exportSession.status != .completed {
|
||||
throw RecorderError.exportFailed(exportSession.error?.localizedDescription ?? "Unknown")
|
||||
}
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: mergedTS, to: outputMP4.deletingPathExtension().appendingPathExtension("ts"))
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
return outputMP4.deletingPathExtension().appendingPathExtension("ts")
|
||||
}
|
||||
|
||||
// 7. Clean up temp
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
|
||||
return outputMP4
|
||||
}
|
||||
|
||||
private func resolveURL(_ urlString: String, baseURL: URL) -> URL {
|
||||
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
|
||||
return URL(string: urlString)!
|
||||
}
|
||||
return baseURL.appendingPathComponent(urlString)
|
||||
}
|
||||
|
||||
enum RecorderError: LocalizedError {
|
||||
case invalidPlaylist
|
||||
case noSegments
|
||||
case exportFailed(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidPlaylist: return "Cannot parse HLS playlist"
|
||||
case .noSegments: return "No segments found in playlist"
|
||||
case .exportFailed(let msg): return "Export failed: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -123,12 +123,14 @@ struct MediaLibraryView: View {
|
||||
// MARK: - 侧边栏
|
||||
|
||||
private var sidebarView: some View {
|
||||
List(selection: $selectedSidebar) {
|
||||
List {
|
||||
Section {
|
||||
Label(L.allMedia, systemImage: "square.grid.2x2")
|
||||
.tag(SidebarItem.all)
|
||||
.onTapGesture { selectedSidebar = .all }
|
||||
Label(L.liked, systemImage: "heart.fill")
|
||||
.tag(SidebarItem.liked)
|
||||
.onTapGesture { selectedSidebar = .liked }
|
||||
}
|
||||
|
||||
Section(L.playlist) {
|
||||
@ -140,6 +142,7 @@ struct MediaLibraryView: View {
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
.tag(SidebarItem.playlist(pl.id))
|
||||
.onTapGesture { selectedSidebar = .playlist(pl.id) }
|
||||
.contextMenu {
|
||||
Button(L.delete, role: .destructive) {
|
||||
library.deletePlaylist(pl.id)
|
||||
@ -162,6 +165,7 @@ struct MediaLibraryView: View {
|
||||
ForEach(library.allTags, id: \.self) { tag in
|
||||
Label(tag, systemImage: "tag")
|
||||
.tag(SidebarItem.tag(tag))
|
||||
.onTapGesture { selectedSidebar = .tag(tag) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -255,7 +259,9 @@ struct MediaLibraryView: View {
|
||||
.foregroundColor(showUnavailable ? .orange : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if os(macOS)
|
||||
.help(showUnavailable ? L.hideUnavailable : L.showUnavailable)
|
||||
#endif
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
|
||||
@ -384,11 +390,15 @@ struct LibraryItemRow: View {
|
||||
case .forbidden:
|
||||
Image(systemName: "lock.fill")
|
||||
.foregroundColor(.red).font(.caption)
|
||||
#if os(macOS)
|
||||
.help(L.statusForbidden)
|
||||
#endif
|
||||
case .unavailable:
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.gray).font(.caption)
|
||||
#if os(macOS)
|
||||
.help(L.statusUnavailable)
|
||||
#endif
|
||||
case .unknown:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
@ -4,7 +4,11 @@ import Combine
|
||||
import SwiftBricks
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#elseif os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
// 主窗口关闭时退出应用
|
||||
class AppDel: NSObject, NSApplicationDelegate {
|
||||
private var closeObserver: Any?
|
||||
@ -56,18 +60,21 @@ class AppDel: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct MiniPlayerApp: App {
|
||||
public struct MiniPlayerApp: App {
|
||||
#if os(macOS)
|
||||
@NSApplicationDelegateAdaptor(AppDel.self) var appDelegate
|
||||
#endif
|
||||
@StateObject private var bridge = PlayerBridge()
|
||||
@StateObject private var engine = BricksEngine()
|
||||
|
||||
var body: some Scene {
|
||||
public init() {}
|
||||
|
||||
public var body: some Scene {
|
||||
WindowGroup {
|
||||
BricksAppView(bridge: bridge, engine: engine)
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 900, minHeight: 600)
|
||||
#endif
|
||||
.onAppear {
|
||||
bridge.setup()
|
||||
setupBridgeEvents()
|
||||
@ -177,7 +184,17 @@ struct MiniPlayerApp: App {
|
||||
let locale = Locale.current.language.languageCode?.identifier ?? "en"
|
||||
let langFile = (locale == "zh") ? "zh" : "en"
|
||||
|
||||
if let url = Bundle.module.url(forResource: "i18n/\(langFile)", withExtension: "json"),
|
||||
#if os(macOS)
|
||||
let bundle: Bundle = {
|
||||
if let mainResources = Bundle.main.url(forResource: "i18n", withExtension: nil) {
|
||||
return Bundle.main
|
||||
}
|
||||
return Bundle.module
|
||||
}()
|
||||
#else
|
||||
let bundle = Bundle.module
|
||||
#endif
|
||||
if let url = bundle.url(forResource: "i18n/\(langFile)", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] {
|
||||
engine.i18n.loadMessages(dict)
|
||||
@ -187,6 +204,7 @@ struct MiniPlayerApp: App {
|
||||
}
|
||||
|
||||
// MARK: - AVPlayerLayer 直接渲染(无内置控制栏)
|
||||
#if os(macOS)
|
||||
struct PlayerLayerView: NSViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
@ -222,8 +240,37 @@ class PlayerLayerHostView: NSView {
|
||||
playerLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
struct PlayerLayerView: UIViewRepresentable {
|
||||
let player: AVPlayer
|
||||
func makeUIView(context: Context) -> PlayerLayerHostViewiOS {
|
||||
let view = PlayerLayerHostViewiOS()
|
||||
view.playerLayer.player = player
|
||||
view.playerLayer.videoGravity = .resizeAspect
|
||||
return view
|
||||
}
|
||||
func updateUIView(_ uiView: PlayerLayerHostViewiOS, context: Context) {
|
||||
uiView.playerLayer.player = player
|
||||
}
|
||||
}
|
||||
class PlayerLayerHostViewiOS: UIView {
|
||||
let playerLayer = AVPlayerLayer()
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
layer.addSublayer(playerLayer)
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
playerLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 窗口级鼠标监控(替代 .onHover 和 background tracking area)
|
||||
#if os(macOS)
|
||||
class WindowMouseMonitor {
|
||||
static let shared = WindowMouseMonitor()
|
||||
private var monitor: Any?
|
||||
@ -254,6 +301,7 @@ class WindowMouseMonitor {
|
||||
win.standardWindowButton(.zoomButton)?.isHidden = !isInside
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 可复用的播放器内容视图(主窗口和全屏共用)
|
||||
struct PlayerContentView: View {
|
||||
@ -285,6 +333,7 @@ struct PlayerContentView: View {
|
||||
.onTapGesture {
|
||||
bridge.togglePlayPause()
|
||||
}
|
||||
#if os(macOS)
|
||||
.onDrop(of: [.fileURL, .text], isTargeted: nil) { providers in
|
||||
for provider in providers {
|
||||
// 尝试作为文件 URL
|
||||
@ -306,6 +355,7 @@ struct PlayerContentView: View {
|
||||
}
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
|
||||
// 播放状态指示(loading/buffering/error)
|
||||
PlayerStatusOverlay(bridge: bridge)
|
||||
@ -351,6 +401,7 @@ struct PlayerContentView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
#if os(macOS)
|
||||
// 初始状态:标题栏完全隐藏(含红绿灯按钮)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
guard let win = NSApplication.shared.windows.first(where: { $0.isVisible }) else { return }
|
||||
@ -367,6 +418,7 @@ struct PlayerContentView: View {
|
||||
// 用全局鼠标事件监听器替代 .onHover
|
||||
WindowMouseMonitor.shared.startMonitoring()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onChange(of: showToolbar) { _, newValue in
|
||||
if newValue {
|
||||
@ -427,6 +479,7 @@ struct ControlToolbar: View {
|
||||
|
||||
// 按钮行
|
||||
HStack(spacing: 10) {
|
||||
#if os(macOS)
|
||||
// 录制(放最前面,更显眼)
|
||||
if bridge.screenRecorder.isRecording {
|
||||
HStack(spacing: 4) {
|
||||
@ -446,10 +499,13 @@ struct ControlToolbar: View {
|
||||
}
|
||||
|
||||
Divider().frame(height: 20).padding(.horizontal, 4)
|
||||
#endif
|
||||
|
||||
TB(icon: "folder") { bridge.openFileDialog(); onTouch() }
|
||||
TB(icon: "link") { bridge.showURLDialog = true; onTouch() }
|
||||
#if os(macOS)
|
||||
TB(icon: "qrcode.viewfinder") { bridge.showQRScannerWindow(); onTouch() }
|
||||
#endif
|
||||
TB(icon: "backward.fill") { bridge.playPrev(); onTouch() }
|
||||
TB(icon: bridge.isPlaying ? "pause.fill" : "play.fill") { bridge.togglePlayPause(); onTouch() }
|
||||
TB(icon: "forward.fill") { bridge.playNext(); onTouch() }
|
||||
|
||||
@ -73,7 +73,9 @@ final class PlayerBridge: ObservableObject {
|
||||
@Published var showURLDialog = false
|
||||
@Published var showTrackDialog = false
|
||||
@Published var showQRScanner = false
|
||||
#if os(macOS)
|
||||
var qrScannerController: QRScannerWindowController?
|
||||
#endif
|
||||
@Published var toastMessage: String?
|
||||
@Published var availableTracks: [String] = []
|
||||
@Published var currentTrackIndex: Int = 0
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
import StreamRecorderKit
|
||||
#if os(macOS)
|
||||
import StreamRecorderKit
|
||||
import AppKit
|
||||
|
||||
// MARK: - RecorderState(应用退出保护)
|
||||
|
||||
2
SourcesMac/main.swift
Normal file
2
SourcesMac/main.swift
Normal file
@ -0,0 +1,2 @@
|
||||
import MiniPlayerCore
|
||||
MiniPlayerApp.main()
|
||||
2
SourcesiOS/main.swift
Normal file
2
SourcesiOS/main.swift
Normal file
@ -0,0 +1,2 @@
|
||||
import MiniPlayerCore
|
||||
MiniPlayerApp.main()
|
||||
171
scripts/build-ios.sh
Executable file
171
scripts/build-ios.sh
Executable file
@ -0,0 +1,171 @@
|
||||
#!/bin/bash
|
||||
# ========================================
|
||||
# MiniPlayer iOS 打包脚本
|
||||
# 生成 .ipa (需要签名配置)
|
||||
# ========================================
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
APP_NAME="MiniPlayer"
|
||||
VERSION=$(date +%Y.%m.%d)
|
||||
BUILD_DIR="$PROJECT_DIR/.build-ios"
|
||||
|
||||
# 签名配置 (修改为你的实际值)
|
||||
TEAM_ID="${IOS_TEAM_ID:-YOUR_TEAM_ID}"
|
||||
BUNDLE_ID="com.miniplayer.ios"
|
||||
PROVISIONING_PROFILE="${IOS_PROVISIONING_PROFILE:-}" # 留空用 automatic signing
|
||||
EXPORT_METHOD="development" # development / ad-hoc / app-store
|
||||
|
||||
echo "=== MiniPlayer iOS 打包 ==="
|
||||
echo "项目目录: $PROJECT_DIR"
|
||||
echo "版本: $VERSION"
|
||||
echo "Team ID: $TEAM_ID"
|
||||
|
||||
# 1. 生成 Xcode 项目
|
||||
echo ""
|
||||
echo "--- Step 1: 生成 Xcode 项目 ---"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
cat > project.yml << EOF
|
||||
name: MiniPlayer
|
||||
options:
|
||||
bundleIdPrefix: com.miniplayer
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "15.0"
|
||||
generateEmptyDirectories: true
|
||||
|
||||
packages:
|
||||
SwiftBricks:
|
||||
path: ../swiftbricks
|
||||
StreamRecorder:
|
||||
path: ../StreamRecorder
|
||||
|
||||
targets:
|
||||
MiniPlayer:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Sources
|
||||
excludes:
|
||||
- "**/PlayerRecorder.swift"
|
||||
dependencies:
|
||||
- package: SwiftBricks
|
||||
- package: StreamRecorder
|
||||
product: StreamRecorderKit
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: $BUNDLE_ID
|
||||
MARKETING_VERSION: $VERSION
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
INFOPLIST_KEY_CFBundleDisplayName: "MiniPlayer"
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: true
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: true
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation: true
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
SWIFT_VERSION: "5.9"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: $TEAM_ID
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
resources:
|
||||
- path: Sources/Resources
|
||||
EOF
|
||||
|
||||
if command -v xcodegen &> /dev/null; then
|
||||
xcodegen generate
|
||||
echo " ✓ Xcode 项目生成成功"
|
||||
else
|
||||
echo " ⚠ xcodegen 未安装,使用 xcodebuild + SPM 模式"
|
||||
rm -f project.yml
|
||||
fi
|
||||
|
||||
# 2. Archive
|
||||
echo ""
|
||||
echo "--- Step 2: Archive ---"
|
||||
|
||||
if [ -f "$PROJECT_DIR/MiniPlayer.xcodeproj/project.pbxproj" ]; then
|
||||
# 使用 xcodegen 生成的项目
|
||||
SCHEME="MiniPlayer"
|
||||
PROJECT_FLAG="-project $PROJECT_DIR/MiniPlayer.xcodeproj"
|
||||
else
|
||||
# 使用 SPM 自动生成的项目
|
||||
SCHEME="MiniPlayeriOS"
|
||||
PROJECT_FLAG=""
|
||||
fi
|
||||
|
||||
ARCHIVE_PATH="$BUILD_DIR/$APP_NAME.xcarchive"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
xcodebuild archive \
|
||||
$PROJECT_FLAG \
|
||||
-scheme "$SCHEME" \
|
||||
-destination "generic/platform=iOS" \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-allowProvisioningUpdates \
|
||||
2>&1 | tail -20
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo " ✗ Archive 失败"
|
||||
echo " 提示: 确保 Team ID 和签名配置正确"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " ✓ Archive 完成"
|
||||
|
||||
# 3. Export .ipa
|
||||
echo ""
|
||||
echo "--- Step 3: 导出 .ipa ---"
|
||||
|
||||
# 生成 ExportOptions.plist
|
||||
cat > "$BUILD_DIR/ExportOptions.plist" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>$EXPORT_METHOD</string>
|
||||
<key>teamID</key>
|
||||
<string>$TEAM_ID</string>
|
||||
<key>compileBitcode</key>
|
||||
<false/>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>thinning</key>
|
||||
<string><none></string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
IPA_OUTPUT="$BUILD_DIR/ipa"
|
||||
mkdir -p "$IPA_OUTPUT"
|
||||
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-exportPath "$IPA_OUTPUT" \
|
||||
-exportOptionsPlist "$BUILD_DIR/ExportOptions.plist" \
|
||||
-allowProvisioningUpdates \
|
||||
2>&1 | tail -10
|
||||
|
||||
IPA_FILE=$(find "$IPA_OUTPUT" -name "*.ipa" -type f | head -1)
|
||||
|
||||
if [ -n "$IPA_FILE" ]; then
|
||||
echo ""
|
||||
echo "=============================="
|
||||
echo " iOS 打包完成!"
|
||||
echo "=============================="
|
||||
echo " .ipa: $IPA_FILE"
|
||||
echo " 大小: $(du -h "$IPA_FILE" | cut -f1)"
|
||||
echo ""
|
||||
echo " 安装方式:"
|
||||
echo " - Xcode: Window > Devices > Add App"
|
||||
echo " - AltStore / SideStore: 直接导入 .ipa"
|
||||
echo " - TestFlight: 使用 app-store 签名方式"
|
||||
else
|
||||
echo " ✗ .ipa 导出失败"
|
||||
echo " 提示: 检查签名配置和 ExportOptions.plist"
|
||||
fi
|
||||
|
||||
# 清理临时文件
|
||||
rm -f "$PROJECT_DIR/project.yml"
|
||||
149
scripts/build-macos.sh
Executable file
149
scripts/build-macos.sh
Executable file
@ -0,0 +1,149 @@
|
||||
#!/bin/bash
|
||||
# ========================================
|
||||
# MiniPlayer macOS 打包脚本
|
||||
# 生成 .app bundle + .dmg 安装包
|
||||
# ========================================
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BUILD_DIR="$PROJECT_DIR/.build/release"
|
||||
APP_NAME="MiniPlayer"
|
||||
BUNDLE_ID="com.miniplayer.app"
|
||||
VERSION=$(date +%Y.%m.%d)
|
||||
|
||||
APP_BUNDLE="$PROJECT_DIR/$APP_NAME.app"
|
||||
DMG_OUTPUT="$PROJECT_DIR/$APP_NAME-$VERSION.dmg"
|
||||
|
||||
echo "=== MiniPlayer macOS 打包 ==="
|
||||
echo "项目目录: $PROJECT_DIR"
|
||||
echo "版本: $VERSION"
|
||||
echo ""
|
||||
|
||||
# 1. 清理旧构建
|
||||
echo "[1/6] 清理旧构建..."
|
||||
rm -rf "$APP_BUNDLE"
|
||||
rm -f "$DMG_OUTPUT"
|
||||
|
||||
# 2. 编译 Release
|
||||
echo "[2/6] 编译 Release 版本..."
|
||||
cd "$PROJECT_DIR"
|
||||
swift build -c release
|
||||
echo " ✓ 编译完成"
|
||||
|
||||
# 3. 创建 .app bundle 结构
|
||||
echo "[3/6] 创建 .app bundle..."
|
||||
mkdir -p "$APP_BUNDLE/Contents/MacOS"
|
||||
mkdir -p "$APP_BUNDLE/Contents/Resources/i18n"
|
||||
mkdir -p "$APP_BUNDLE/Contents/Resources/zh-Hans.lproj"
|
||||
mkdir -p "$APP_BUNDLE/Contents/Resources/en.lproj"
|
||||
|
||||
# 4. 拷贝文件
|
||||
echo "[4/6] 拷贝文件..."
|
||||
# 可执行文件
|
||||
cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/"
|
||||
|
||||
# 资源文件
|
||||
cp "$PROJECT_DIR/Sources/Resources/miniplayer.ui" "$APP_BUNDLE/Contents/Resources/"
|
||||
cp "$PROJECT_DIR/Sources/Resources/i18n/en.json" "$APP_BUNDLE/Contents/Resources/i18n/"
|
||||
cp "$PROJECT_DIR/Sources/Resources/i18n/zh.json" "$APP_BUNDLE/Contents/Resources/i18n/"
|
||||
cp "$PROJECT_DIR/Sources/Resources/zh-Hans.lproj/Localizable.strings" "$APP_BUNDLE/Contents/Resources/zh-Hans.lproj/"
|
||||
cp "$PROJECT_DIR/Sources/Resources/en.lproj/Localizable.strings" "$APP_BUNDLE/Contents/Resources/en.lproj/"
|
||||
|
||||
# App 图标(如果有)
|
||||
if [ -f "$PROJECT_DIR/Resources/AppIcon.icns" ]; then
|
||||
cp "$PROJECT_DIR/Resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/"
|
||||
ICON_FILE="AppIcon.icns"
|
||||
else
|
||||
ICON_FILE=""
|
||||
fi
|
||||
|
||||
# 5. 生成 Info.plist
|
||||
echo "[5/6] 生成 Info.plist..."
|
||||
cat > "$APP_BUNDLE/Contents/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$BUNDLE_ID</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>MiniPlayer</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$VERSION</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$VERSION</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.entertainment</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © $(date +%Y) MiniPlayer. All rights reserved.</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>${ICON_FILE:-}</string>
|
||||
<key>CFBundleResourceSpecification</key>
|
||||
<string></string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<true/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<false/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>MiniPlayer 需要摄像头权限来扫描二维码添加播放地址。</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>MiniPlayer 需要麦克风权限来录制音频。</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
echo " ✓ .app bundle 创建完成: $APP_BUNDLE"
|
||||
|
||||
# 6. 创建 DMG
|
||||
echo "[6/6] 创建 DMG 安装包..."
|
||||
DMG_TEMP="$PROJECT_DIR/.dmg_temp"
|
||||
DMG_RW="$PROJECT_DIR/.dmg_rw.dmg"
|
||||
DMG_SIZE="200m"
|
||||
|
||||
mkdir -p "$DMG_TEMP"
|
||||
cp -R "$APP_BUNDLE" "$DMG_TEMP/"
|
||||
|
||||
# 创建 Applications 快捷方式
|
||||
ln -s /Applications "$DMG_TEMP/Applications"
|
||||
|
||||
# 创建临时 DMG
|
||||
hdiutil create -srcfolder "$DMG_TEMP" -volname "$APP_NAME" \
|
||||
-fs HFS+ -fsargs "-c c=64,a=16,e=16" \
|
||||
-format UDRW -size "$DMG_SIZE" "$DMG_RW" 2>/dev/null
|
||||
|
||||
# 转换为压缩 DMG
|
||||
hdiutil convert "$DMG_RW" -format UDZO -imagekey zlib-level=9 -o "$DMG_OUTPUT" 2>/dev/null
|
||||
|
||||
# 清理临时文件
|
||||
rm -rf "$DMG_TEMP"
|
||||
rm -f "$DMG_RW"
|
||||
|
||||
echo " ✓ DMG 创建完成: $DMG_OUTPUT"
|
||||
|
||||
# 显示结果
|
||||
echo ""
|
||||
echo "=============================="
|
||||
echo " 打包完成!"
|
||||
echo "=============================="
|
||||
echo " .app: $APP_BUNDLE"
|
||||
echo " .dmg: $DMG_OUTPUT"
|
||||
APP_SIZE=$(du -sh "$APP_BUNDLE" | cut -f1)
|
||||
DMG_SIZE=$(du -sh "$DMG_OUTPUT" | cut -f1)
|
||||
echo " .app 大小: $APP_SIZE"
|
||||
echo " .dmg 大小: $DMG_SIZE"
|
||||
echo ""
|
||||
echo " 双击 .app 直接运行"
|
||||
echo " 双击 .dmg 安装(拖入 Applications)"
|
||||
Loading…
x
Reference in New Issue
Block a user