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).
This commit is contained in:
徐翔宇 2026-04-22 08:20:47 +08:00
commit c4080b5cb9
14 changed files with 848 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
build/
.DS_Store
*.swiftmodule
*.dSYM
.build/

46
Info.plist Normal file
View File

@ -0,0 +1,46 @@
<?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>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>AirDropPlugin</string>
<key>CFBundleIdentifier</key>
<string>com.mioisland.plugin.airdrop</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>AirDrop</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSPrincipalClass</key>
<string>AirDropPlugin.AirDropPlugin</string>
<!--
═════════════════════════════════════════════════════════════
PANEL SIZE (host reads these on load)
═════════════════════════════════════════════════════════════
Host clamp: width [280, 1200], height [120, 900]
Out-of-range → host falls back to 620×780 default (bad).
Layout (280pt):
top transparent 40pt (host overlays back chevron + notch)
margin top 20pt
card 180pt (14 pad + drop zone + 14 pad)
margin bottom 20pt
─────────────────
total 200pt ... wait, this adds to 260.
Actually: 40 + 20 + 180 + 20 = 260pt.
Bumping to 280 gives 20pt buffer.
═════════════════════════════════════════════════════════════
-->
<key>MioPluginPreferredWidth</key>
<integer>440</integer>
<key>MioPluginPreferredHeight</key>
<integer>280</integer>
</dict>
</plist>

21
LICENSE Normal file
View File

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

66
README.md Normal file
View File

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

63
README.zh-CN.md Normal file
View File

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

View File

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

View File

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

22
Sources/MioPlugin.swift Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

56
build.sh Executable file
View File

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