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:
yumoqing 2026-07-01 08:29:16 +08:00
parent 103b994c60
commit d22a5037b8
11 changed files with 587 additions and 9 deletions

7
.gitignore vendored
View File

@ -3,3 +3,10 @@ build/
*.xcodeproj
.DS_Store
MiniPlayer.app/
# Build artifacts
*.dmg
MiniPlayer.app/
.build-ios/
*.xcodeproj/
project.yml

View File

@ -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
View 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

View File

@ -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()
}

View File

@ -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() }

View File

@ -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

View File

@ -1,6 +1,6 @@
import Foundation
import StreamRecorderKit
#if os(macOS)
import StreamRecorderKit
import AppKit
// MARK: - RecorderState退

2
SourcesMac/main.swift Normal file
View File

@ -0,0 +1,2 @@
import MiniPlayerCore
MiniPlayerApp.main()

2
SourcesiOS/main.swift Normal file
View File

@ -0,0 +1,2 @@
import MiniPlayerCore
MiniPlayerApp.main()

171
scripts/build-ios.sh Executable file
View 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>&lt;none&gt;</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
View 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"