commit c4080b5cb9dc47306d18ae0ffe62d2268ac725d2 Author: 徐翔宇 Date: Wed Apr 22 08:20:47 2026 +0800 v1.0.0: initial AirDrop plugin for MioIsland Quick-access AirDrop from the notch panel. Tap the card body, pick files via NSOpenPanel, hand off to NSSharingService(.sendViaAirDrop). No private APIs, no entitlements, no network — just a thin wrapper around the same AirDrop API Finder uses. Design constraint: drag-and-drop is intentionally not implemented. MioIsland's notch panel auto-collapses on click-outside, which breaks the Cmd-Tab-to-Finder / grab / drag-back workflow before the drop target reaches. Tap-to-choose is rock solid by comparison. Requires MioIsland host v2.2.0+ (panel size clamp floor was lowered to 120pt in that release). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33f779a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +.DS_Store +*.swiftmodule +*.dSYM +.build/ diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..1dcf651 --- /dev/null +++ b/Info.plist @@ -0,0 +1,46 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + AirDropPlugin + CFBundleIdentifier + com.mioisland.plugin.airdrop + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AirDrop + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + NSPrincipalClass + AirDropPlugin.AirDropPlugin + + MioPluginPreferredWidth + 440 + MioPluginPreferredHeight + 280 + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a44fc63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MioMioOS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9153820 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# AirDrop Plugin for MioIsland + +Quick access to macOS AirDrop from your Mio Island panel. Tap the +center of the card to pick files, then the system AirDrop sheet +appears and lets you send to nearby devices or contacts. + +[简体中文](README.zh-CN.md) + +## Features + +- **One-tap file picker** — opens the native `NSOpenPanel`, supports + multi-select and folders. +- **Native AirDrop sheet** — uses `NSSharingService(.sendViaAirDrop)`, + the same API Finder uses. No private APIs, no entitlements. +- **Bi-lingual UI** — follows MioIsland's `appLanguage` preference + (`zh` / `en`). +- **Zero permissions required** — no Accessibility, no Automation, no + network. The plugin just delegates to macOS' built-in AirDrop. + +## Why no drag-and-drop? + +Mio Island's notch panel auto-collapses when the user clicks outside +it — that means the natural "open Island → Cmd-Tab to Finder → grab +file → drag back" workflow can't work, the panel disappears before +the drop target reaches. Rather than fight the host's event routing +(we tried, it broke in three different ways), this plugin commits +to a rock-solid **tap-to-choose** UX. + +If you want drag-based sharing, stay tuned — a future Mio Island +host release may add a global drag-to-open trigger (similar to +Atoll's Dynamic Island drop zone). + +## Requirements + +- macOS 15.0+ +- Mio Island v2.2.0+ + +## Building from source + +```bash +./build.sh +``` + +Produces `build/airdrop.bundle` and `build/airdrop.zip`. Drop the +bundle into `~/.config/codeisland/plugins/` to test locally. + +## Structure + +``` +Sources/ +├── MioPlugin.swift ← protocol copy (runtime-matched via @objc) +├── AirDropPlugin.swift ← principal class, Info.plist NSPrincipalClass +├── AirDropState.swift ← @MainActor ObservableObject phase machine +├── services/ +│ └── AirDropService.swift ← thin wrapper around NSSharingService +├── ui/ +│ ├── ExpandedView.swift ← card-in-container layout +│ └── DropZoneView.swift ← tap-target, phase-reactive visuals +└── support/ + ├── Localization.swift + └── HostVersionCheck.swift +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..95bd6b8 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,63 @@ +# 隔空投送插件(Mio Island) + +在 Mio Island 面板里一键打开系统隔空投送。点一下中间的卡片区域,选 +文件,系统原生的 AirDrop 弹窗就会出现,发给附近的设备或联系人。 + +[English](README.md) + +## 功能 + +- **一键选文件** — 弹出系统原生 `NSOpenPanel`,支持多选、文件夹 +- **原生 AirDrop 弹窗** — 用 `NSSharingService(.sendViaAirDrop)`, + Finder 发 AirDrop 就是用这个 API。不涉及私有 API,不需要 entitlements +- **中英双语** — 跟随 Mio Island 的 `appLanguage` 设置(`zh` / `en`) +- **零权限要求** — 不需要辅助功能、自动化、网络,所有发送走 macOS + 内置 AirDrop + +## 为什么不做拖拽? + +Mio Island 的刘海面板在用户点击面板外区域时会自动收起 —— 这使得 +"展开 Island → Cmd-Tab 到 Finder → 抓文件 → 拖回来" 这条交互无 +法成立,因为拖拽目标在用户还没到之前就消失了。我们尝试过三次修改 +host 的事件路由来支持持久化,每次都暴出新 bug(乱触、卡顿、冻结 UI)。 + +所以这个插件**只做点击选择文件**这条路径,稳如老狗。 + +如果你想要拖拽的体验,未来 Mio Island 主程序可能会加一个全局 +拖拽触发机制(类似 Atoll 的 Dynamic Island),届时这个插件会自 +动受益。 + +## 系统要求 + +- macOS 15.0+ +- Mio Island v2.2.0+ + +## 本地构建 + +```bash +./build.sh +``` + +产物在 `build/airdrop.bundle` 和 `build/airdrop.zip`。复制 bundle +到 `~/.config/codeisland/plugins/` 即可本地测试。 + +## 代码结构 + +``` +Sources/ +├── MioPlugin.swift ← 协议副本(@objc 运行时按签名匹配) +├── AirDropPlugin.swift ← principal 类,对应 Info.plist 的 NSPrincipalClass +├── AirDropState.swift ← @MainActor ObservableObject,状态机 +├── services/ +│ └── AirDropService.swift ← NSSharingService 的薄封装 +├── ui/ +│ ├── ExpandedView.swift ← 卡片容器布局 +│ └── DropZoneView.swift ← 可点击目标,根据 phase 做视觉反馈 +└── support/ + ├── Localization.swift + └── HostVersionCheck.swift +``` + +## 许可 + +MIT — 见 [LICENSE](LICENSE)。 diff --git a/Sources/AirDropPlugin.swift b/Sources/AirDropPlugin.swift new file mode 100644 index 0000000..289eedb --- /dev/null +++ b/Sources/AirDropPlugin.swift @@ -0,0 +1,34 @@ +// +// AirDropPlugin.swift +// MioIsland AirDrop Plugin +// +// Principal class. Module is `AirDropPlugin`, class is `AirDropPlugin`, +// so Info.plist NSPrincipalClass = "AirDropPlugin.AirDropPlugin". +// +// Wraps NSSharingService(.sendViaAirDrop) — a public Apple API — in a +// Mio Island panel. No private APIs, no entitlements, no network. +// + +import AppKit +import SwiftUI + +final class AirDropPlugin: NSObject, MioPlugin { + var id: String { "airdrop" } + var name: String { "AirDrop" } + var icon: String { "airplayaudio" } + var version: String { "1.0.0" } + + func activate() { + NSLog("[mio-plugin-airdrop] activate") + } + + func deactivate() { + NSLog("[mio-plugin-airdrop] deactivate") + } + + func makeView() -> NSView { + let view = NSHostingView(rootView: ExpandedView()) + view.autoresizingMask = [.width, .height] + return view + } +} diff --git a/Sources/AirDropState.swift b/Sources/AirDropState.swift new file mode 100644 index 0000000..07ad20a --- /dev/null +++ b/Sources/AirDropState.swift @@ -0,0 +1,83 @@ +// +// AirDropState.swift +// MioIsland AirDrop Plugin +// +// Shared state + send coordinator. Single source of truth for what +// the panel should render. Not strictly "ObservableObject" heavy — +// we have only three phases, but using @Published keeps the door +// open for future "sending progress" UI. +// + +import AppKit +import Combine +import Foundation + +@MainActor +final class AirDropState: ObservableObject { + static let shared = AirDropState() + + enum Phase: Equatable { + case idle + case showingPicker + case sent(count: Int) + case error(message: String) + } + + @Published var phase: Phase = .idle + + /// Last completed send — used to animate a brief "✓ 已发送" toast. + @Published private(set) var lastSendAt: Date? + + /// Called by UI when user drops files or picks from NSOpenPanel. + /// Kicks off NSSharingService (opens the AirDrop chooser window) + /// and resets phase when the user dismisses the chooser. + func send(files: [URL]) { + let urls = files.filter { $0.isFileURL } + guard !urls.isEmpty else { return } + + phase = .showingPicker + + AirDropService.perform(files: urls) { [weak self] result in + guard let self else { return } + Task { @MainActor in + switch result { + case .success: + self.phase = .sent(count: urls.count) + self.lastSendAt = Date() + // Drop back to idle after 2.5s so the drop zone is + // usable for the next file without a click. + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in + guard let self else { return } + if case .sent = self.phase { + self.phase = .idle + } + } + case .failure(let err): + self.phase = .error(message: err.localizedDescription) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self else { return } + if case .error = self.phase { + self.phase = .idle + } + } + } + } + } + } + + /// Open NSOpenPanel to let the user pick files manually, then send. + func chooseAndSend() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = true + panel.canChooseFiles = true + panel.message = L10n.chooseFilesTitle + panel.prompt = L10n.choose + + // Run modal on the main thread so it doesn't race the drop zone. + panel.begin { [weak self] response in + guard response == .OK else { return } + self?.send(files: panel.urls) + } + } +} diff --git a/Sources/MioPlugin.swift b/Sources/MioPlugin.swift new file mode 100644 index 0000000..d1a1c09 --- /dev/null +++ b/Sources/MioPlugin.swift @@ -0,0 +1,22 @@ +// +// MioPlugin.swift +// MioIsland Plugin SDK (duplicated into each external plugin) +// +// At runtime, @objc protocol conformance is matched by selector +// signatures, not by module identity, so this standalone copy +// works even though the host defines its own MioPlugin protocol +// in a different Swift module. +// + +import AppKit + +@objc protocol MioPlugin: AnyObject { + var id: String { get } + var name: String { get } + var icon: String { get } + var version: String { get } + func activate() + func deactivate() + func makeView() -> NSView + @objc optional func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? +} diff --git a/Sources/services/AirDropService.swift b/Sources/services/AirDropService.swift new file mode 100644 index 0000000..ebc79db --- /dev/null +++ b/Sources/services/AirDropService.swift @@ -0,0 +1,55 @@ +// +// AirDropService.swift +// MioIsland AirDrop Plugin +// +// Thin wrapper around NSSharingService(.sendViaAirDrop). This is a +// 100% public Apple API — no entitlements, no permissions, no +// private frameworks. When invoked, macOS pops the native AirDrop +// chooser sheet; we don't render the device list ourselves. +// + +import AppKit +import Foundation + +enum AirDropError: Error, LocalizedError { + case serviceUnavailable + case cannotPerform + + var errorDescription: String? { + switch self { + case .serviceUnavailable: + return L10n.errServiceUnavailable + case .cannotPerform: + return L10n.errCannotPerform + } + } +} + +enum AirDropService { + /// Invoke macOS' AirDrop chooser for the given files. + /// + /// NSSharingService does NOT surface per-recipient completion, + /// so we can only distinguish "could not start" vs "started and + /// returned control to us". A returned .success(()) means the + /// chooser sheet was presented; the user may have cancelled it, + /// but from our UI perspective we treat it as handled. + static func perform( + files: [URL], + completion: @escaping (Result) -> Void + ) { + DispatchQueue.main.async { + guard let service = NSSharingService(named: .sendViaAirDrop) else { + completion(.failure(AirDropError.serviceUnavailable)) + return + } + guard service.canPerform(withItems: files) else { + completion(.failure(AirDropError.cannotPerform)) + return + } + service.perform(withItems: files) + // AirDrop chooser is modal; perform() returns right after + // presenting. Treat that as success. + completion(.success(())) + } + } +} diff --git a/Sources/support/HostVersionCheck.swift b/Sources/support/HostVersionCheck.swift new file mode 100644 index 0000000..b0ec9f6 --- /dev/null +++ b/Sources/support/HostVersionCheck.swift @@ -0,0 +1,46 @@ +// +// HostVersionCheck.swift +// MioIsland AirDrop Plugin +// +// Gates the plugin against a minimum host version. Plugin relies on +// the v2.2.0 panel-sizing rules (min height 120, floating back chip, +// no chrome header), so older hosts would render this plugin wrong. +// + +import Foundation + +enum HostVersionCheck { + static let minHost = "2.2.0" + + /// Bundle.main inside a plugin dylib points to the *host* app, so + /// we can read its CFBundleShortVersionString to gate features. + static func isOK() -> Bool { + let host = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + return compare(host, ">=", minHost) + } + + static func hostVersion() -> String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" + } + + // MARK: - Pure semver compare + + private static func compare(_ a: String, _ op: String, _ b: String) -> Bool { + let aParts = parts(a) + let bParts = parts(b) + let maxLen = max(aParts.count, bParts.count) + let ap = aParts + Array(repeating: 0, count: maxLen - aParts.count) + let bp = bParts + Array(repeating: 0, count: maxLen - bParts.count) + + switch op { + case ">=": return !ap.lexicographicallyPrecedes(bp) + case "<": return ap.lexicographicallyPrecedes(bp) + case "==": return ap == bp + default: return false + } + } + + private static func parts(_ s: String) -> [Int] { + s.split(separator: ".").compactMap { Int($0) } + } +} diff --git a/Sources/support/Localization.swift b/Sources/support/Localization.swift new file mode 100644 index 0000000..250a20e --- /dev/null +++ b/Sources/support/Localization.swift @@ -0,0 +1,87 @@ +// +// Localization.swift +// MioIsland AirDrop Plugin +// +// zh/en string map. Host's `appLanguage` UserDefault is the source +// of truth, with "auto" falling back to system locale. +// + +import Foundation + +enum L10n { + static var isChinese: Bool { + let setting = UserDefaults.standard.string(forKey: "appLanguage") ?? "auto" + switch setting { + case "zh": return true + case "en": return false + default: + if let code = Locale.current.language.languageCode?.identifier, + code.hasPrefix("zh") { + return true + } + if let pref = Locale.preferredLanguages.first, + pref.hasPrefix("zh") { + return true + } + return false + } + } + + // MARK: - Title / primary CTA + + static var title: String { + isChinese ? "隔空投送" : "AirDrop" + } + + static var chooseFiles: String { + isChinese ? "选择文件" : "Choose files" + } + + static var clickToChoose: String { + isChinese ? "点击选择要发送的文件" : "Tap to pick files to send" + } + + static var choose: String { + isChinese ? "选择" : "Choose" + } + + static var chooseFilesTitle: String { + isChinese ? "选择要通过隔空投送发送的文件" : "Choose files to AirDrop" + } + + // MARK: - Status + + static var opening: String { + isChinese ? "正在打开隔空投送…" : "Opening AirDrop…" + } + + static func sentCount(_ n: Int) -> String { + isChinese ? "✓ 已发送 \(n) 个文件" : "✓ Sent \(n) file\(n == 1 ? "" : "s")" + } + + // MARK: - Errors + + static var errServiceUnavailable: String { + isChinese + ? "隔空投送服务不可用(系统可能未开启)" + : "AirDrop service unavailable (check System Settings)" + } + + static var errCannotPerform: String { + isChinese + ? "这些文件无法通过隔空投送发送" + : "These files cannot be sent via AirDrop" + } + + // MARK: - Host upgrade hint + + static var hostUpgradeTitle: String { + isChinese ? "需要 Mio Island v2.2.0+" : "Mio Island v2.2.0+ required" + } + + static var hostUpgradeHint: String { + isChinese + ? "请升级主 app 以启用本插件" + : "Please upgrade Mio Island to unlock this plugin" + } +} diff --git a/Sources/ui/DropZoneView.swift b/Sources/ui/DropZoneView.swift new file mode 100644 index 0000000..1edc97c --- /dev/null +++ b/Sources/ui/DropZoneView.swift @@ -0,0 +1,129 @@ +// +// DropZoneView.swift +// MioIsland AirDrop Plugin +// +// Click-to-choose zone at the center of the panel. Tapping anywhere +// inside opens NSOpenPanel → user picks files → NSSharingService +// fires the AirDrop chooser sheet. +// +// Drag-and-drop was intentionally removed: macOS Dynamic Island +// panels auto-collapse when the user clicks outside them, which +// prevents the Cmd-Tab → Finder → grab file → drag back workflow. +// Rather than fight the host's event routing, we commit to the +// click-to-choose path which is rock-solid. +// + +import SwiftUI + +struct DropZoneView: View { + @ObservedObject var state: AirDropState = .shared + + private static let lime = Color( + red: 0xCA / 255.0, + green: 0xFF / 255.0, + blue: 0x00 / 255.0 + ) + private static let ink = Color.white + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(fillColor) + + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(strokeColor, lineWidth: 1) + + centerContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + guard case .idle = state.phase else { return } + state.chooseAndSend() + } + .animation(.easeInOut(duration: 0.25), value: state.phase) + } + + // MARK: - Reactive styling + + private var fillColor: Color { + switch state.phase { + case .sent: return Self.lime.opacity(0.14) + case .showingPicker: return Color.white.opacity(0.08) + case .error: return Color.red.opacity(0.08) + case .idle: return Color.white.opacity(0.04) + } + } + + private var strokeColor: Color { + switch state.phase { + case .sent: return Self.lime.opacity(0.9) + case .showingPicker: return Self.lime.opacity(0.6) + case .error: return Color.red.opacity(0.7) + case .idle: return Color.white.opacity(0.18) + } + } + + // MARK: - Center content (icon + label) + + @ViewBuilder + private var centerContent: some View { + VStack(spacing: 8) { + Image(systemName: iconName) + .font(.system(size: 32, weight: .light)) + .foregroundColor(iconColor) + + Text(primaryText) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(primaryColor) + .multilineTextAlignment(.center) + + if let sub = secondaryText { + Text(sub) + .font(.system(size: 11)) + .foregroundColor(Self.ink.opacity(0.5)) + } + } + .padding(.horizontal, 20) + } + + private var iconName: String { + switch state.phase { + case .sent: return "checkmark.circle.fill" + case .error: return "exclamationmark.triangle.fill" + case .showingPicker: return "airplayaudio" + case .idle: return "airplayaudio" + } + } + + private var iconColor: Color { + switch state.phase { + case .sent: return Self.lime + case .error: return Color.red.opacity(0.8) + case .showingPicker: return Self.ink.opacity(0.85) + case .idle: return Self.ink.opacity(0.7) + } + } + + private var primaryText: String { + switch state.phase { + case .sent(let n): return L10n.sentCount(n) + case .error(let m): return m + case .showingPicker: return L10n.opening + case .idle: return L10n.chooseFiles + } + } + + private var primaryColor: Color { + switch state.phase { + case .sent: return Self.lime + case .error: return Color.red.opacity(0.9) + default: return Self.ink.opacity(0.85) + } + } + + private var secondaryText: String? { + guard case .idle = state.phase else { return nil } + return L10n.clickToChoose + } +} diff --git a/Sources/ui/ExpandedView.swift b/Sources/ui/ExpandedView.swift new file mode 100644 index 0000000..3f5e94d --- /dev/null +++ b/Sources/ui/ExpandedView.swift @@ -0,0 +1,135 @@ +// +// ExpandedView.swift +// MioIsland AirDrop Plugin +// +// Main panel rendered when the user opens the AirDrop plugin in +// Mio Island. Card-in-container layout: top transparent strip for +// the notch + floating back chip, themed card below with the drop +// zone + "choose files" button. +// + +import AppKit +import SwiftUI + +struct ExpandedView: View { + @ObservedObject private var state: AirDropState = .shared + + private static let ink = Color.white + private static let base = Color(red: 0x0A / 255.0, green: 0x0A / 255.0, blue: 0x0A / 255.0) + + // MARK: - Body + + var body: some View { + Group { + if HostVersionCheck.isOK() { + playingCard + } else { + hostUpgradeCard + } + } + .animation(.easeInOut(duration: 0.2), value: state.phase) + } + + // MARK: - Main panel (when host version OK) + + private var playingCard: some View { + // Layout (panel 440×280): + // 40 top transparent (notch + back chevron area) + // 20 margin + // 180 card (14 pad + ~135 content + 14 pad) + // 20 margin + // = 260pt used, 20pt buffer for SwiftUI rounding. + VStack(spacing: 0) { + Color.clear.frame(height: 40) // notch strip + Color.clear.frame(height: 20) // margin top + + cardContent + .padding(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Self.base.opacity(0.88)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + + Color.clear.frame(height: 20) // margin bottom + } + .padding(.horizontal, 16) + } + + private var cardContent: some View { + VStack(spacing: 10) { + // Header row — just the title. The whole DropZoneView + // below is the tap target now; no redundant chip button. + HStack(alignment: .center, spacing: 6) { + Image(systemName: "airplayaudio") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Self.ink.opacity(0.9)) + Text(L10n.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Self.ink.opacity(0.95)) + Spacer() + } + + // Big tap target for the whole card body. + DropZoneView() + } + } + + // MARK: - Host upgrade card + + private var hostUpgradeCard: some View { + VStack(spacing: 0) { + Color.clear.frame(height: 40) + Color.clear.frame(height: 20) + + VStack(spacing: 12) { + HStack(spacing: 14) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 24, weight: .regular)) + .foregroundColor(.orange) + .frame(width: 48, height: 48) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.orange.opacity(0.12)) + ) + + VStack(alignment: .leading, spacing: 4) { + Text(L10n.hostUpgradeTitle) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Self.ink.opacity(0.9)) + Text(L10n.hostUpgradeHint) + .font(.system(size: 11)) + .foregroundColor(Self.ink.opacity(0.55)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Text("Host: v\(HostVersionCheck.hostVersion()) · required: v\(HostVersionCheck.minHost)+") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(Self.ink.opacity(0.4)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(EdgeInsets(top: 18, leading: 16, bottom: 18, trailing: 16)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Self.base.opacity(0.88)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + + Color.clear.frame(height: 20) + } + .padding(.horizontal, 16) + } +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..c2a7d95 --- /dev/null +++ b/build.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Build the AirDrop plugin as a .bundle for MioIsland +set -e + +PLUGIN_NAME="airdrop" +MODULE_NAME="AirDropPlugin" +BUNDLE_NAME="${PLUGIN_NAME}.bundle" +BUILD_DIR="build" + +# Recursively pick up every .swift under Sources/ (root + subdirectories +# like services/, ui/, support/ for the layered layout). +SOURCES=$(find Sources -name "*.swift" -type f) + +echo "Building ${PLUGIN_NAME} plugin..." +echo "Compiling $(echo "$SOURCES" | wc -l | tr -d ' ') Swift files..." + +# Clean +rm -rf "${BUILD_DIR}" +mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS" + +# Compile to dynamic library +swiftc \ + -emit-library \ + -module-name "${MODULE_NAME}" \ + -target arm64-apple-macos15.0 \ + -sdk $(xcrun --show-sdk-path) \ + -o "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS/${MODULE_NAME}" \ + ${SOURCES} + +# Copy Info.plist +cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/" + +# Optional resources +if [ -d "Resources" ] && [ "$(ls -A Resources 2>/dev/null)" ]; then + mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources" + cp -R Resources/* "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/" +fi + +# Ad-hoc sign +codesign --force --deep --sign - "${BUILD_DIR}/${BUNDLE_NAME}" + +echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}" + +# Create zip for marketplace upload +cd "${BUILD_DIR}" +rm -f "${PLUGIN_NAME}.zip" +zip -rq "${PLUGIN_NAME}.zip" "${BUNDLE_NAME}" +cd .. + +echo "✓ Created ${BUILD_DIR}/${PLUGIN_NAME}.zip (for marketplace upload)" +echo "" +echo "Install locally:" +echo " cp -r ${BUILD_DIR}/${BUNDLE_NAME} ~/.config/codeisland/plugins/" +echo "" +echo "Upload to marketplace:" +echo " ${BUILD_DIR}/${PLUGIN_NAME}.zip"