mirror of
https://github.com/carey314/mio-plugin-airdrop.git
synced 2026-06-11 03:54:33 +00:00
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:
commit
c4080b5cb9
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
build/
|
||||
.DS_Store
|
||||
*.swiftmodule
|
||||
*.dSYM
|
||||
.build/
|
||||
46
Info.plist
Normal file
46
Info.plist
Normal 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
21
LICENSE
Normal 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
66
README.md
Normal 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
63
README.zh-CN.md
Normal 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)。
|
||||
34
Sources/AirDropPlugin.swift
Normal file
34
Sources/AirDropPlugin.swift
Normal 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
|
||||
}
|
||||
}
|
||||
83
Sources/AirDropState.swift
Normal file
83
Sources/AirDropState.swift
Normal 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
22
Sources/MioPlugin.swift
Normal 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?
|
||||
}
|
||||
55
Sources/services/AirDropService.swift
Normal file
55
Sources/services/AirDropService.swift
Normal 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(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
46
Sources/support/HostVersionCheck.swift
Normal file
46
Sources/support/HostVersionCheck.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
87
Sources/support/Localization.swift
Normal file
87
Sources/support/Localization.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
129
Sources/ui/DropZoneView.swift
Normal file
129
Sources/ui/DropZoneView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
135
Sources/ui/ExpandedView.swift
Normal file
135
Sources/ui/ExpandedView.swift
Normal 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
56
build.sh
Executable 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"
|
||||
Loading…
Reference in New Issue
Block a user