mirror of
https://github.com/carey314/mio-plugin-airdrop.git
synced 2026-06-11 12:04:31 +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
ed84deb9ba
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