diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86f9c814..4cc0faf4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v7 - name: Install the pinned Rust toolchain @@ -132,6 +132,30 @@ jobs: path: target/package/*-setup.exe retention-days: 14 + desktop-macos: + name: macOS app + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Install the pinned Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + - uses: Swatinem/rust-cache@v2 + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: mac + target: desktop + arch: clang_64 + cache: true + aqtsource: git+https://github.com/miurahr/aqtinstall.git + - name: Clippy + run: cargo clippy --locked -p nectarlink-desktop --all-targets -- -D warnings + - name: Tests + run: cargo test --locked -p nectarlink-desktop + - name: Release build + run: cargo build --locked --release -p nectarlink-desktop + android: name: Android app runs-on: ubuntu-latest diff --git a/README.md b/README.md index 4cecd080..1101f272 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,23 @@ searchable **Timeline** of everything that moved, a **Command Palette** Four themes, **Hive**, **Bloom**, **Glass** and **Graphite**, each in light and dark. +### Platform support + +| Feature | Windows | macOS (Preview) | +|---|---|---| +| Pairing & mutual TLS | Yes | Yes | +| Files & folder transfer | Yes | Yes | +| Photos browser & download | Yes | Yes | +| Continuity Camera | Yes | Yes | +| Calls & SMS viewer | Yes | Yes | +| Phone quick toggles & volume | Yes | Yes | +| Clipboard text sync | Yes | Yes (two-way text, incoming photo) | +| System notifications | Yes | Yes (system banners) | +| Phone screen mirroring | Yes | Not yet (in development) | +| Screen recording (MP4) | Yes | Not yet | +| PC audio stream / phone mic | Yes | Not yet | +| Storage mount in Explorer | Yes | Windows Cloud Files only | + ## Private by design - **Local-first.** Devices talk directly over your network or a USB cable. @@ -228,6 +245,7 @@ docs/ Guide, technical docs, protocol, design system (CC B |---|---| | Core and CLI | [Rust](https://rustup.rs) (stable, pinned in `rust-toolchain.toml`) | | Windows app | Windows 11, Visual Studio 2022 Build Tools (C++), [Qt 6.12](https://www.qt.io/download-open-source) for MSVC 2022 x64 | +| macOS app | macOS 14+, Xcode Command Line Tools, [Qt 6.12](https://www.qt.io/download-open-source) (or Homebrew Qt 6) | | Android app | Android SDK (platform 37, NDK 30.0.16248370), JDK 17 or newer, `cargo-ndk` (see [android/README.md](android/README.md)) | ```sh diff --git a/core/nectarlink-core/src/features.rs b/core/nectarlink-core/src/features.rs index 76320f26..1e9307f0 100644 --- a/core/nectarlink-core/src/features.rs +++ b/core/nectarlink-core/src/features.rs @@ -1461,4 +1461,73 @@ mod tests { ); assert_eq!(matrix.state("device.find_phone"), Some(FeatureState::Available)); } + + #[test] + fn macos_desktop_matrix_reflects_supported_and_unsupported_features() { + const MAC_DESKTOP: &[&str] = &[ + "core.ping", + "device.battery", + "device.ring", + "files.transfer", + "clip.image", + "media.remote", + "link.open", + "task.notify", + "camera.continuity", + "pc.power", + "photos.show", + "calls.show", + "contacts.show", + "sms.show", + "toggles.show", + ]; + let mac = facts(DeviceKind::Desktop, "macos", "15.0", PowerLevel::NotApplicable, MAC_DESKTOP); + let mut phone_caps = BASIC_PHONE.to_vec(); + phone_caps.extend(["link.open", "storage.read", "storage.write"]); + let phone = facts(DeviceKind::Phone, "android", "16", PowerLevel::Elevated, &phone_caps); + let m = matrix_with(&phone, &mac, &HashMap::new()); + + // Supported macOS features are Available: + assert_eq!(m.state("device.find_phone"), Some(FeatureState::Available)); + assert_eq!(m.state("device.battery"), Some(FeatureState::Available)); + assert_eq!(m.state("device.pc_actions"), Some(FeatureState::Available)); + assert_eq!(m.state("device.links_to_pc"), Some(FeatureState::Available)); + assert_eq!(m.state("device.links_to_phone"), Some(FeatureState::Available)); + assert_eq!(m.state("camera.continuity"), Some(FeatureState::Available)); + assert_eq!(m.state("files.send"), Some(FeatureState::Available)); + assert_eq!(m.state("files.recent_photos"), Some(FeatureState::Available)); + assert_eq!(m.state("calls.alerts"), Some(FeatureState::Available)); + assert_eq!(m.state("contacts.read"), Some(FeatureState::Available)); + assert_eq!(m.state("toggles.volume"), Some(FeatureState::Available)); + assert_eq!(m.state("clipboard.pc_to_phone"), Some(FeatureState::Available)); + + // Unsupported macOS features are NOT Available: + assert_eq!( + m.state("mirroring.view"), + Some(locked(UpgradeAction::UpdateApp(Role::Desktop), Effort::Minutes(2))) + ); + assert_ne!(m.state("mirroring.control"), Some(FeatureState::Available)); + assert_ne!(m.state("mirroring.app_windows"), Some(FeatureState::Available)); + assert_ne!(m.state("mirroring.audio"), Some(FeatureState::Available)); + assert_eq!( + m.state("media.pc_control"), + Some(locked(UpgradeAction::UpdateApp(Role::Desktop), Effort::Minutes(2))) + ); + // With default toggles, storage is opt-in (toggle off): + assert_eq!( + m.state("files.storage"), + Some(locked(UpgradeAction::EnableDeviceToggle("storage"), Effort::Instant)) + ); + // With storage toggle enabled, missing desktop("storage.mount") locks behind UpdateApp: + let toggles_with_storage = HashMap::from([("storage".to_owned(), true)]); + let m_storage = matrix_with(&phone, &mac, &toggles_with_storage); + assert_eq!( + m_storage.state("files.storage"), + Some(locked(UpgradeAction::UpdateApp(Role::Desktop), Effort::Minutes(2))) + ); + assert_eq!( + m.state("camera.webcam"), + Some(locked(UpgradeAction::EnableAddon("vcam"), Effort::Minutes(1))) + ); + } } diff --git a/core/nectarlink-core/src/node.rs b/core/nectarlink-core/src/node.rs index 3d00ad5d..70fbd391 100644 --- a/core/nectarlink-core/src/node.rs +++ b/core/nectarlink-core/src/node.rs @@ -75,7 +75,6 @@ const BASE_CAPABILITIES: &[&str] = &[ "media.remote", "link.open", "task.notify", - "mirror.view", ]; /// This device's mutable description, sent to peers. diff --git a/desktop/app/build.rs b/desktop/app/build.rs index 91478e66..c1144107 100644 --- a/desktop/app/build.rs +++ b/desktop/app/build.rs @@ -282,9 +282,17 @@ fn main() { ]) .build(); - // The C++ window code calls DWM, Shell property store, and user32 directly. - println!("cargo:rustc-link-lib=dwmapi"); - println!("cargo:rustc-link-lib=ole32"); - println!("cargo:rustc-link-lib=shell32"); - println!("cargo:rustc-link-lib=user32"); + // The C++ window code calls DWM, Shell property store, and user32 directly on Windows. + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + println!("cargo:rustc-link-lib=dwmapi"); + println!("cargo:rustc-link-lib=ole32"); + println!("cargo:rustc-link-lib=shell32"); + println!("cargo:rustc-link-lib=user32"); + } else if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + println!("cargo:rustc-link-lib=framework=AppKit"); + println!("cargo:rustc-link-lib=framework=CoreGraphics"); + println!("cargo:rustc-link-lib=framework=CoreAudio"); + println!("cargo:rustc-link-lib=framework=AudioToolbox"); + println!("cargo:rustc-link-lib=framework=IOKit"); + } } diff --git a/desktop/app/cpp/app_helpers.cpp b/desktop/app/cpp/app_helpers.cpp index 412d50d9..619415c1 100644 --- a/desktop/app/cpp/app_helpers.cpp +++ b/desktop/app/cpp/app_helpers.cpp @@ -3,13 +3,16 @@ #include "video_view.h" #include +#include #include #include #include #include #include #include +#include #include +#include #include #include #include @@ -318,4 +321,220 @@ rust::String translate(rust::Str context, rust::Str text) return rust::String(QCoreApplication::translate(c.c_str(), t.c_str()).toStdString()); } +rust::Vec decode_image(rust::Slice bytes) +{ + rust::Vec result; + if (bytes.empty()) + return result; + QImage img; + if (!img.loadFromData(bytes.data(), static_cast(bytes.size()))) + return result; + const QImage argb = img.convertToFormat(QImage::Format_ARGB32); + const uint32_t width = static_cast(argb.width()); + const uint32_t height = static_cast(argb.height()); + const size_t byteCount = static_cast(argb.sizeInBytes()); + result.reserve(8 + byteCount); + for (int i = 0; i < 4; ++i) + result.push_back(static_cast((width >> (i * 8)) & 0xFF)); + for (int i = 0; i < 4; ++i) + result.push_back(static_cast((height >> (i * 8)) & 0xFF)); + const uint8_t *bits = argb.constBits(); + for (size_t i = 0; i < byteCount; ++i) + result.push_back(bits[i]); + return result; +} + +#ifdef __APPLE__ +#include +#include + +extern "C" void *objc_autoreleasePoolPush(void); +extern "C" void objc_autoreleasePoolPop(void *pool); + +namespace { +struct AutoReleasePool { + void *pool; + AutoReleasePool() : pool(objc_autoreleasePoolPush()) {} + ~AutoReleasePool() { objc_autoreleasePoolPop(pool); } +}; +} // namespace + +int64_t mac_clipboard_change_count() +{ + AutoReleasePool pool; + id pboardClass = reinterpret_cast(objc_getClass("NSPasteboard")); + if (!pboardClass) + return 0; + id pboard = reinterpret_cast(objc_msgSend)(pboardClass, sel_registerName("generalPasteboard")); + if (!pboard) + return 0; + return reinterpret_cast(objc_msgSend)(pboard, sel_registerName("changeCount")); +} + +rust::String mac_clipboard_read_text(bool &is_private) +{ + AutoReleasePool pool; + is_private = false; + id pboardClass = reinterpret_cast(objc_getClass("NSPasteboard")); + if (!pboardClass) + return rust::String(); + id pboard = reinterpret_cast(objc_msgSend)(pboardClass, sel_registerName("generalPasteboard")); + if (!pboard) + return rust::String(); + + id strClass = reinterpret_cast(objc_getClass("NSString")); + if (!strClass) + return rust::String(); + + id concealed = reinterpret_cast(objc_msgSend)( + strClass, sel_registerName("stringWithUTF8String:"), "org.nspasteboard.ConcealedType"); + id transient = reinterpret_cast(objc_msgSend)( + strClass, sel_registerName("stringWithUTF8String:"), "org.nspasteboard.TransientType"); + id autoGen = reinterpret_cast(objc_msgSend)( + strClass, sel_registerName("stringWithUTF8String:"), "org.nspasteboard.AutoGeneratedType"); + id plainTextType = reinterpret_cast(objc_msgSend)( + strClass, sel_registerName("stringWithUTF8String:"), "public.utf8-plain-text"); + + for (int attempt = 0; attempt < 2; ++attempt) { + int64_t countBefore = reinterpret_cast(objc_msgSend)(pboard, sel_registerName("changeCount")); + + id types = reinterpret_cast(objc_msgSend)(pboard, sel_registerName("types")); + if (types) { + if ((concealed && reinterpret_cast(objc_msgSend)(types, sel_registerName("containsObject:"), concealed)) + || (transient && reinterpret_cast(objc_msgSend)(types, sel_registerName("containsObject:"), transient)) + || (autoGen && reinterpret_cast(objc_msgSend)(types, sel_registerName("containsObject:"), autoGen))) { + int64_t countAfter = reinterpret_cast(objc_msgSend)(pboard, sel_registerName("changeCount")); + if (countBefore == countAfter) { + is_private = true; + return rust::String(); + } + continue; + } + } + + id str = reinterpret_cast(objc_msgSend)(pboard, sel_registerName("stringForType:"), plainTextType); + int64_t countAfter = reinterpret_cast(objc_msgSend)(pboard, sel_registerName("changeCount")); + if (countBefore != countAfter) { + continue; + } + + if (str) { + const char *utf8 = reinterpret_cast(objc_msgSend)(str, sel_registerName("UTF8String")); + if (utf8) + return rust::String(utf8); + } + return rust::String(); + } + + is_private = false; + return rust::String(); +} + +bool mac_clipboard_write_text(rust::Str text, bool is_private) +{ + AutoReleasePool pool; + id pboardClass = reinterpret_cast(objc_getClass("NSPasteboard")); + if (!pboardClass) + return false; + id pboard = reinterpret_cast(objc_msgSend)(pboardClass, sel_registerName("generalPasteboard")); + if (!pboard) + return false; + + reinterpret_cast(objc_msgSend)(pboard, sel_registerName("clearContents")); + id strClass = reinterpret_cast(objc_getClass("NSString")); + if (!strClass) + return false; + + const std::string text_str(text.data(), text.size()); + id nsStr = reinterpret_cast(objc_msgSend)( + strClass, sel_registerName("stringWithUTF8String:"), text_str.c_str()); + if (!nsStr) + return false; + + id plainTextType = reinterpret_cast(objc_msgSend)( + strClass, sel_registerName("stringWithUTF8String:"), "public.utf8-plain-text"); + + id arrClass = reinterpret_cast(objc_getClass("NSArray")); + if (!arrClass) + return false; + + if (is_private) { + id concealed = reinterpret_cast(objc_msgSend)( + strClass, sel_registerName("stringWithUTF8String:"), "org.nspasteboard.ConcealedType"); + id transient = reinterpret_cast(objc_msgSend)( + strClass, sel_registerName("stringWithUTF8String:"), "org.nspasteboard.TransientType"); + const id items[] = { plainTextType, concealed, transient }; + id types = reinterpret_cast(objc_msgSend)( + arrClass, sel_registerName("arrayWithObjects:count:"), items, 3); + reinterpret_cast(objc_msgSend)( + pboard, sel_registerName("declareTypes:owner:"), types, nullptr); + bool ok = reinterpret_cast(objc_msgSend)( + pboard, sel_registerName("setString:forType:"), nsStr, plainTextType); + if (!ok) + return false; + id dataClass = reinterpret_cast(objc_getClass("NSData")); + if (dataClass) { + id emptyData = reinterpret_cast(objc_msgSend)(dataClass, sel_registerName("data")); + reinterpret_cast(objc_msgSend)( + pboard, sel_registerName("setData:forType:"), emptyData, concealed); + reinterpret_cast(objc_msgSend)( + pboard, sel_registerName("setData:forType:"), emptyData, transient); + } + return true; + } else { + const id items[] = { plainTextType }; + id types = reinterpret_cast(objc_msgSend)( + arrClass, sel_registerName("arrayWithObjects:count:"), items, 1); + reinterpret_cast(objc_msgSend)( + pboard, sel_registerName("declareTypes:owner:"), types, nullptr); + return reinterpret_cast(objc_msgSend)( + pboard, sel_registerName("setString:forType:"), nsStr, plainTextType); + } +} +#else +int64_t mac_clipboard_change_count() +{ + return 0; +} + +rust::String mac_clipboard_read_text(bool &is_private) +{ + is_private = false; + return rust::String(); +} + +bool mac_clipboard_write_text(rust::Str /*text*/, bool /*is_private*/) +{ + return false; +} +#endif + +int64_t mac_local_offset_ms(int64_t at_ms) +{ + const QDateTime dt = QDateTime::fromMSecsSinceEpoch(at_ms, QTimeZone::systemTimeZone()); + return static_cast(dt.offsetFromUtc()) * 1000; +} + +uint64_t mac_free_space(rust::Str path, bool &ok) +{ + ok = false; + const std::string pathStr(path.data(), path.size()); + QString qpath = QString::fromStdString(pathStr); + QFileInfo info(qpath); + while (!info.exists() && !qpath.isEmpty()) { + const QString parent = info.dir().absolutePath(); + if (parent == qpath) + break; + qpath = parent; + info.setFile(qpath); + } + const QStorageInfo storage(qpath); + if (!storage.isValid() || !storage.isReady()) + return 0; + const qint64 available = storage.bytesAvailable(); + if (available < 0) + return 0; + ok = true; + return static_cast(available); +} diff --git a/desktop/app/cpp/app_helpers.h b/desktop/app/cpp/app_helpers.h index 1b6a9531..27da9f67 100644 --- a/desktop/app/cpp/app_helpers.h +++ b/desktop/app/cpp/app_helpers.h @@ -52,5 +52,13 @@ bool install_translator(rust::Str dir, rust::Str language); // builds in Rust; `text` itself when there is no translation. rust::String translate(rust::Str context, rust::Str text); +// Decodes image bytes (PNG, JPEG, WebP, BMP, etc.) into width, height and ARGB32 bytes. +rust::Vec decode_image(rust::Slice bytes); +// Native macOS clipboard support: changeCount, privacy detection, and direct reading/writing. +int64_t mac_clipboard_change_count(); +rust::String mac_clipboard_read_text(bool &is_private); +bool mac_clipboard_write_text(rust::Str text, bool is_private); +int64_t mac_local_offset_ms(int64_t at_ms); +uint64_t mac_free_space(rust::Str path, bool &ok); diff --git a/desktop/app/cpp/native_window.cpp b/desktop/app/cpp/native_window.cpp index cd675489..1731550b 100644 --- a/desktop/app/cpp/native_window.cpp +++ b/desktop/app/cpp/native_window.cpp @@ -8,6 +8,7 @@ #include +#ifdef _WIN32 #define WIN32_LEAN_AND_MEAN #include #include @@ -50,12 +51,19 @@ void trimD3DResources(QQuickWindow *window) } } // namespace +#else +namespace { +void trimD3DResources(QQuickWindow * /*window*/) {} +} // namespace +#endif NativeWindow::NativeWindow(QWindow *parent) : QQuickWindow(parent) { - // Qt removes the system title bar and lets content fill the window. +#ifdef _WIN32 + // Qt removes the system title bar and lets content fill the window on Windows. setFlags(flags() | Qt::ExpandedClientAreaHint); +#endif setColor(Qt::transparent); // Drop the scene graph and D3D/RHI resources when the window is hidden or // closed to the tray instead of keeping GPU buffers in private commit. @@ -142,6 +150,7 @@ void NativeWindow::bringToFront() showNormal(); raise(); requestActivate(); +#ifdef _WIN32 if (!handle()) return; const HWND hwnd = hwndOf(this); @@ -160,14 +169,19 @@ void NativeWindow::bringToFront() SetForegroundWindow(hwnd); if (attached) AttachThreadInput(thisThread, foregroundThread, FALSE); +#endif } void NativeWindow::flash() { +#ifdef _WIN32 if (!handle()) return; FLASHWINFO info { sizeof(FLASHWINFO), hwndOf(this), FLASHW_TRAY | FLASHW_TIMERNOFG, 0, 0 }; FlashWindowEx(&info); +#else + alert(0); +#endif } void NativeWindow::releaseAndTeardown() @@ -208,6 +222,7 @@ bool NativeWindow::event(QEvent *event) void NativeWindow::applyFrame() { +#ifdef _WIN32 if (!handle()) return; const HWND hwnd = hwndOf(this); @@ -222,6 +237,7 @@ void NativeWindow::applyFrame() DwmSetWindowAttribute(hwnd, kSystemBackdropType, &backdrop, sizeof(backdrop)); const COLORREF none = kColorNone; DwmSetWindowAttribute(hwnd, kCaptionColor, &none, sizeof(none)); +#endif } bool NativeWindow::itemContains(const QQuickItem *item, const QPointF &scenePoint) const @@ -233,6 +249,7 @@ bool NativeWindow::itemContains(const QQuickItem *item, const QPointF &scenePoin long NativeWindow::hitTest(int screenX, int screenY) const { +#ifdef _WIN32 const HWND hwnd = hwndOf(this); POINT pt { screenX, screenY }; ScreenToClient(hwnd, &pt); @@ -264,10 +281,16 @@ long NativeWindow::hitTest(int screenX, int screenY) const return HTCLIENT; } return HTCAPTION; +#else + Q_UNUSED(screenX); + Q_UNUSED(screenY); + return 0; +#endif } bool NativeWindow::nativeEvent(const QByteArray &eventType, void *message, qintptr *result) { +#ifdef _WIN32 if (eventType != "windows_generic_MSG") return QQuickWindow::nativeEvent(eventType, message, result); const MSG *msg = static_cast(message); @@ -325,5 +348,10 @@ bool NativeWindow::nativeEvent(const QByteArray &eventType, void *message, qintp default: break; } +#else + Q_UNUSED(eventType); + Q_UNUSED(message); + Q_UNUSED(result); +#endif return QQuickWindow::nativeEvent(eventType, message, result); } diff --git a/desktop/app/cpp/native_window.h b/desktop/app/cpp/native_window.h index ad06b6bb..fc253b32 100644 --- a/desktop/app/cpp/native_window.h +++ b/desktop/app/cpp/native_window.h @@ -18,6 +18,7 @@ class NativeWindow : public QQuickWindow Q_OBJECT QML_ELEMENT + // Show Mica behind transparent areas. Q_PROPERTY(bool backdrop READ backdrop WRITE setBackdrop NOTIFY backdropChanged) // Use Windows 11 Desktop Acrylic (DWMSBT_TRANSIENTWINDOW) when backdrop is active (for Liquid Glass theme). diff --git a/desktop/app/qml/App.qml b/desktop/app/qml/App.qml index 006427a8..59da7434 100644 --- a/desktop/app/qml/App.qml +++ b/desktop/app/qml/App.qml @@ -131,7 +131,7 @@ QtObject { function onMainClosed() { if (mainLoader.active && mainLoader.item) mainLoader.item.saveWindowState() - if (!Preferences.closeToTray) { + if (Qt.platform.os === "osx" || !Preferences.closeToTray) { Qt.quit() return } diff --git a/desktop/app/qml/components/CaptionButtons.qml b/desktop/app/qml/components/CaptionButtons.qml index 9df80ed8..25f677b9 100644 --- a/desktop/app/qml/components/CaptionButtons.qml +++ b/desktop/app/qml/components/CaptionButtons.qml @@ -12,6 +12,9 @@ Row { required property NativeWindow window readonly property bool maximized: window.visibility === Window.Maximized + visible: Qt.platform.os === "windows" + width: visible ? implicitWidth : 0 + // As tall as the top bar, like Windows 11's tall title bars, so the glyphs // line up with the page title and the logo. height: Theme.topBarHeight diff --git a/desktop/app/qml/pages/SettingsPage.qml b/desktop/app/qml/pages/SettingsPage.qml index 8a981715..95289242 100644 --- a/desktop/app/qml/pages/SettingsPage.qml +++ b/desktop/app/qml/pages/SettingsPage.qml @@ -966,13 +966,18 @@ Item { ? Math.round((parent.height - height) / 2) : (startWinRow.implicitHeight + 1) width: startupPairRow.colWidth + opacity: Qt.platform.os === "osx" ? 0.5 : 1.0 iconPath: Icons.window title: qsTr("Stay in system tray") description: qsTr("Keep running when the window is closed.") Toggle { label: qsTr("Stay in system tray") checked: Preferences.closeToTray - onToggled: (on) => Preferences.closeToTray = on + enabled: Qt.platform.os !== "osx" + onToggled: (on) => { + if (Qt.platform.os !== "osx") + Preferences.closeToTray = on + } } } } diff --git a/desktop/app/src/automations.rs b/desktop/app/src/automations.rs index 188f6e72..78857e1c 100644 --- a/desktop/app/src/automations.rs +++ b/desktop/app/src/automations.rs @@ -16,8 +16,10 @@ use crate::{deck, settings, win}; /// Supported automation trigger identifiers. pub const TRIGGER_PHONE_CONNECTED: &str = "phone_connected"; pub const TRIGGER_PHONE_DISCONNECTED: &str = "phone_disconnected"; +#[allow(dead_code)] pub const TRIGGER_PHONE_WALKED_AWAY: &str = "phone_walked_away"; pub const TRIGGER_CALL_RINGING: &str = "call_ringing"; + pub const TRIGGER_CALL_ENDED: &str = "call_ended"; pub const TRIGGER_BATTERY_LOW: &str = "battery_low"; pub const TRIGGER_CHARGING_STARTED: &str = "charging_started"; diff --git a/desktop/app/src/bridge/media.rs b/desktop/app/src/bridge/media.rs index bae92dba..61b2677d 100644 --- a/desktop/app/src/bridge/media.rs +++ b/desktop/app/src/bridge/media.rs @@ -410,9 +410,11 @@ mod tests { assert_eq!(get("positionAt").value::(), Some(1_500.0)); assert_eq!(get("canPause").value::(), Some(true)); assert_eq!(get("canPlay").value::(), Some(false)); - assert_eq!( - get("art").value::().map(|s| String::from(&s)), - Some("file:///C:/data/cache/art/a.jpg".into()) - ); + #[cfg(windows)] + let expected_art = "file:///C:/data/cache/art/a.jpg".to_string(); + #[cfg(not(windows))] + let expected_art = + String::from(&QUrl::from_local_file(&QString::from(r"C:\data\cache\art\a.jpg")).to_qstring()); + assert_eq!(get("art").value::().map(|s| String::from(&s)), Some(expected_art)); } } diff --git a/desktop/app/src/bridge/native.rs b/desktop/app/src/bridge/native.rs index 282df8cb..7903c20f 100644 --- a/desktop/app/src/bridge/native.rs +++ b/desktop/app/src/bridge/native.rs @@ -50,5 +50,24 @@ pub mod ffi { /// `text` in the installed language (`QCoreApplication::translate`). fn translate(context: &str, text: &str) -> String; + + /// Decodes image bytes into a byte vector: 4-byte width (LE), 4-byte height (LE), followed by ARGB32 bytes. + #[allow(dead_code)] + fn decode_image(bytes: &[u8]) -> Vec; + + #[allow(dead_code)] + fn mac_clipboard_change_count() -> i64; + + #[allow(dead_code)] + fn mac_clipboard_read_text(is_private: &mut bool) -> String; + + #[allow(dead_code)] + fn mac_clipboard_write_text(text: &str, is_private: bool) -> bool; + + #[allow(dead_code)] + fn mac_local_offset_ms(at_ms: i64) -> i64; + + #[allow(dead_code)] + fn mac_free_space(path: &str, ok: &mut bool) -> u64; } } diff --git a/desktop/app/src/bridge/prefs.rs b/desktop/app/src/bridge/prefs.rs index c0f4b50c..44fdf773 100644 --- a/desktop/app/src/bridge/prefs.rs +++ b/desktop/app/src/bridge/prefs.rs @@ -364,7 +364,8 @@ impl cxx_qt::Initialize for qobject::Preferences { self.as_mut().set_amoled(settings.amoled); self.as_mut().set_backdrop(settings.backdrop); self.as_mut().set_comb_motion(settings.comb_motion); - self.as_mut().set_close_to_tray(settings.close_to_tray); + let close_to_tray = if cfg!(target_os = "macos") { false } else { settings.close_to_tray }; + self.as_mut().set_close_to_tray(close_to_tray); self.as_mut().set_auto_clipboard(settings.auto_clipboard); crate::clipboard::set_auto_send(settings.auto_clipboard); self.as_mut().set_clipboard_history(settings.clipboard_history); @@ -447,7 +448,18 @@ impl cxx_qt::Initialize for qobject::Preferences { self.as_mut().on_amoled_changed(|p| p.save()).release(); self.as_mut().on_backdrop_changed(|p| p.save()).release(); self.as_mut().on_comb_motion_changed(|p| p.save()).release(); - self.as_mut().on_close_to_tray_changed(|p| p.save()).release(); + self.as_mut() + .on_close_to_tray_changed(|p| { + #[cfg(target_os = "macos")] + let mut p = p; + #[cfg(target_os = "macos")] + if p.close_to_tray { + p.as_mut().set_close_to_tray(false); + return; + } + p.save(); + }) + .release(); self.as_mut() .on_auto_clipboard_changed(|p| { crate::clipboard::set_auto_send(p.auto_clipboard); @@ -976,7 +988,7 @@ impl qobject::Preferences { amoled: p.amoled, backdrop: p.backdrop, comb_motion: p.comb_motion, - close_to_tray: p.close_to_tray, + close_to_tray: if cfg!(target_os = "macos") { false } else { p.close_to_tray }, auto_clipboard: p.auto_clipboard, clipboard_history: p.clipboard_history, suggest_clipboard_actions: p.suggest_clipboard_actions, diff --git a/desktop/app/src/core_host.rs b/desktop/app/src/core_host.rs index 2a508970..7984c837 100644 --- a/desktop/app/src/core_host.rs +++ b/desktop/app/src/core_host.rs @@ -40,6 +40,7 @@ pub fn host() -> &'static CoreHost { } /// The running host's data directory, if the host has been started. +#[allow(dead_code)] pub fn try_data_dir() -> Option<&'static std::path::Path> { HOST.get().map(|h| h.data_dir.as_path()) } @@ -99,31 +100,39 @@ pub fn start(data_dir: PathBuf, platform: Arc) -> std::io::Result< /// Computes the PC's advertised capabilities, advertising capture (`mirror.capture`) /// and input injection (`mirror.input`, `input.inject`) only when enabled for at /// least one paired device (or actively sharing). -pub fn compute_capabilities(node: Option<&Node>) -> Vec { +pub fn compute_capabilities( + #[cfg_attr(not(windows), allow(unused_variables))] node: Option<&Node>, +) -> Vec { let mut caps = vec![ - "media.control".into(), "pc.power".into(), - nectarlink_core::PC_WAKE.into(), nectarlink_core::PHOTOS_SHOW.into(), nectarlink_core::CAMERA_CONTINUITY.into(), nectarlink_core::CALLS_SHOW.into(), nectarlink_core::CONTACTS_SHOW.into(), nectarlink_core::SMS_SHOW.into(), - nectarlink_core::MIRROR_VIEW.into(), - nectarlink_core::MIRROR_LISTEN.into(), - nectarlink_core::DECK_ACTIONS.into(), - nectarlink_core::PC_AUDIO.into(), - nectarlink_core::RECORDER.into(), nectarlink_core::TOGGLES_SHOW.into(), - nectarlink_core::STORAGE_MOUNT.into(), - nectarlink_core::STORAGE_READ.into(), - nectarlink_core::STORAGE_WRITE.into(), - nectarlink_core::WEBCAM_VIRTUAL.into(), - nectarlink_core::MEDIA_STREAM.into(), - nectarlink_core::MIC_LISTEN.into(), ]; + #[cfg(windows)] + { + caps.push(nectarlink_core::PC_WAKE.into()); + caps.push("media.control".into()); + caps.push(nectarlink_core::STORAGE_MOUNT.into()); + caps.push(nectarlink_core::STORAGE_READ.into()); + caps.push(nectarlink_core::STORAGE_WRITE.into()); + caps.push(nectarlink_core::MIRROR_VIEW.into()); + caps.push(nectarlink_core::MIRROR_LISTEN.into()); + caps.push(nectarlink_core::DECK_ACTIONS.into()); + caps.push(nectarlink_core::PC_AUDIO.into()); + caps.push(nectarlink_core::RECORDER.into()); + caps.push(nectarlink_core::WEBCAM_VIRTUAL.into()); + caps.push(nectarlink_core::MEDIA_STREAM.into()); + caps.push(nectarlink_core::MIC_LISTEN.into()); + } + #[cfg(windows)] let mut pc_mirror_enabled = crate::pc_mirror::is_active(); + #[cfg(windows)] let mut remote_input_enabled = false; + #[cfg(windows)] if let Some(node) = node && let Ok(devices) = node.paired_devices() { @@ -140,9 +149,11 @@ pub fn compute_capabilities(node: Option<&Node>) -> Vec { } } } + #[cfg(windows)] if pc_mirror_enabled { caps.push(nectarlink_core::MIRROR_CAPTURE.into()); } + #[cfg(windows)] if remote_input_enabled { caps.push(nectarlink_core::MIRROR_INPUT.into()); caps.push(nectarlink_core::INPUT_INJECT.into()); @@ -361,7 +372,7 @@ fn this_device() -> DeviceInfo { DeviceInfo { name, kind: if crate::win::is_laptop() { DeviceKind::Laptop } else { DeviceKind::Desktop }, - os: "windows".into(), + os: std::env::consts::OS.into(), os_ver: crate::win::os_version(), model: None, accent: None, @@ -392,3 +403,54 @@ pub fn shutdown() { } } } + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::*; + + #[test] + fn macos_advertises_only_supported_capabilities() { + let caps = compute_capabilities(None); + // Ensure unsupported features on macOS are never advertised + assert!(!caps.contains(&"mirror.view".into()), "macOS must not advertise mirror.view"); + assert!(!caps.contains(&"mirror.listen".into()), "macOS must not advertise mirror.listen"); + assert!(!caps.contains(&"media.control".into()), "macOS must not advertise media.control"); + assert!( + !caps.contains(&nectarlink_core::STORAGE_MOUNT.into()), + "macOS must not advertise storage.mount" + ); + assert!( + !caps.contains(&nectarlink_core::STORAGE_READ.into()), + "macOS must not advertise storage.read" + ); + assert!( + !caps.contains(&nectarlink_core::STORAGE_WRITE.into()), + "macOS must not advertise storage.write" + ); + assert!( + !caps.contains(&nectarlink_core::WEBCAM_VIRTUAL.into()), + "macOS must not advertise camera.virtual" + ); + assert!(!caps.contains(&nectarlink_core::PC_AUDIO.into()), "macOS must not advertise pc.audio"); + assert!(!caps.contains(&nectarlink_core::RECORDER.into()), "macOS must not advertise recorder"); + assert!( + !caps.contains(&nectarlink_core::DECK_ACTIONS.into()), + "macOS must not advertise deck.actions" + ); + assert!( + !caps.contains(&nectarlink_core::MEDIA_STREAM.into()), + "macOS must not advertise media.stream" + ); + assert!(!caps.contains(&nectarlink_core::MIC_LISTEN.into()), "macOS must not advertise mic.listen"); + assert!(!caps.contains(&nectarlink_core::PC_WAKE.into()), "macOS must not advertise pc.wake"); + + // Ensure supported features on macOS are advertised + assert!(caps.contains(&"pc.power".into())); + assert!(caps.contains(&nectarlink_core::PHOTOS_SHOW.into())); + assert!(caps.contains(&nectarlink_core::CAMERA_CONTINUITY.into())); + assert!(caps.contains(&nectarlink_core::CALLS_SHOW.into())); + assert!(caps.contains(&nectarlink_core::CONTACTS_SHOW.into())); + assert!(caps.contains(&nectarlink_core::SMS_SHOW.into())); + assert!(caps.contains(&nectarlink_core::TOGGLES_SHOW.into())); + } +} diff --git a/desktop/app/src/mac/audio.rs b/desktop/app/src/mac/audio.rs new file mode 100644 index 00000000..f608b314 --- /dev/null +++ b/desktop/app/src/mac/audio.rs @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS speaker volume, mute, and state. + +fn parse_speaker_state(text: &str) -> Option<(u8, bool)> { + let mut parts = text.split_whitespace(); + let vol: u8 = parts.next()?.parse().ok()?; + let muted: bool = parts.next()?.parse().unwrap_or(false); + Some((vol, muted)) +} + +pub fn speaker_state() -> Option<(u8, bool)> { + let output = std::process::Command::new("osascript") + .args([ + "-e", + "set v to get volume settings", + "-e", + "(output volume of v as string) & \" \" & (output muted of v as string)", + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_speaker_state(&String::from_utf8_lossy(&output.stdout)) +} + +fn run_osascript(script: &str) -> Result<(), String> { + let output = + std::process::Command::new("osascript").args(["-e", script]).output().map_err(|e| e.to_string())?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let msg = stderr.trim(); + return Err(if msg.is_empty() { + format!("osascript failed with exit code {:?}", output.status.code()) + } else { + msg.to_string() + }); + } + Ok(()) +} + +pub fn set_speaker_volume(level: u8) -> Result<(), String> { + run_osascript(&format!("set volume output volume {level}")) +} + +pub fn set_speaker_mute(muted: bool) -> Result<(), String> { + run_osascript(&format!("set volume output muted {muted}")) +} + +pub fn toggle_speaker_mute() -> Result { + let (_, muted) = speaker_state().unwrap_or((50, false)); + let next = !muted; + set_speaker_mute(next)?; + Ok(next) +} + +pub fn speaker_volume_up() -> Result<(u8, bool), String> { + let (vol, muted) = speaker_state().unwrap_or((50, false)); + let next = vol.saturating_add(5).min(100); + set_speaker_volume(next)?; + Ok((next, muted)) +} + +pub fn speaker_volume_down() -> Result<(u8, bool), String> { + let (vol, muted) = speaker_state().unwrap_or((50, false)); + let next = vol.saturating_sub(5); + set_speaker_volume(next)?; + Ok((next, muted)) +} + +static LAST_MIC_VOL: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(75); + +pub fn mic_volume() -> Option { + let output = std::process::Command::new("osascript") + .args(["-e", "input volume of (get volume settings)"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + text.trim().parse().ok() +} + +pub fn mic_muted() -> Option { + mic_volume().map(|vol| vol == 0) +} + +pub fn toggle_mic_mute() -> Result { + let current_vol = mic_volume().ok_or_else(|| "Couldn't read microphone volume".to_string())?; + let muted = current_vol == 0; + let next = !muted; + let vol = if next { + if current_vol > 0 { + LAST_MIC_VOL.store(current_vol, std::sync::atomic::Ordering::Relaxed); + } + 0 + } else { + let saved = LAST_MIC_VOL.load(std::sync::atomic::Ordering::Relaxed); + if saved > 0 { saved } else { 75 } + }; + let script = format!("set volume input volume {vol}"); + run_osascript(&script)?; + Ok(next) +} + +pub fn output_devices() -> Vec { + vec![nectarlink_core::AudioOutputDevice { + id: "default".into(), + name: "Default macOS Output".into(), + is_default: true, + }] +} + +pub fn watch(_changed: fn()) {} + +#[allow(dead_code)] +pub fn start(_on_changed: impl Fn() + Send + Sync + 'static) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speaker_state_parses_volume_and_mute() { + assert_eq!(parse_speaker_state("25 false"), Some((25, false))); + assert_eq!(parse_speaker_state("100 true\n"), Some((100, true))); + assert_eq!(parse_speaker_state("0 false"), Some((0, false))); + assert_eq!(parse_speaker_state(""), None); + assert_eq!(parse_speaker_state("invalid"), None); + } + + #[test] + fn query_live_speaker_state() { + if let Some((vol, _muted)) = speaker_state() { + assert!(vol <= 100); + } + } +} diff --git a/desktop/app/src/mac/audio_out.rs b/desktop/app/src/mac/audio_out.rs new file mode 100644 index 00000000..8ee6e2a3 --- /dev/null +++ b/desktop/app/src/mac/audio_out.rs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS audio output streaming player. + +#[derive(Debug)] +pub struct Player; + +impl Player { + pub fn start(rate: u32, channels: u16) -> std::io::Result { + Self::start_on(None, rate, channels) + } + + pub fn start_on(_device: Option, _rate: u32, _channels: u16) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "streamed audio playback is not yet implemented on macOS", + )) + } + + pub fn push(&self, _data: Vec) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn audio_out_reports_unsupported() { + assert_eq!(Player::start(48000, 2).unwrap_err().kind(), std::io::ErrorKind::Unsupported); + assert_eq!(Player::start_on(None, 48000, 2).unwrap_err().kind(), std::io::ErrorKind::Unsupported); + } +} diff --git a/desktop/app/src/mac/autostart.rs b/desktop/app/src/mac/autostart.rs new file mode 100644 index 00000000..ff09f837 --- /dev/null +++ b/desktop/app/src/mac/autostart.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS login item / autostart via LaunchAgents. + +use std::path::Path; + +pub fn is_enabled() -> bool { + if let Some(dir) = dirs::home_dir() { + let plist = dir.join("Library/LaunchAgents/com.thtbee.nectarlink.plist"); + plist.exists() + } else { + false + } +} + +pub fn is_installed(_exe: &Path) -> bool { + is_enabled() +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +pub fn set_enabled(enabled: bool) -> Result<(), String> { + if let Some(dir) = dirs::home_dir() { + let agents = dir.join("Library/LaunchAgents"); + let plist = agents.join("com.thtbee.nectarlink.plist"); + if enabled { + let _ = std::fs::create_dir_all(&agents); + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let escaped_exe = xml_escape(&exe.to_string_lossy()); + let content = format!( + r#" + + + + Label + com.thtbee.nectarlink + ProgramArguments + + {escaped_exe} + + RunAtLoad + + +"# + ); + std::fs::write(&plist, content).map_err(|e| e.to_string())?; + } else if plist.exists() { + let _ = std::fs::remove_file(&plist); + } + } + Ok(()) +} + +pub fn set(enabled: bool, _exe: &Path) -> Result<(), String> { + set_enabled(enabled) +} diff --git a/desktop/app/src/mac/ble_beacon.rs b/desktop/app/src/mac/ble_beacon.rs new file mode 100644 index 00000000..367694b4 --- /dev/null +++ b/desktop/app/src/mac/ble_beacon.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS BLE beacon stubs. + +use nectarlink_core::DeviceId; + +pub fn start(_device_id: DeviceId) {} +pub fn stop() {} diff --git a/desktop/app/src/mac/browser.rs b/desktop/app/src/mac/browser.rs new file mode 100644 index 00000000..e43e569e --- /dev/null +++ b/desktop/app/src/mac/browser.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS browser and window inspection stubs. + +pub fn programs_for(player: &str) -> Vec { + vec![player.to_lowercase()] +} + +pub fn is_browser(program: &str) -> bool { + let lower = program.to_lowercase(); + lower.contains("safari") + || lower.contains("chrome") + || lower.contains("firefox") + || lower.contains("edge") + || lower.contains("brave") +} + +#[derive(Debug, Clone)] +pub struct Window { + #[allow(dead_code)] + pub hwnd: isize, + pub program: String, + pub title: String, +} + +pub fn windows() -> Vec { + Vec::new() +} diff --git a/desktop/app/src/mac/bt_hfp.rs b/desktop/app/src/mac/bt_hfp.rs new file mode 100644 index 00000000..be6149d5 --- /dev/null +++ b/desktop/app/src/mac/bt_hfp.rs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS Bluetooth HFP integration. + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HfpStatus { + pub available: bool, + pub connected: bool, + pub device_name: String, + pub detail: String, +} + +pub fn probe_status(phone_name: Option<&str>) -> HfpStatus { + HfpStatus { + available: true, + connected: false, + device_name: phone_name.unwrap_or("Phone").to_string(), + detail: "Bluetooth ready".to_string(), + } +} + +pub fn connect_audio(phone_name: Option<&str>) -> Result { + Ok(probe_status(phone_name)) +} + +pub fn disconnect_audio() {} + +pub fn open_bluetooth_settings() { + let _ = std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.BluetoothSettings") + .spawn(); +} diff --git a/desktop/app/src/mac/clipboard.rs b/desktop/app/src/mac/clipboard.rs new file mode 100644 index 00000000..a69130b0 --- /dev/null +++ b/desktop/app/src/mac/clipboard.rs @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS clipboard implementation: pbcopy / pbpaste / file handling. + +use std::{ + path::PathBuf, + sync::{ + Mutex, OnceLock, + atomic::{AtomicU64, Ordering}, + }, +}; + +static CLIP_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[allow(dead_code)] +#[derive(Clone, PartialEq, Eq)] +pub enum Image { + Png(Vec), + Bitmap(Vec), +} + +impl std::fmt::Debug for Image { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (kind, len) = match self { + Image::Png(b) => ("Png", b.len()), + Image::Bitmap(b) => ("Bitmap", b.len()), + }; + f.debug_struct(kind).field("bytes", &len).finish() + } +} + +impl Image { + pub fn to_png(&self) -> Result, String> { + match self { + Image::Png(png) => Ok(png.clone()), + Image::Bitmap(b) => Ok(b.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Clip { + Text { + text: String, + html: Option, + rtf: Option, + }, + #[allow(dead_code)] + Image(Image), + Private, + Empty, +} + +type OnCopy = Box; +static ON_COPY: OnceLock = OnceLock::new(); +static LAST_TEXT: Mutex> = Mutex::new(None); +static LAST_COUNT: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(-1); + +pub fn start(on_copy: impl Fn(Clip) + Send + Sync + 'static) { + let _ = ON_COPY.set(Box::new(on_copy)); + // Record initial changeCount at startup so we never broadcast existing clipboard contents + let initial_count = crate::bridge::native::ffi::mac_clipboard_change_count(); + LAST_COUNT.store(initial_count, Ordering::Relaxed); + + std::thread::Builder::new() + .name("mac-clipboard-watcher".into()) + .spawn(|| { + loop { + std::thread::sleep(std::time::Duration::from_millis(500)); + let count = crate::bridge::native::ffi::mac_clipboard_change_count(); + let last = LAST_COUNT.load(Ordering::Relaxed); + if count != last { + LAST_COUNT.store(count, Ordering::Relaxed); + let clip = read(); + match &clip { + Clip::Text { text, .. } => { + let mut last_text = LAST_TEXT.lock().unwrap_or_else(|e| e.into_inner()); + if last_text.as_deref() != Some(text) { + *last_text = Some(text.clone()); + drop(last_text); + if let Some(cb) = ON_COPY.get() { + cb(clip); + } + } + } + Clip::Private => { + if let Some(cb) = ON_COPY.get() { + cb(clip); + } + } + _ => {} + } + } + } + }) + .ok(); +} + +pub fn read() -> Clip { + let mut is_private = false; + let text = crate::bridge::native::ffi::mac_clipboard_read_text(&mut is_private); + if is_private { + return Clip::Private; + } + if !text.is_empty() { + return Clip::Text { text, html: None, rtf: None }; + } + Clip::Empty +} + +pub fn read_for_paste() -> Clip { + read() +} + +pub fn read_image_png() -> Option> { + None +} + +pub fn write(text: &str) -> Result<(), String> { + if crate::bridge::native::ffi::mac_clipboard_write_text(text, false) { + *LAST_TEXT.lock().unwrap_or_else(|e| e.into_inner()) = Some(text.to_string()); + LAST_COUNT.store(crate::bridge::native::ffi::mac_clipboard_change_count(), Ordering::Relaxed); + Ok(()) + } else { + Err("mac_clipboard_write_text failed".into()) + } +} + +pub fn write_rich(text: &str, _html: Option<&str>, _rtf: Option<&str>) -> Result<(), String> { + write(text) +} + +pub fn write_private(text: &str) -> Result<(), String> { + if crate::bridge::native::ffi::mac_clipboard_write_text(text, true) { + *LAST_TEXT.lock().unwrap_or_else(|e| e.into_inner()) = Some(text.to_string()); + LAST_COUNT.store(crate::bridge::native::ffi::mac_clipboard_change_count(), Ordering::Relaxed); + Ok(()) + } else { + Err("mac_clipboard_write_text failed".into()) + } +} + +pub fn write_image(mime: &str, bytes: &[u8]) -> Result<(), String> { + if bytes.is_empty() { + return Err("empty image data".into()); + } + let png_bytes = if mime == "image/png" && bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + bytes.to_vec() + } else { + let bitmap = super::image::decode(bytes)?; + super::image::encode_png(&bitmap)? + }; + + let seq = CLIP_COUNTER.fetch_add(1, Ordering::Relaxed); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + let tmp = std::env::temp_dir().join(format!("nectar_clip_{}_{}_{}.png", std::process::id(), now, seq)); + + std::fs::write(&tmp, &png_bytes).map_err(|e| e.to_string())?; + let script = + format!(r#"set the clipboard to (read (POSIX file "{}") as «class PNGf»)"#, tmp.to_string_lossy()); + let res = std::process::Command::new("osascript").args(["-e", &script]).output(); + let _ = std::fs::remove_file(&tmp); + match res { + Ok(out) if out.status.success() => Ok(()), + Ok(out) => Err(String::from_utf8_lossy(&out.stderr).into_owned()), + Err(e) => Err(e.to_string()), + } +} + +#[allow(dead_code)] +pub fn build_dropfiles_payload(_paths: &[PathBuf]) -> Vec { + Vec::new() +} + +#[allow(dead_code)] +pub fn parse_dropfiles_payload(_bytes: &[u8]) -> Option> { + None +} + +#[allow(dead_code)] +pub fn read_files() -> Option> { + None +} + +#[allow(dead_code)] +pub fn write_files(_paths: &[PathBuf]) -> bool { + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_image_rejects_empty_data() { + assert!(write_image("image/png", &[]).is_err()); + } + + #[test] + fn write_image_rejects_corrupted_jpeg() { + assert!(write_image("image/jpeg", &[0xff, 0xd8, 0x00, 0x00]).is_err()); + } + + #[test] + fn write_image_converts_valid_jpeg() { + #[rustfmt::skip] + const TINY_JPEG: &[u8] = &[ + 255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 255, 219, 0, 67, 0, 8, + 6, 6, 7, 6, 5, 8, 7, 7, 7, 9, 9, 8, 10, 12, 20, 13, 12, 11, 11, 12, 25, 18, 19, 15, 20, 29, 26, + 31, 30, 29, 26, 28, 28, 32, 36, 46, 39, 32, 34, 44, 35, 28, 28, 40, 55, 41, 44, 48, 49, 52, 52, + 52, 31, 39, 57, 61, 56, 50, 60, 46, 51, 52, 50, 255, 219, 0, 67, 1, 9, 9, 9, 12, 11, 12, 24, + 13, 13, 24, 50, 33, 28, 33, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, + 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, + 50, 50, 50, 50, 50, 50, 50, 50, 50, 255, 192, 0, 17, 8, 0, 1, 0, 1, 3, 1, 34, 0, 2, 17, 1, 3, + 17, 1, 255, 196, 0, 31, 0, 0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, + 7, 8, 9, 10, 11, 255, 196, 0, 181, 16, 0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 125, 1, 2, + 3, 0, 4, 17, 5, 18, 33, 49, 65, 6, 19, 81, 97, 7, 34, 113, 20, 50, 129, 145, 161, 8, 35, 66, + 177, 193, 21, 82, 209, 240, 36, 51, 98, 114, 130, 9, 10, 22, 23, 24, 25, 26, 37, 38, 39, 40, + 41, 42, 52, 53, 54, 55, 56, 57, 58, 67, 68, 69, 70, 71, 72, 73, 74, 83, 84, 85, 86, 87, 88, 89, + 90, 99, 100, 101, 102, 103, 104, 105, 106, 115, 116, 117, 118, 119, 120, 121, 122, 131, 132, + 133, 134, 135, 136, 137, 138, 146, 147, 148, 149, 150, 151, 152, 153, 154, 162, 163, 164, 165, + 166, 167, 168, 169, 170, 178, 179, 180, 181, 182, 183, 184, 185, 186, 194, 195, 196, 197, 198, + 199, 200, 201, 202, 210, 211, 212, 213, 214, 215, 216, 217, 218, 225, 226, 227, 228, 229, 230, + 231, 232, 233, 234, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 255, 196, 0, 31, 1, 0, + 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 255, 196, 0, + 181, 17, 0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 119, 0, 1, 2, 3, 17, 4, 5, 33, 49, 6, 18, + 65, 81, 7, 97, 113, 19, 34, 50, 129, 8, 20, 66, 145, 161, 177, 193, 9, 35, 51, 82, 240, 21, 98, + 114, 209, 10, 22, 36, 52, 225, 37, 241, 23, 24, 25, 26, 38, 39, 40, 41, 42, 53, 54, 55, 56, 57, + 58, 67, 68, 69, 70, 71, 72, 73, 74, 83, 84, 85, 86, 87, 88, 89, 90, 99, 100, 101, 102, 103, + 104, 105, 106, 115, 116, 117, 118, 119, 120, 121, 122, 130, 131, 132, 133, 134, 135, 136, 137, + 138, 146, 147, 148, 149, 150, 151, 152, 153, 154, 162, 163, 164, 165, 166, 167, 168, 169, 170, + 178, 179, 180, 181, 182, 183, 184, 185, 186, 194, 195, 196, 197, 198, 199, 200, 201, 202, 210, + 211, 212, 213, 214, 215, 216, 217, 218, 226, 227, 228, 229, 230, 231, 232, 233, 234, 242, 243, + 244, 245, 246, 247, 248, 249, 250, 255, 218, 0, 12, 3, 1, 0, 2, 17, 3, 17, 0, 63, 0, 226, 232, + 162, 138, 249, 147, 247, 19, 255, 217, + ]; + assert!(write_image("image/jpeg", TINY_JPEG).is_ok()); + } +} diff --git a/desktop/app/src/mac/doctor.rs b/desktop/app/src/mac/doctor.rs new file mode 100644 index 00000000..cbdb4d15 --- /dev/null +++ b/desktop/app/src/mac/doctor.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS connection doctor integrations. + +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Firewall { + pub on: bool, + pub allowed: bool, + pub blocked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Network { + pub name: String, + pub trusted: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct Adapters { + pub local_address: bool, + pub vpn: bool, +} + +pub fn firewall(_exe: &Path) -> Result { + Err("firewall check is not supported on macOS".into()) +} + +pub fn networks() -> Result, String> { + Err("network profile check is not supported on macOS".into()) +} + +pub fn adapters() -> Adapters { + Adapters { local_address: true, vpn: false } +} + +pub fn fix_firewall(_exe: &Path) -> Result { + Err("automatic firewall configuration is not supported on macOS".into()) +} + +pub fn open_firewall_settings() -> bool { + let _ = std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Firewall") + .spawn(); + true +} + +#[allow(dead_code)] +pub fn diagnose() -> Vec<(String, bool, String)> { + Vec::new() +} diff --git a/desktop/app/src/mac/elevated.rs b/desktop/app/src/mac/elevated.rs new file mode 100644 index 00000000..05d5b8b6 --- /dev/null +++ b/desktop/app/src/mac/elevated.rs @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS elevated execution stubs. + +use std::path::Path; + +pub enum Error { + #[allow(dead_code)] + Declined, + Other(String), +} + +pub struct Process; + +impl Process { + pub fn exit_code(&self) -> Option { + Some(0) + } +} + +pub fn start(_program: &Path, _params: &str) -> Result { + Err(Error::Other("elevated execution is not supported on macOS".into())) +} diff --git a/desktop/app/src/mac/h264.rs b/desktop/app/src/mac/h264.rs new file mode 100644 index 00000000..f5c49de4 --- /dev/null +++ b/desktop/app/src/mac/h264.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS H.264 video decoding stubs. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Colors { + pub bt601: bool, + pub full_range: bool, +} + +#[derive(Debug)] +#[allow(dead_code)] +pub struct Picture { + pub width: u32, + pub height: u32, + pub stride: usize, + pub plane_rows: usize, + pub nv12: Vec, + pub colors: Colors, +} + +#[derive(Debug, Default)] +pub struct Decoder { + codec: String, +} + +impl Decoder { + pub fn new_for_codec(codec: &str) -> Result { + Err(format!("video decoding ({codec}) is not yet supported on macOS")) + } + + pub fn new_gpu(codec: &str) -> Result { + Self::new_for_codec(codec) + } + + pub fn on_gpu(&self) -> bool { + false + } + + pub fn codec(&self) -> &str { + &self.codec + } + + pub fn decode(&mut self, _data: &[u8], _time_us: u64) -> Result, String> { + Err("video decoding is not supported on macOS".into()) + } + + pub fn flush(&mut self) {} +} + +pub fn to_bgrx(picture: &Picture, width: u32, height: u32) -> Vec { + let (w, h) = (width.min(picture.width) as usize, height.min(picture.height) as usize); + vec![0; w * h * 4] +} + +#[allow(dead_code)] +pub fn ensure_mf_started() -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn h264_decoder_reports_unsupported_on_creation() { + assert!(Decoder::new_for_codec("h264").is_err()); + assert!(Decoder::new_gpu("h264").is_err()); + } + + #[test] + fn h264_decode_reports_unsupported() { + let mut decoder = Decoder::default(); + assert!(decoder.decode(&[0, 0, 0, 1], 0).is_err()); + } +} diff --git a/desktop/app/src/mac/http.rs b/desktop/app/src/mac/http.rs new file mode 100644 index 00000000..7668646d --- /dev/null +++ b/desktop/app/src/mac/http.rs @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! HTTPS GET and download using system curl on macOS. + +use std::path::Path; + +pub fn get(url: &str, limit: usize) -> Result, String> { + let output = std::process::Command::new("curl") + .args(["-s", "-L", "-f", "--max-time", "30", url]) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(format!("curl error: {}", String::from_utf8_lossy(&output.stderr))); + } + if output.stdout.len() > limit { + return Err("too large".into()); + } + Ok(output.stdout) +} + +pub fn get_retrying(url: &str, limit: usize) -> Result, String> { + for _ in 0..3 { + if let Ok(data) = get(url, limit) { + return Ok(data); + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } + get(url, limit) +} + +pub fn download( + url: &str, + file: &Path, + limit: u64, + mut progress: impl FnMut(u64, u64), +) -> Result<(), String> { + let have = file.metadata().map(|m| m.len()).unwrap_or(0); + if have > limit { + return Err("too large".into()); + } + let limit_str = limit.to_string(); + let status = std::process::Command::new("curl") + .args(["-s", "-L", "-f", "-C", "-", "--max-filesize", &limit_str, "-o", &file.to_string_lossy(), url]) + .status() + .map_err(|e| e.to_string())?; + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + if len > limit { + let _ = std::fs::remove_file(file); + return Err("too large".into()); + } + if !status.success() { + if status.code() == Some(63) { + let _ = std::fs::remove_file(file); + return Err("too large".into()); + } + return Err("download failed".into()); + } + progress(len, len); + Ok(()) +} diff --git a/desktop/app/src/mac/image.rs b/desktop/app/src/mac/image.rs new file mode 100644 index 00000000..5228fb88 --- /dev/null +++ b/desktop/app/src/mac/image.rs @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS image conversion and scaling. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bitmap { + pub width: u32, + pub height: u32, + pub bgra: Vec, +} + +pub fn decode(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err("empty image data".into()); + } + let out = crate::bridge::native::ffi::decode_image(bytes); + if out.len() >= 8 { + let width = u32::from_le_bytes(out[0..4].try_into().unwrap_or([0; 4])); + let height = u32::from_le_bytes(out[4..8].try_into().unwrap_or([0; 4])); + let bgra = out[8..].to_vec(); + Ok(Bitmap { width, height, bgra }) + } else { + Err("failed to decode image".into()) + } +} + +pub fn encode_png(bitmap: &Bitmap) -> Result, String> { + Ok(create_png(bitmap.width, bitmap.height, &bitmap.bgra)) +} + +#[allow(dead_code)] +pub fn bmp_from_dib(dib: &[u8]) -> Option> { + Some(dib.to_vec()) +} + +pub fn scale_to(bitmap: &Bitmap, max: u32) -> Bitmap { + let longest = bitmap.width.max(bitmap.height); + if longest <= max || longest == 0 { + return bitmap.clone(); + } + let scale = f64::from(max) / f64::from(longest); + let width = ((f64::from(bitmap.width) * scale).round() as u32).max(1); + let height = ((f64::from(bitmap.height) * scale).round() as u32).max(1); + let mut bgra = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + let (y0, y1) = ( + y * bitmap.height / height, + ((y + 1) * bitmap.height / height).max(y * bitmap.height / height + 1), + ); + for x in 0..width { + let (x0, x1) = ( + x * bitmap.width / width, + ((x + 1) * bitmap.width / width).max(x * bitmap.width / width + 1), + ); + let mut sum = [0u32; 4]; + for sy in y0..y1 { + for sx in x0..x1 { + let i = ((sy * bitmap.width + sx) * 4) as usize; + if i + 4 <= bitmap.bgra.len() { + for (total, &value) in sum.iter_mut().zip(&bitmap.bgra[i..i + 4]) { + *total += u32::from(value); + } + } + } + } + let count = (y1 - y0) * (x1 - x0); + if count > 0 { + for total in sum { + bgra.push(total.checked_div(count).unwrap_or(0) as u8); + } + } else { + bgra.extend_from_slice(&[0, 0, 0, 255]); + } + } + } + Bitmap { width, height, bgra } +} + +fn create_png(width: u32, height: u32, bgra: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(b"\x89PNG\r\n\x1a\n"); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.push(8); + ihdr.push(6); // RGBA + ihdr.push(0); + ihdr.push(0); + ihdr.push(0); + write_chunk(&mut out, b"IHDR", &ihdr); + + let mut raw = Vec::with_capacity((height * (width * 4 + 1)) as usize); + for y in 0..height { + raw.push(0); + let row_start = (y * width * 4) as usize; + let row_end = row_start + (width * 4) as usize; + if row_end <= bgra.len() { + for pixel in bgra[row_start..row_end].as_chunks::<4>().0 { + raw.push(pixel[2]); // R + raw.push(pixel[1]); // G + raw.push(pixel[0]); // B + raw.push(pixel[3]); // A + } + } + } + + let mut zlib = Vec::new(); + zlib.push(0x78); + zlib.push(0x01); + + for (i, chunk) in raw.chunks(65535).enumerate() { + let is_last = i + 1 == raw.chunks(65535).len(); + zlib.push(if is_last { 0x01 } else { 0x00 }); + let len = chunk.len() as u16; + let nlen = !len; + zlib.extend_from_slice(&len.to_le_bytes()); + zlib.extend_from_slice(&nlen.to_le_bytes()); + zlib.extend_from_slice(chunk); + } + let mut s1: u32 = 1; + let mut s2: u32 = 0; + for &b in &raw { + s1 = (s1 + b as u32) % 65521; + s2 = (s2 + s1) % 65521; + } + let adler = (s2 << 16) | s1; + zlib.extend_from_slice(&adler.to_be_bytes()); + + write_chunk(&mut out, b"IDAT", &zlib); + write_chunk(&mut out, b"IEND", &[]); + out +} + +fn write_chunk(out: &mut Vec, tag: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(tag); + out.extend_from_slice(data); + let mut crc = 0xffff_ffff_u32; + for &b in tag.iter().chain(data.iter()) { + crc ^= b as u32; + for _ in 0..8 { + if crc & 1 != 0 { + crc = (crc >> 1) ^ 0xedb8_8320; + } else { + crc >>= 1; + } + } + } + out.extend_from_slice(&(crc ^ 0xffff_ffff_u32).to_be_bytes()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_rejects_empty_data() { + assert!(decode(&[]).is_err()); + } + + #[test] + fn encode_and_decode_roundtrip_preserves_pixels() { + let original = Bitmap { + width: 2, + height: 2, + bgra: vec![ + 255, 0, 0, 255, // blue + 0, 255, 0, 255, // green + 0, 0, 255, 255, // red + 255, 255, 255, 255, // white + ], + }; + let png = encode_png(&original).expect("encode_png failed"); + let decoded = decode(&png).expect("decode failed"); + assert_eq!(decoded.width, 2); + assert_eq!(decoded.height, 2); + assert_eq!(decoded.bgra.len(), 16); + assert_eq!(decoded.bgra, original.bgra); + } +} diff --git a/desktop/app/src/mac/input.rs b/desktop/app/src/mac/input.rs new file mode 100644 index 00000000..8974f722 --- /dev/null +++ b/desktop/app/src/mac/input.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS synthetic input stubs. + +use nectarlink_core::{ButtonAction, KeyMod, MouseButton, SlideAction, TouchAction}; + +pub fn move_relative(_dx: f32, _dy: f32) {} +#[allow(dead_code)] +pub fn move_absolute(_x: f32, _y: f32) {} +#[allow(dead_code)] +pub fn mirror_touch(_action: TouchAction, _x: f32, _y: f32) {} +pub fn mouse_button(_button: MouseButton, _action: ButtonAction) {} +pub fn release_held_buttons() {} +pub fn scroll(_dx: f32, _dy: f32) {} +pub fn type_text(_text: &str) {} +pub fn press_key(_key: &str, _mods: &[KeyMod]) {} +pub fn slide(_action: SlideAction) {} +pub fn is_user_active_within_ms(_max_idle_ms: u32) -> bool { + true +} diff --git a/desktop/app/src/mac/jumplist.rs b/desktop/app/src/mac/jumplist.rs new file mode 100644 index 00000000..0cf6f2c0 --- /dev/null +++ b/desktop/app/src/mac/jumplist.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS task argument stubs. + +use std::path::Path; + +pub const ARG: &str = "--task"; + +pub fn register(_data_dir: Option<&Path>) {} + +pub fn unregister() {} diff --git a/desktop/app/src/mac/media_sessions.rs b/desktop/app/src/mac/media_sessions.rs new file mode 100644 index 00000000..05e4850b --- /dev/null +++ b/desktop/app/src/mac/media_sessions.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS media session integration stubs. + +use nectarlink_core::{MediaAction, MediaError, MediaPlayer}; + +pub fn start(_on_change: impl Fn(Vec) + Send + Sync + 'static) {} + +pub fn command(_player: &str, _action: MediaAction, _pos: Option) -> Result<(), MediaError> { + Ok(()) +} + +pub fn pause_for_call() {} + +pub fn resume_after_call() {} + +#[allow(dead_code)] +pub fn says_video(_id: &str) -> bool { + false +} diff --git a/desktop/app/src/mac/mod.rs b/desktop/app/src/mac/mod.rs new file mode 100644 index 00000000..eb9816cb --- /dev/null +++ b/desktop/app/src/mac/mod.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS integration: system facts, single instance, clipboard, sound, +//! notifications, volume, and shell operations. + +pub mod audio; +pub mod audio_out; +pub mod autostart; +pub mod ble_beacon; +pub mod browser; +pub mod bt_hfp; +pub mod clipboard; +pub mod doctor; +pub mod elevated; +pub mod h264; +pub mod http; +pub mod image; +pub mod input; +pub mod jumplist; +pub mod media_sessions; +pub mod mp4; +pub mod net; +pub mod share_target; +pub mod shell; +pub mod shortcut; +pub mod single_instance; +pub mod smtc; +pub mod sound; +pub mod toast; +pub mod tray; +pub mod vcam; +pub mod wallpaper; +pub mod widget; +pub mod wifi; +pub mod window_capture; +pub mod wol; + +/// Machine GUID on macOS: derived from IOPlatformSerialNumber. +pub fn machine_guid() -> Option { + let output = + std::process::Command::new("ioreg").args(["-rd1", "-c", "IOPlatformExpertDevice"]).output().ok()?; + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.contains("IOPlatformSerialNumber") + && let Some(pos) = line.find('=') + { + let val = line[pos + 1..].trim().trim_matches('"'); + if !val.is_empty() { + return Some(val.to_string()); + } + } + } + None +} + +/// The macOS version as "..". +pub fn os_version() -> String { + let output = std::process::Command::new("sw_vers").arg("-productVersion").output().ok(); + if let Some(out) = output { + let ver = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !ver.is_empty() { + return ver; + } + } + "15.0.0".to_string() +} + +/// Whether this Mac runs on a battery (MacBook). +pub fn is_laptop() -> bool { + let output = std::process::Command::new("pmset").arg("-g").arg("batt").output().ok(); + if let Some(out) = output { + let text = String::from_utf8_lossy(&out.stdout); + text.contains("InternalBattery") + } else { + false + } +} + +/// Whether Low Power Mode is on. +pub fn power_saver() -> bool { + let output = std::process::Command::new("pmset").arg("-g").output().ok(); + if let Some(out) = output { + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + if line.contains("lowpowermode") { + return line.contains('1'); + } + } + } + false +} + +/// Whether macOS is set to dark mode. +pub fn system_dark() -> bool { + let output = + std::process::Command::new("defaults").args(["read", "-g", "AppleInterfaceStyle"]).output().ok(); + if let Some(out) = output { String::from_utf8_lossy(&out.stdout).trim() == "Dark" } else { false } +} + +/// Whether Reduce Motion is enabled. +pub fn reduce_motion() -> bool { + let output = std::process::Command::new("defaults") + .args(["read", "com.apple.universalaccess", "reduceMotion"]) + .output() + .ok(); + if let Some(out) = output { String::from_utf8_lossy(&out.stdout).trim() == "1" } else { false } +} + +/// Primary display work area in physical pixels. +pub fn work_area() -> Option<(i32, i32, i32, i32)> { + None +} + +/// How far this PC's time zone is from UTC at `at_ms` (Unix milliseconds). +pub fn local_offset_ms(at_ms: i64) -> i64 { + crate::bridge::native::ffi::mac_local_offset_ms(at_ms) +} + +/// Free bytes on the drive holding `path`. +pub fn free_space(path: &std::path::Path) -> Option { + let s = path.to_str()?; + let mut ok = false; + let bytes = crate::bridge::native::ffi::mac_free_space(s, &mut ok); + ok.then_some(bytes) +} + +/// Fills `buf` from cryptographic randomness. +pub fn secure_random(buf: &mut [u8]) -> bool { + use std::io::Read; + if let Ok(mut f) = std::fs::File::open("/dev/urandom") { f.read_exact(buf).is_ok() } else { false } +} + +/// On macOS, COM is not used. Runs `f` directly. +#[allow(dead_code)] +pub fn with_com(f: impl FnOnce() -> T) -> T { + f() +} + +#[allow(dead_code)] +pub(crate) fn reg_string(_root: u32, _key: &str, _value: &str) -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn free_space_returns_some_on_system_drive() { + let space = free_space(std::path::Path::new("/")); + assert!(space.is_some()); + } +} diff --git a/desktop/app/src/mac/mp4.rs b/desktop/app/src/mac/mp4.rs new file mode 100644 index 00000000..84e171d8 --- /dev/null +++ b/desktop/app/src/mac/mp4.rs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS MP4 recording stubs. + +use std::{ + path::PathBuf, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; + +pub fn local_timestamp_filename(prefix: &str, ext: &str) -> String { + let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()); + format!("{prefix} {now}.{ext}") +} + +pub struct MirrorRecorder { + path: PathBuf, + started_at: Instant, + started_epoch_ms: i64, +} + +impl std::fmt::Debug for MirrorRecorder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MirrorRecorder").field("path", &self.path).finish_non_exhaustive() + } +} + +impl MirrorRecorder { + pub fn new( + path: PathBuf, + _width: u32, + _height: u32, + _fps: u32, + _audio_cfg: Option<(u32, u8)>, + ) -> MirrorRecorder { + let started_epoch_ms = + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as i64); + MirrorRecorder { path, started_at: Instant::now(), started_epoch_ms } + } + + pub fn started_at(&self) -> Instant { + self.started_at + } + + pub fn started_epoch_ms(&self) -> i64 { + self.started_epoch_ms + } + + pub fn set_sps_pps(&mut self, _sps_pps: &[u8]) {} + + pub fn on_config(&mut self, _width: u32, _height: u32) {} + + pub fn on_audio_config(&mut self, _rate: u32, _channels: u8) {} + + pub fn on_packet(&mut self, _keyframe: bool, _time_us: u64, _data: &[u8]) -> Result<(), String> { + Err("screen recording is not supported on macOS".into()) + } + + pub fn on_audio(&mut self, _time_us: u64, _pcm: &[u8]) -> Result<(), String> { + Err("audio recording is not supported on macOS".into()) + } + + pub fn finish(self) -> Result { + Err("screen recording is not supported on macOS".into()) + } +} + +pub fn merge_sps_pps(sps_pps: &mut Vec, data: &[u8]) { + sps_pps.extend_from_slice(data); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mp4_recorder_reports_unsupported() { + let mut rec = MirrorRecorder::new(PathBuf::from("/tmp/dummy.mp4"), 1920, 1080, 60, None); + assert!(rec.on_packet(true, 0, &[1, 2, 3]).is_err()); + assert!(rec.on_audio(0, &[1, 2, 3]).is_err()); + assert!(rec.finish().is_err()); + } + + #[test] + fn local_timestamp_filename_formats_correctly() { + let name = local_timestamp_filename("Screen", "mp4"); + assert!(name.starts_with("Screen ")); + assert!(name.ends_with(".mp4")); + } +} diff --git a/desktop/app/src/mac/net.rs b/desktop/app/src/mac/net.rs new file mode 100644 index 00000000..6fec10ed --- /dev/null +++ b/desktop/app/src/mac/net.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS network change notification stubs. + +pub fn watch(_callback: impl Fn() + Send + Sync + 'static) {} +pub fn stop() {} diff --git a/desktop/app/src/mac/share_target.rs b/desktop/app/src/mac/share_target.rs new file mode 100644 index 00000000..73543931 --- /dev/null +++ b/desktop/app/src/mac/share_target.rs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS share target stubs. + +use std::path::{Path, PathBuf}; + +pub const ARG: &str = "--share"; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SharedPayload { + pub files: Vec, + pub url: Option, + pub text: Option, +} + +impl SharedPayload { + pub fn is_empty(&self) -> bool { + self.files.is_empty() && self.url.is_none() && self.text.is_none() + } +} + +fn is_share_url(s: &str) -> bool { + let s = s.trim(); + if s.is_empty() || s.len() > 4096 || s.chars().any(char::is_whitespace) { + return false; + } + let lower: String = s.chars().take(8).collect::().to_ascii_lowercase(); + lower.starts_with("http://") + || lower.starts_with("https://") + || (lower.starts_with("geo:") && s.len() > 4) +} + +fn looks_like_path(s: &str) -> bool { + let bytes = s.as_bytes(); + Path::new(s).exists() + || s.contains('\\') + || s.contains('/') + || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') +} + +/// Classifies `--share ` CLI arguments into files, a link, or text. +pub fn parse_targets(targets: &[String]) -> SharedPayload { + let mut payload = SharedPayload::default(); + let mut text_parts = Vec::new(); + for raw in targets { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + if payload.url.is_none() && is_share_url(trimmed) { + payload.url = Some(trimmed.to_owned()); + } else if looks_like_path(trimmed) { + payload.files.push(PathBuf::from(trimmed)); + } else { + text_parts.push(trimmed.to_owned()); + } + } + if !text_parts.is_empty() { + payload.text = Some(text_parts.join(" ")); + } + payload +} + +pub fn take_activated_share(_data_dir: &Path) -> Option { + None +} diff --git a/desktop/app/src/mac/shell.rs b/desktop/app/src/mac/shell.rs new file mode 100644 index 00000000..5d0171f3 --- /dev/null +++ b/desktop/app/src/mac/shell.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Opening links, maps, email, locking, and sleeping the Mac. + +use std::path::Path; + +/// Opens a link in the default browser using macOS `open`. +pub fn open_url(url: &str) -> Result<(), String> { + std::process::Command::new("open").arg(url).spawn().map(|_| ()).map_err(|e| e.to_string()) +} + +/// Opens a `mailto:` link in the default mail client. +pub fn open_mailto(email: &str) -> Result<(), String> { + let trimmed = email.trim(); + if trimmed.is_empty() { + return Err("empty email address".into()); + } + open_url(&format!("mailto:{trimmed}")) +} + +/// Opens a street address in Apple Maps or web maps. +pub fn open_maps(address: &str) -> Result<(), String> { + let trimmed = address.trim(); + if trimmed.is_empty() { + return Err("empty address".into()); + } + let url = format!("https://maps.apple.com/?q={}", urlencoding::encode(trimmed)); + open_url(&url) +} + +/// Returns `true` if `scheme` is recognized. +#[allow(dead_code)] +pub fn is_url_scheme_registered(scheme: &str) -> bool { + matches!(scheme.trim(), "http" | "https" | "mailto" | "maps") +} + +/// Starts a program with arguments via `open` or direct process spawn. +pub fn run(program: &Path, arguments: &str) -> Result<(), String> { + let mut cmd = std::process::Command::new(program); + if !arguments.is_empty() { + cmd.args(arguments.split_whitespace()); + } + cmd.spawn().map(|_| ()).map_err(|e| e.to_string()) +} + +/// Locks the Mac screen. +pub fn lock() -> Result<(), String> { + // Legacy macOS (< Big Sur): CGSession -suspend locked the screen directly. + // Modern macOS (11+) removed CGSession, so it falls back to osascript. + let status = std::process::Command::new( + "/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession", + ) + .arg("-suspend") + .status(); + if status.is_ok_and(|s| s.success()) { + return Ok(()); + } + // Fallback to osascript with status check + let output = std::process::Command::new("osascript") + .args(["-e", r#"tell application "System Events" to key code 12 using {control down, command down}"#]) + .output() + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +/// Puts the Mac to sleep. +pub fn sleep() -> Result<(), String> { + std::process::Command::new("pmset").arg("sleepnow").spawn().map(|_| ()).map_err(|e| e.to_string()) +} + +/// Launches an application bundle or executable. +pub fn launch_app(path: &Path) -> Result<(), String> { + std::process::Command::new("open").arg(path).spawn().map(|_| ()).map_err(|e| e.to_string()) +} + +/// Runs a shell command. +pub fn run_command(command: &str) -> Result<(), String> { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Err("empty command".into()); + } + std::process::Command::new("/bin/sh") + .arg("-c") + .arg(trimmed) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|e| format!("failed to run command: {e}")) +} + +mod urlencoding { + pub fn encode(s: &str) -> String { + let mut out = String::new(); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~' { + out.push(b as char); + } else { + out.push_str(&format!("%{:02X}", b)); + } + } + out + } +} diff --git a/desktop/app/src/mac/shortcut.rs b/desktop/app/src/mac/shortcut.rs new file mode 100644 index 00000000..a943b1e7 --- /dev/null +++ b/desktop/app/src/mac/shortcut.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS desktop shortcut and send-to stubs. + +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Shortcut { + pub target: PathBuf, + pub arguments: String, + pub description: String, +} + +pub fn send_to_dir() -> Option { + None +} + +pub fn recent_dir() -> Option { + None +} + +pub fn arguments(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let mut lines = content.lines(); + let _target = lines.next()?; + let arguments = lines.next()?; + Some(arguments.to_string()) +} + +pub fn target(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let mut lines = content.lines(); + let target = lines.next()?; + Some(PathBuf::from(target)) +} + +pub fn write(path: &Path, shortcut: &Shortcut) -> std::io::Result<()> { + std::fs::write( + path, + format!("{}\n{}\n{}\n", shortcut.target.display(), shortcut.arguments, shortcut.description), + ) +} diff --git a/desktop/app/src/mac/single_instance.rs b/desktop/app/src/mac/single_instance.rs new file mode 100644 index 00000000..88ff33f0 --- /dev/null +++ b/desktop/app/src/mac/single_instance.rs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS single-instance lock and IPC using Unix domain sockets. + +use std::{ + io::{Read, Write}, + os::unix::net::{UnixListener, UnixStream}, + path::{Path, PathBuf}, + sync::Mutex, +}; + +#[derive(Debug)] +pub struct PrimaryInstance { + listener: Mutex>, + sock_path: PathBuf, +} + +#[derive(Debug)] +pub enum Instance { + Primary(PrimaryInstance), + Secondary, +} + +pub fn acquire(data_dir: &Path) -> Result { + let _ = std::fs::create_dir_all(data_dir); + let sock_path = data_dir.join("nectarlink.sock"); + + match UnixListener::bind(&sock_path) { + Ok(listener) => { + Ok(Instance::Primary(PrimaryInstance { listener: Mutex::new(Some(listener)), sock_path })) + } + Err(_) => { + // Check if another instance is actively responding + if UnixStream::connect(&sock_path).is_ok() { + return Ok(Instance::Secondary); + } + // Stale socket from previous crash or exit + let _ = std::fs::remove_file(&sock_path); + match UnixListener::bind(&sock_path) { + Ok(listener) => { + Ok(Instance::Primary(PrimaryInstance { listener: Mutex::new(Some(listener)), sock_path })) + } + Err(e) => Err(format!("can't bind single-instance socket: {e}")), + } + } + } +} + +pub fn wake(data_dir: &Path) { + let sock_path = data_dir.join("nectarlink.sock"); + if let Ok(mut stream) = UnixStream::connect(&sock_path) { + let _ = stream.write_all(b"wake\n"); + } +} + +impl PrimaryInstance { + pub fn watch(&'static self, on_activate: impl Fn() + Send + 'static) { + let listener = match self.listener.lock() { + Ok(mut guard) => guard.take(), + Err(_) => None, + }; + let Some(listener) = listener else { + return; + }; + + std::thread::Builder::new() + .name("single-instance".into()) + .spawn(move || { + for mut s in listener.incoming().flatten() { + let mut buf = [0u8; 16]; + let _ = s.read(&mut buf); + on_activate(); + } + }) + .ok(); + } +} + +impl Drop for PrimaryInstance { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.sock_path); + } +} diff --git a/desktop/app/src/mac/smtc.rs b/desktop/app/src/mac/smtc.rs new file mode 100644 index 00000000..c248bf83 --- /dev/null +++ b/desktop/app/src/mac/smtc.rs @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS system media transport controls stubs. + +use crate::state::PlayerView; +use nectarlink_core::{DeviceId, MediaAction}; + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct Shown { + pub device: DeviceId, + pub device_name: String, + pub view: PlayerView, +} + +pub fn start(_on_command: impl Fn(DeviceId, String, MediaAction, Option) + Send + Sync + 'static) {} +pub fn show(_shown: Option) {} +#[allow(dead_code)] +pub fn clear() {} diff --git a/desktop/app/src/mac/sound.rs b/desktop/app/src/mac/sound.rs new file mode 100644 index 00000000..0f6a3ae2 --- /dev/null +++ b/desktop/app/src/mac/sound.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS sound ringing ("find my PC" from phone). + +use std::{ + sync::atomic::{AtomicU32, AtomicU64, Ordering}, + time::Duration, +}; + +const MAX_RING: Duration = Duration::from_secs(60); +static GENERATION: AtomicU64 = AtomicU64::new(0); +static CURRENT_PID: AtomicU32 = AtomicU32::new(0); + +pub fn start_ringing() { + let generation = GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + let _ = std::thread::Builder::new().name("mac-ring".into()).spawn(move || { + let start = std::time::Instant::now(); + while start.elapsed() < MAX_RING && GENERATION.load(Ordering::SeqCst) == generation { + if let Ok(mut child) = + std::process::Command::new("afplay").arg("/System/Library/Sounds/Ping.aiff").spawn() + { + CURRENT_PID.store(child.id(), Ordering::SeqCst); + let _ = child.wait(); + CURRENT_PID.store(0, Ordering::SeqCst); + } + std::thread::sleep(Duration::from_millis(600)); + } + }); +} + +pub fn stop_ringing() { + GENERATION.fetch_add(1, Ordering::SeqCst); + let pid = CURRENT_PID.swap(0, Ordering::SeqCst); + if pid != 0 { + let _ = std::process::Command::new("kill").arg("-9").arg(pid.to_string()).output(); + } +} diff --git a/desktop/app/src/mac/toast.rs b/desktop/app/src/mac/toast.rs new file mode 100644 index 00000000..ecab9e0c --- /dev/null +++ b/desktop/app/src/mac/toast.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS notifications via AppleScript. + +use std::{ + path::PathBuf, + sync::{Mutex, OnceLock}, +}; + +#[allow(dead_code)] +pub const AUMID: &str = "com.thtbee.nectarlink"; +pub const CALL_ANSWER: &str = "answer"; +pub const CALL_DECLINE: &str = "decline"; +pub const CALL_END: &str = "end"; + +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToastEvent { + Opened { device: String, key: String }, + Action { device: String, key: String, action: String }, + Reply { device: String, key: String, action: String, text: String }, + Dismissed { device: String, key: String }, +} + +#[allow(dead_code)] +#[derive(Clone)] +pub struct Toast { + pub device: String, + pub key: String, + pub title: String, + pub body: String, + pub attribution: String, + pub icon: Option, + pub image: Option, + pub actions: Vec<(String, String)>, + pub reply: Option<(String, String)>, + pub silent: bool, + pub progress: Option, + pub call: bool, +} + +impl std::fmt::Debug for Toast { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Toast").field("device", &self.device).finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Progress { + pub status: String, + pub value: Option, + pub label: String, +} + +type OnEvent = Box; +static ON_EVENT: OnceLock>> = OnceLock::new(); + +pub fn enabled() -> bool { + true +} + +pub fn set_process_id() {} + +pub fn unregister() {} + +pub fn start(_icon: PathBuf, on_event: impl Fn(ToastEvent) + Send + Sync + 'static) { + let _ = ON_EVENT.set(Mutex::new(Some(Box::new(on_event)))); +} + +pub fn show(toast: Toast) { + let script = if toast.attribution.is_empty() { + format!("display notification {:?} with title {:?}", toast.body, toast.title) + } else { + format!( + "display notification {:?} with title {:?} subtitle {:?}", + toast.body, toast.title, toast.attribution + ) + }; + let _ = std::process::Command::new("osascript").args(["-e", &script]).spawn(); +} + +pub fn update_progress(_device: &str, _key: &str, _progress: Progress) {} + +pub fn remove(_device: &str, _key: &str) {} + +pub fn remove_device(_device: &str) {} diff --git a/desktop/app/src/mac/tray.rs b/desktop/app/src/mac/tray.rs new file mode 100644 index 00000000..6114408c --- /dev/null +++ b/desktop/app/src/mac/tray.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS status bar / tray integration stubs. + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayEvent { + Open, + CommandPalette, + Shelf, + TakePhoto, + ScanDocument, + FindPhone, + OpenLinkOnPhone, + Quit, + Resumed, + AppearanceChanged, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct MenuLabels { + pub open: String, + pub command_palette: String, + pub shelf: String, + pub take_photo: String, + pub scan_document: String, + pub find_phone: String, + pub open_link: String, + pub quit: String, +} + +pub struct Tray; + +impl Tray { + pub fn create( + _tooltip: &str, + _labels: MenuLabels, + _handler: impl Fn(TrayEvent) + 'static, + ) -> Result { + Ok(Tray) + } + + pub fn set_tooltip(&self, _text: &str) {} + + pub fn set_find_phone_enabled(&self, _enabled: bool) {} +} + +pub fn update_hotkeys(_photo_spec: &str, _scan_spec: &str, _palette_spec: &str, _shelf_spec: &str) {} + +#[allow(dead_code)] +pub fn record_foreground_window() {} + +pub fn apply_no_activate_style_by_title(_title: &str) {} + +pub fn focus_window(_hwnd: isize) -> bool { + true +} + +pub fn is_window_alive_and_visible(_hwnd: isize) -> bool { + false +} + +pub fn snapshot_target_window() -> Option<(isize, String)> { + None +} + +pub fn send_paste_fallback_if_not_foreground(_hwnd: isize) {} + +#[allow(dead_code)] +pub fn parse_hotkey(_spec: &str) -> Option<(u32, u32)> { + None +} diff --git a/desktop/app/src/mac/vcam.rs b/desktop/app/src/mac/vcam.rs new file mode 100644 index 00000000..52cd27a8 --- /dev/null +++ b/desktop/app/src/mac/vcam.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS virtual camera stubs. + +pub const REGISTER_ARG: &str = "--register-vcam"; +pub const UNREGISTER_ARG: &str = "--unregister-vcam"; + +pub fn is_registered() -> bool { + false +} + +pub fn register_hklm() -> Result<(), String> { + Ok(()) +} + +pub fn unregister_hklm() -> Result<(), String> { + Ok(()) +} + +pub fn run_elevated(_arg: &'static str, on_done: impl FnOnce(bool) + Send + 'static) { + on_done(false); +} + +#[derive(Debug, Default)] +pub struct VirtualCameraHandle; + +impl VirtualCameraHandle { + pub fn start() -> Result { + Err("virtual camera is not supported on macOS".into()) + } +} diff --git a/desktop/app/src/mac/wallpaper.rs b/desktop/app/src/mac/wallpaper.rs new file mode 100644 index 00000000..54630a9a --- /dev/null +++ b/desktop/app/src/mac/wallpaper.rs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS wallpaper color accent seed and artwork palette extraction. + +use material_colors::color::Rgb; +use std::path::Path; + +pub fn seed() -> Option { + Some(Rgb::new(245, 166, 35)) +} + +pub fn artwork_scheme_from_path(_path: &Path) -> Option { + None +} diff --git a/desktop/app/src/mac/widget.rs b/desktop/app/src/mac/widget.rs new file mode 100644 index 00000000..a49b98dc --- /dev/null +++ b/desktop/app/src/mac/widget.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS widget stubs. + +use std::{path::Path, process::ExitCode}; + +pub const ARG: &str = "--widget-provider"; + +pub fn write_state_snapshot(_dir: &Path) {} + +pub fn run_provider(_data_dir: &Path) -> ExitCode { + ExitCode::SUCCESS +} diff --git a/desktop/app/src/mac/wifi.rs b/desktop/app/src/mac/wifi.rs new file mode 100644 index 00000000..2ee8991e --- /dev/null +++ b/desktop/app/src/mac/wifi.rs @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Wi-Fi stubs on macOS. + +/// Whether a Wi-Fi network is in range. +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reach { + NotInRange, + InRange, + Connected, +} + +/// Saved Wi-Fi networks known to the OS. +pub fn saved_networks() -> Vec { + Vec::new() +} + +/// Requests a background Wi-Fi scan. +pub fn scan() {} + +/// Checks whether `profile` is in range or connected. +pub fn reach(_profile: &str) -> Reach { + Reach::NotInRange +} + +/// Connects to a saved Wi-Fi profile. +pub fn connect(_profile: &str) -> Result<(), String> { + Err("Wi-Fi network connection is not supported on macOS".into()) +} diff --git a/desktop/app/src/mac/window_capture.rs b/desktop/app/src/mac/window_capture.rs new file mode 100644 index 00000000..ba5d0021 --- /dev/null +++ b/desktop/app/src/mac/window_capture.rs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS window capture stubs. + +#[allow(dead_code)] +#[derive(Debug)] +pub struct WindowCapture; + +#[allow(dead_code)] +impl WindowCapture { + pub fn new(_window: &super::browser::Window) -> Option { + None + } + pub fn capture_frame(&mut self) -> Option> { + None + } +} diff --git a/desktop/app/src/mac/wol.rs b/desktop/app/src/mac/wol.rs new file mode 100644 index 00000000..052c7fc1 --- /dev/null +++ b/desktop/app/src/mac/wol.rs @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! macOS Wake-on-LAN integration stubs. + +use nectarlink_core::PcWakeInfo; + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WakeSupport { + Enabled, + Disabled, + Unknown, +} + +impl WakeSupport { + pub fn as_str(self) -> &'static str { + match self { + WakeSupport::Enabled => "enabled", + WakeSupport::Disabled => "disabled", + WakeSupport::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WakeAdapter { + pub label: String, + pub wired: bool, + pub mac: String, + pub broadcasts: Vec, + pub support: WakeSupport, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct WakeStatus { + pub info: PcWakeInfo, + pub adapters: Vec, +} + +impl WakeStatus { + pub fn primary(&self) -> Option<&WakeAdapter> { + self.adapters.first() + } + + pub fn state_str(&self) -> &'static str { + match self.primary() { + Some(a) => a.support.as_str(), + None => "none", + } + } +} + +pub fn query_wake_status() -> WakeStatus { + WakeStatus::default() +} + +#[allow(dead_code)] +pub fn wake(_mac: &[u8]) -> Result<(), String> { + Err("wake-on-lan is not supported on macOS".into()) +} diff --git a/desktop/app/src/main.rs b/desktop/app/src/main.rs index 29971f08..f7c1c138 100644 --- a/desktop/app/src/main.rs +++ b/desktop/app/src/main.rs @@ -56,6 +56,10 @@ mod storage; mod transfers; mod updater; mod webcam; +#[cfg(windows)] +mod win; +#[cfg(target_os = "macos")] +#[path = "mac/mod.rs"] mod win; use std::{ diff --git a/desktop/app/src/media_file.rs b/desktop/app/src/media_file.rs index 98685884..c5a0ae8f 100644 --- a/desktop/app/src/media_file.rs +++ b/desktop/app/src/media_file.rs @@ -4,6 +4,8 @@ //! shortcut to every file opened lately (the Recent folder), and the //! player's title or its window's names the file. +#![cfg_attr(not(windows), allow(dead_code))] + use std::path::{Path, PathBuf}; use crate::win::{browser, shortcut}; diff --git a/desktop/app/src/mirror.rs b/desktop/app/src/mirror.rs index c2991991..8099443c 100644 --- a/desktop/app/src/mirror.rs +++ b/desktop/app/src/mirror.rs @@ -1354,6 +1354,7 @@ pub fn leave_window() { } /// Records text that arrived from a remote device so focus/hover sync never echoes it back. +#[allow(dead_code)] pub fn note_remote_clipboard(text: &str) { let normalized = text.replace("\r\n", "\n"); state(|s| { @@ -1404,10 +1405,13 @@ pub fn sync_clipboard_on_focus(window: Window) { } /// Standard XInput / Xbox left thumbstick deadzone. +#[allow(dead_code)] pub const LEFT_THUMB_DEADZONE: i16 = 7849; /// Standard XInput / Xbox right thumbstick deadzone. +#[allow(dead_code)] pub const RIGHT_THUMB_DEADZONE: i16 = 8689; /// Standard XInput / Xbox trigger threshold. +#[allow(dead_code)] pub const TRIGGER_THRESHOLD: u8 = 30; /// Normalized controller snapshot forwarded via [`nectarlink_core::MirrorInput::Gamepad`]. @@ -1437,12 +1441,14 @@ impl GamepadState { } } +#[allow(dead_code)] fn deadzone_axis(val: i16, deadzone: i16) -> i16 { if val.saturating_abs() <= deadzone { 0 } else { val } } /// Applies stick deadzones and trigger thresholds so idle controller noise does /// not generate redundant [`nectarlink_core::MirrorInput::Gamepad`] frames. +#[allow(dead_code)] pub fn normalize_gamepad_state(raw: GamepadState) -> GamepadState { GamepadState { buttons: raw.buttons, @@ -1455,6 +1461,7 @@ pub fn normalize_gamepad_state(raw: GamepadState) -> GamepadState { } } +#[cfg(windows)] #[allow(unsafe_code)] fn poll_xinput_gamepad() -> Option { use windows::Win32::UI::Input::XboxController::{XINPUT_STATE, XInputGetState}; @@ -1477,6 +1484,7 @@ fn poll_xinput_gamepad() -> Option { None } +#[cfg(windows)] fn poll_winrt_gamepad() -> Option { use windows::Gaming::Input::{Gamepad, GamepadButtons}; let gamepads = Gamepad::Gamepads().ok()?; @@ -1542,10 +1550,16 @@ fn poll_winrt_gamepad() -> Option { })) } +#[cfg(windows)] fn poll_gamepad() -> Option { poll_xinput_gamepad().or_else(poll_winrt_gamepad) } +#[cfg(not(windows))] +fn poll_gamepad() -> Option { + None +} + fn ensure_gamepad_loop() { static RUNNING: AtomicBool = AtomicBool::new(false); if RUNNING.swap(true, Ordering::AcqRel) { diff --git a/desktop/app/src/pc_audio_stream.rs b/desktop/app/src/pc_audio_stream.rs index 03e7efb1..5cd0a97b 100644 --- a/desktop/app/src/pc_audio_stream.rs +++ b/desktop/app/src/pc_audio_stream.rs @@ -4,394 +4,425 @@ //! 32-bit float stereo samples to 16-bit signed little-endian PCM, and streams //! them to connected phones over NectarLink's low-latency audio stream. -#![allow(unsafe_code)] - -use std::{ - sync::atomic::{AtomicBool, Ordering}, - time::Duration, -}; - -use windows::Win32::{ - Media::Audio::{ - AUDCLNT_BUFFERFLAGS_SILENT, AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_LOOPBACK, - IAudioCaptureClient, IAudioClient, IMMDeviceEnumerator, MMDeviceEnumerator, WAVEFORMATEX, eConsole, - eRender, - }, - System::{ - Com::{CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree}, - Threading::{GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL}, - }, -}; - -use crate::core_host; - -static ENABLED: AtomicBool = AtomicBool::new(false); -static WORKER_RUNNING: AtomicBool = AtomicBool::new(false); -/// The phone that last turned "Phone speaker" on. Audio goes there while it's online; -/// when the PC turned it on (or that phone is away), to the first phone that is. -static TARGET: std::sync::Mutex> = std::sync::Mutex::new(None); - -/// The phone the sound goes to first, if one asked. -pub fn target() -> Option { - *TARGET.lock().unwrap_or_else(|e| e.into_inner()) -} +#[cfg(windows)] +mod imp { -/// Records which phone asked to be the speaker. -pub fn set_target(phone: Option) { - *TARGET.lock().unwrap_or_else(|e| e.into_inner()) = phone; -} + #![allow(unsafe_code)] -/// Returns whether PC-to-phone audio streaming is currently enabled. -pub fn is_enabled() -> bool { - ENABLED.load(Ordering::SeqCst) -} + use std::{ + sync::atomic::{AtomicBool, Ordering}, + time::Duration, + }; -/// Enables or disables PC-to-phone audio loopback streaming. -pub fn set_enabled(enabled: bool) { - let prev = ENABLED.swap(enabled, Ordering::SeqCst); - if enabled && !prev && core_host::node().is_some() { - ensure_worker(); + use windows::Win32::{ + Media::Audio::{ + AUDCLNT_BUFFERFLAGS_SILENT, AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_LOOPBACK, + IAudioCaptureClient, IAudioClient, IMMDeviceEnumerator, MMDeviceEnumerator, WAVEFORMATEX, + eConsole, eRender, + }, + System::{ + Com::{CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree}, + Threading::{GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL}, + }, + }; + + use crate::core_host; + + static ENABLED: AtomicBool = AtomicBool::new(false); + static WORKER_RUNNING: AtomicBool = AtomicBool::new(false); + /// The phone that last turned "Phone speaker" on. Audio goes there while it's online; + /// when the PC turned it on (or that phone is away), to the first phone that is. + static TARGET: std::sync::Mutex> = std::sync::Mutex::new(None); + + /// The phone the sound goes to first, if one asked. + pub fn target() -> Option { + *TARGET.lock().unwrap_or_else(|e| e.into_inner()) } -} -/// Converts interleaved stereo `f32` samples (`[-1.0, 1.0]`) into 16-bit -/// signed little-endian PCM bytes (`pcm_s16le`). -#[cfg(test)] -pub fn f32_stereo_to_i16le(samples: &[f32]) -> Vec { - let mut out = Vec::with_capacity(samples.len() * 2); - for &sample in samples { - let clean = if sample.is_finite() { sample.clamp(-1.0, 1.0) } else { 0.0 }; - let scaled = (clean * 32767.0).round() as i16; - out.extend_from_slice(&scaled.to_le_bytes()); + /// Records which phone asked to be the speaker. + pub fn set_target(phone: Option) { + *TARGET.lock().unwrap_or_else(|e| e.into_inner()) = phone; } - out -} -#[derive(Clone, Copy)] -struct Biquad { - b0: f32, - b1: f32, - b2: f32, - a1: f32, - a2: f32, - z1_l: f32, - z2_l: f32, - z1_r: f32, - z2_r: f32, -} + /// Returns whether PC-to-phone audio streaming is currently enabled. + pub fn is_enabled() -> bool { + ENABLED.load(Ordering::SeqCst) + } -impl Biquad { - fn low_shelf(sample_rate: f32, freq: f32, gain_db: f32) -> Self { - let a = 10.0_f32.powf(gain_db / 40.0); - let w0 = 2.0 * std::f32::consts::PI * (freq / sample_rate.max(8_000.0)).clamp(0.001, 0.45); - let cos_w0 = w0.cos(); - let sin_w0 = w0.sin(); - let alpha = sin_w0 / 2.0 * std::f32::consts::SQRT_2; - let two_sqrt_a_alpha = 2.0 * a.sqrt() * alpha; - - let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + two_sqrt_a_alpha); - let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0); - let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - two_sqrt_a_alpha); - let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + two_sqrt_a_alpha; - let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0); - let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - two_sqrt_a_alpha; - - Self { - b0: b0 / a0, - b1: b1 / a0, - b2: b2 / a0, - a1: a1 / a0, - a2: a2 / a0, - z1_l: 0.0, - z2_l: 0.0, - z1_r: 0.0, - z2_r: 0.0, + /// Enables or disables PC-to-phone audio loopback streaming. + pub fn set_enabled(enabled: bool) { + let prev = ENABLED.swap(enabled, Ordering::SeqCst); + if enabled && !prev && core_host::node().is_some() { + ensure_worker(); } } - fn high_shelf(sample_rate: f32, freq: f32, gain_db: f32) -> Self { - let a = 10.0_f32.powf(gain_db / 40.0); - let w0 = 2.0 * std::f32::consts::PI * (freq / sample_rate.max(8_000.0)).clamp(0.001, 0.45); - let cos_w0 = w0.cos(); - let sin_w0 = w0.sin(); - let alpha = sin_w0 / 2.0 * std::f32::consts::SQRT_2; - let two_sqrt_a_alpha = 2.0 * a.sqrt() * alpha; - - let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + two_sqrt_a_alpha); - let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0); - let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - two_sqrt_a_alpha); - let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + two_sqrt_a_alpha; - let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0); - let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - two_sqrt_a_alpha; - - Self { - b0: b0 / a0, - b1: b1 / a0, - b2: b2 / a0, - a1: a1 / a0, - a2: a2 / a0, - z1_l: 0.0, - z2_l: 0.0, - z1_r: 0.0, - z2_r: 0.0, + /// Converts interleaved stereo `f32` samples (`[-1.0, 1.0]`) into 16-bit + /// signed little-endian PCM bytes (`pcm_s16le`). + #[cfg(test)] + pub fn f32_stereo_to_i16le(samples: &[f32]) -> Vec { + let mut out = Vec::with_capacity(samples.len() * 2); + for &sample in samples { + let clean = if sample.is_finite() { sample.clamp(-1.0, 1.0) } else { 0.0 }; + let scaled = (clean * 32767.0).round() as i16; + out.extend_from_slice(&scaled.to_le_bytes()); } + out } - #[inline] - fn process_stereo(&mut self, l: f32, r: f32) -> (f32, f32) { - let out_l = self.b0 * l + self.z1_l; - self.z1_l = self.b1 * l - self.a1 * out_l + self.z2_l; - self.z2_l = self.b2 * l - self.a2 * out_l; - - let out_r = self.b0 * r + self.z1_r; - self.z1_r = self.b1 * r - self.a1 * out_r + self.z2_r; - self.z2_r = self.b2 * r - self.a2 * out_r; - (out_l, out_r) + #[derive(Clone, Copy)] + struct Biquad { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, + z1_l: f32, + z2_l: f32, + z1_r: f32, + z2_r: f32, } -} -struct SpeakerEnhancer { - low_shelf: Biquad, - high_shelf: Biquad, -} + impl Biquad { + fn low_shelf(sample_rate: f32, freq: f32, gain_db: f32) -> Self { + let a = 10.0_f32.powf(gain_db / 40.0); + let w0 = 2.0 * std::f32::consts::PI * (freq / sample_rate.max(8_000.0)).clamp(0.001, 0.45); + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let alpha = sin_w0 / 2.0 * std::f32::consts::SQRT_2; + let two_sqrt_a_alpha = 2.0 * a.sqrt() * alpha; + + let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + two_sqrt_a_alpha); + let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0); + let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - two_sqrt_a_alpha); + let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + two_sqrt_a_alpha; + let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0); + let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - two_sqrt_a_alpha; + + Self { + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + z1_l: 0.0, + z2_l: 0.0, + z1_r: 0.0, + z2_r: 0.0, + } + } -impl SpeakerEnhancer { - fn new(sample_rate: u32) -> Self { - let sr = sample_rate as f32; - Self { low_shelf: Biquad::low_shelf(sr, 155.0, 3.5), high_shelf: Biquad::high_shelf(sr, 3800.0, 1.5) } - } + fn high_shelf(sample_rate: f32, freq: f32, gain_db: f32) -> Self { + let a = 10.0_f32.powf(gain_db / 40.0); + let w0 = 2.0 * std::f32::consts::PI * (freq / sample_rate.max(8_000.0)).clamp(0.001, 0.45); + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let alpha = sin_w0 / 2.0 * std::f32::consts::SQRT_2; + let two_sqrt_a_alpha = 2.0 * a.sqrt() * alpha; + + let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + two_sqrt_a_alpha); + let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0); + let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - two_sqrt_a_alpha); + let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + two_sqrt_a_alpha; + let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0); + let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - two_sqrt_a_alpha; + + Self { + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + z1_l: 0.0, + z2_l: 0.0, + z1_r: 0.0, + z2_r: 0.0, + } + } - #[inline] - fn soft_limit(x: f32) -> f32 { - if !x.is_finite() { - return 0.0; + #[inline] + fn process_stereo(&mut self, l: f32, r: f32) -> (f32, f32) { + let out_l = self.b0 * l + self.z1_l; + self.z1_l = self.b1 * l - self.a1 * out_l + self.z2_l; + self.z2_l = self.b2 * l - self.a2 * out_l; + + let out_r = self.b0 * r + self.z1_r; + self.z1_r = self.b1 * r - self.a1 * out_r + self.z2_r; + self.z2_r = self.b2 * r - self.a2 * out_r; + (out_l, out_r) } - let boosted = x * 1.12; - (boosted / (1.0 + 0.18 * boosted.abs())).clamp(-1.0, 1.0) } - fn encode_stereo_f32(&mut self, floats: &[f32], channels: usize) -> Vec { - let frames = floats.len() / channels.max(1); - let mut out = Vec::with_capacity(frames * 4); - for frame in floats.chunks_exact(channels.max(1)) { - let l_in = if frame[0].is_finite() { frame[0] } else { 0.0 }; - let r_in = if channels > 1 && frame[1].is_finite() { frame[1] } else { l_in }; - let (l1, r1) = self.low_shelf.process_stereo(l_in, r_in); - let (l2, r2) = self.high_shelf.process_stereo(l1, r1); - let l_i16 = (Self::soft_limit(l2) * 32767.0).round() as i16; - let r_i16 = (Self::soft_limit(r2) * 32767.0).round() as i16; - out.extend_from_slice(&l_i16.to_le_bytes()); - out.extend_from_slice(&r_i16.to_le_bytes()); - } - out + struct SpeakerEnhancer { + low_shelf: Biquad, + high_shelf: Biquad, } - fn encode_stereo_i16(&mut self, samples: &[i16], channels: usize) -> Vec { - let frames = samples.len() / channels.max(1); - let mut out = Vec::with_capacity(frames * 4); - for frame in samples.chunks_exact(channels.max(1)) { - let l_in = (frame[0] as f32) / 32768.0; - let r_in = if channels > 1 { (frame[1] as f32) / 32768.0 } else { l_in }; - let (l1, r1) = self.low_shelf.process_stereo(l_in, r_in); - let (l2, r2) = self.high_shelf.process_stereo(l1, r1); - let l_i16 = (Self::soft_limit(l2) * 32767.0).round() as i16; - let r_i16 = (Self::soft_limit(r2) * 32767.0).round() as i16; - out.extend_from_slice(&l_i16.to_le_bytes()); - out.extend_from_slice(&r_i16.to_le_bytes()); + impl SpeakerEnhancer { + fn new(sample_rate: u32) -> Self { + let sr = sample_rate as f32; + Self { + low_shelf: Biquad::low_shelf(sr, 155.0, 3.5), + high_shelf: Biquad::high_shelf(sr, 3800.0, 1.5), + } } - out - } -} -fn ensure_worker() { - if WORKER_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { - return; - } - std::thread::Builder::new() - .name("nectarlink-pc-audio-loopback".into()) - .spawn(|| { - run_loopback_worker(); - WORKER_RUNNING.store(false, Ordering::SeqCst); - }) - .ok(); -} + #[inline] + fn soft_limit(x: f32) -> f32 { + if !x.is_finite() { + return 0.0; + } + let boosted = x * 1.12; + (boosted / (1.0 + 0.18 * boosted.abs())).clamp(-1.0, 1.0) + } -fn run_loopback_worker() { - // SAFETY: Initialize COM and elevate thread priority for low-latency audio capture. - unsafe { - let _ = CoInitializeEx(None, COINIT_MULTITHREADED); - let _ = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL); - } + fn encode_stereo_f32(&mut self, floats: &[f32], channels: usize) -> Vec { + let frames = floats.len() / channels.max(1); + let mut out = Vec::with_capacity(frames * 4); + for frame in floats.chunks_exact(channels.max(1)) { + let l_in = if frame[0].is_finite() { frame[0] } else { 0.0 }; + let r_in = if channels > 1 && frame[1].is_finite() { frame[1] } else { l_in }; + let (l1, r1) = self.low_shelf.process_stereo(l_in, r_in); + let (l2, r2) = self.high_shelf.process_stereo(l1, r1); + let l_i16 = (Self::soft_limit(l2) * 32767.0).round() as i16; + let r_i16 = (Self::soft_limit(r2) * 32767.0).round() as i16; + out.extend_from_slice(&l_i16.to_le_bytes()); + out.extend_from_slice(&r_i16.to_le_bytes()); + } + out + } - while is_enabled() { - if let Err(e) = capture_and_stream_once() { - tracing::debug!(error = %e, "PC audio loopback stream cycle ended"); - std::thread::sleep(Duration::from_millis(500)); + fn encode_stereo_i16(&mut self, samples: &[i16], channels: usize) -> Vec { + let frames = samples.len() / channels.max(1); + let mut out = Vec::with_capacity(frames * 4); + for frame in samples.chunks_exact(channels.max(1)) { + let l_in = (frame[0] as f32) / 32768.0; + let r_in = if channels > 1 { (frame[1] as f32) / 32768.0 } else { l_in }; + let (l1, r1) = self.low_shelf.process_stereo(l_in, r_in); + let (l2, r2) = self.high_shelf.process_stereo(l1, r1); + let l_i16 = (Self::soft_limit(l2) * 32767.0).round() as i16; + let r_i16 = (Self::soft_limit(r2) * 32767.0).round() as i16; + out.extend_from_slice(&l_i16.to_le_bytes()); + out.extend_from_slice(&r_i16.to_le_bytes()); + } + out } } -} -fn capture_and_stream_once() -> Result<(), String> { - let node = core_host::node().ok_or_else(|| "core node not running".to_owned())?; - let peers: Vec<_> = node - .paired_devices() - .unwrap_or_default() - .into_iter() - .filter(|d| matches!(d.link, nectarlink_core::LinkState::Online { .. })) - .map(|d| d.id) - .collect(); - let wanted = *TARGET.lock().unwrap_or_else(|e| e.into_inner()); - let Some(&peer) = wanted.as_ref().filter(|w| peers.contains(w)).or_else(|| peers.first()) else { - std::thread::sleep(Duration::from_millis(500)); - return Ok(()); - }; + fn ensure_worker() { + if WORKER_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { + return; + } + std::thread::Builder::new() + .name("nectarlink-pc-audio-loopback".into()) + .spawn(|| { + run_loopback_worker(); + WORKER_RUNNING.store(false, Ordering::SeqCst); + }) + .ok(); + } - // SAFETY: WASAPI loopback initialization on the default render endpoint. - let (client, capture, rate, channels, bits_per_sample) = unsafe { - let enumerator: IMMDeviceEnumerator = - CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL).map_err(|e| e.to_string())?; - let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole).map_err(|e| e.to_string())?; - let client: IAudioClient = device.Activate(CLSCTX_ALL, None).map_err(|e| e.to_string())?; - let mix_ptr: *mut WAVEFORMATEX = client.GetMixFormat().map_err(|e| e.to_string())?; - if mix_ptr.is_null() { - return Err("GetMixFormat returned null".into()); + fn run_loopback_worker() { + // SAFETY: Initialize COM and elevate thread priority for low-latency audio capture. + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + let _ = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL); } - let mix = *mix_ptr; - let rate = mix.nSamplesPerSec.clamp(8_000, 96_000); - let channels = mix.nChannels.max(1) as usize; - let bits = mix.wBitsPerSample; - // 10 ms shared loopback buffer in 100-ns units = 100_000. - let init_res = client.Initialize( - AUDCLNT_SHAREMODE_SHARED, - AUDCLNT_STREAMFLAGS_LOOPBACK, - 100_000, - 0, - mix_ptr, - None, - ); - CoTaskMemFree(Some(mix_ptr.cast())); - init_res.map_err(|e| e.to_string())?; - let capture: IAudioCaptureClient = client.GetService().map_err(|e| e.to_string())?; - client.Start().map_err(|e| e.to_string())?; - (client, capture, rate, channels, bits) - }; - let mut enhancer = SpeakerEnhancer::new(rate); - let (tx, mut rx) = tokio::sync::mpsc::channel::<(u32, u8, Vec)>(64); - let send_node = node.clone(); - core_host::spawn(async move { - while let Some((r, ch, pcm)) = rx.recv().await { - // Slice into 960-byte (5 ms at 48 kHz stereo) sub-MTU chunks so each - // chunk travels in a single QUIC datagram without HOL blocking. - for chunk in pcm.chunks(960) { - if send_node.send_pc_audio_pcm(&peer, r, ch, chunk.to_vec()).await.is_err() { - return; - } + while is_enabled() { + if let Err(e) = capture_and_stream_once() { + tracing::debug!(error = %e, "PC audio loopback stream cycle ended"); + std::thread::sleep(Duration::from_millis(500)); } } - }); - - // Keep sending zero-filled PCM for ~500 ms after audible audio stops so brief - // pauses between words or beats never starve the phone's AudioTrack ringbuffer. - let mut silence_tail_packets: u32 = 0; - - 'capture: while is_enabled() { - std::thread::sleep(Duration::from_millis(2)); - // Another phone asked for the sound (it watches the PC's playback): - // start over for that one. - if target() != wanted && target().is_some_and(|t| peers.contains(&t)) { - break 'capture; - } - // SAFETY: Drain available capture packets from `IAudioCaptureClient`. - unsafe { - loop { - let Ok(packet_frames) = capture.GetNextPacketSize() else { break }; - if packet_frames == 0 { - break; - } - let mut data_ptr: *mut u8 = std::ptr::null_mut(); - let mut frames_to_read: u32 = 0; - let mut flags: u32 = 0; - if capture.GetBuffer(&mut data_ptr, &mut frames_to_read, &mut flags, None, None).is_err() { - break; + } + + fn capture_and_stream_once() -> Result<(), String> { + let node = core_host::node().ok_or_else(|| "core node not running".to_owned())?; + let peers: Vec<_> = node + .paired_devices() + .unwrap_or_default() + .into_iter() + .filter(|d| matches!(d.link, nectarlink_core::LinkState::Online { .. })) + .map(|d| d.id) + .collect(); + let wanted = *TARGET.lock().unwrap_or_else(|e| e.into_inner()); + let Some(&peer) = wanted.as_ref().filter(|w| peers.contains(w)).or_else(|| peers.first()) else { + std::thread::sleep(Duration::from_millis(500)); + return Ok(()); + }; + + // SAFETY: WASAPI loopback initialization on the default render endpoint. + let (client, capture, rate, channels, bits_per_sample) = unsafe { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL).map_err(|e| e.to_string())?; + let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole).map_err(|e| e.to_string())?; + let client: IAudioClient = device.Activate(CLSCTX_ALL, None).map_err(|e| e.to_string())?; + let mix_ptr: *mut WAVEFORMATEX = client.GetMixFormat().map_err(|e| e.to_string())?; + if mix_ptr.is_null() { + return Err("GetMixFormat returned null".into()); + } + let mix = *mix_ptr; + let rate = mix.nSamplesPerSec.clamp(8_000, 96_000); + let channels = mix.nChannels.max(1) as usize; + let bits = mix.wBitsPerSample; + // 10 ms shared loopback buffer in 100-ns units = 100_000. + let init_res = client.Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_LOOPBACK, + 100_000, + 0, + mix_ptr, + None, + ); + CoTaskMemFree(Some(mix_ptr.cast())); + init_res.map_err(|e| e.to_string())?; + let capture: IAudioCaptureClient = client.GetService().map_err(|e| e.to_string())?; + client.Start().map_err(|e| e.to_string())?; + (client, capture, rate, channels, bits) + }; + + let mut enhancer = SpeakerEnhancer::new(rate); + let (tx, mut rx) = tokio::sync::mpsc::channel::<(u32, u8, Vec)>(64); + let send_node = node.clone(); + core_host::spawn(async move { + while let Some((r, ch, pcm)) = rx.recv().await { + // Slice into 960-byte (5 ms at 48 kHz stereo) sub-MTU chunks so each + // chunk travels in a single QUIC datagram without HOL blocking. + for chunk in pcm.chunks(960) { + if send_node.send_pc_audio_pcm(&peer, r, ch, chunk.to_vec()).await.is_err() { + return; + } } - let silent = (flags & (AUDCLNT_BUFFERFLAGS_SILENT.0 as u32)) != 0 || data_ptr.is_null(); - let frames = frames_to_read as usize; - let pcm_bytes = if silent { - if silence_tail_packets > 0 { - silence_tail_packets -= 1; - vec![0u8; frames * 4] - } else { - Vec::new() + } + }); + + // Keep sending zero-filled PCM for ~500 ms after audible audio stops so brief + // pauses between words or beats never starve the phone's AudioTrack ringbuffer. + let mut silence_tail_packets: u32 = 0; + + 'capture: while is_enabled() { + std::thread::sleep(Duration::from_millis(2)); + // Another phone asked for the sound (it watches the PC's playback): + // start over for that one. + if target() != wanted && target().is_some_and(|t| peers.contains(&t)) { + break 'capture; + } + // SAFETY: Drain available capture packets from `IAudioCaptureClient`. + unsafe { + loop { + let Ok(packet_frames) = capture.GetNextPacketSize() else { break }; + if packet_frames == 0 { + break; } - } else if bits_per_sample == 32 { - let floats = std::slice::from_raw_parts(data_ptr.cast::(), frames * channels); - let has_signal = floats.iter().any(|&s| s.is_finite() && s.abs() > 1e-5); - if has_signal { - silence_tail_packets = 50; - enhancer.encode_stereo_f32(floats, channels) - } else if silence_tail_packets > 0 { - silence_tail_packets -= 1; - vec![0u8; frames * 4] - } else { - Vec::new() + let mut data_ptr: *mut u8 = std::ptr::null_mut(); + let mut frames_to_read: u32 = 0; + let mut flags: u32 = 0; + if capture.GetBuffer(&mut data_ptr, &mut frames_to_read, &mut flags, None, None).is_err() + { + break; } - } else if bits_per_sample == 16 { - let i16s = std::slice::from_raw_parts(data_ptr.cast::(), frames * channels); - let has_signal = i16s.iter().any(|&s| s.unsigned_abs() > 2); - if has_signal { - silence_tail_packets = 50; - enhancer.encode_stereo_i16(i16s, channels) - } else if silence_tail_packets > 0 { - silence_tail_packets -= 1; - vec![0u8; frames * 4] + let silent = (flags & (AUDCLNT_BUFFERFLAGS_SILENT.0 as u32)) != 0 || data_ptr.is_null(); + let frames = frames_to_read as usize; + let pcm_bytes = if silent { + if silence_tail_packets > 0 { + silence_tail_packets -= 1; + vec![0u8; frames * 4] + } else { + Vec::new() + } + } else if bits_per_sample == 32 { + let floats = std::slice::from_raw_parts(data_ptr.cast::(), frames * channels); + let has_signal = floats.iter().any(|&s| s.is_finite() && s.abs() > 1e-5); + if has_signal { + silence_tail_packets = 50; + enhancer.encode_stereo_f32(floats, channels) + } else if silence_tail_packets > 0 { + silence_tail_packets -= 1; + vec![0u8; frames * 4] + } else { + Vec::new() + } + } else if bits_per_sample == 16 { + let i16s = std::slice::from_raw_parts(data_ptr.cast::(), frames * channels); + let has_signal = i16s.iter().any(|&s| s.unsigned_abs() > 2); + if has_signal { + silence_tail_packets = 50; + enhancer.encode_stereo_i16(i16s, channels) + } else if silence_tail_packets > 0 { + silence_tail_packets -= 1; + vec![0u8; frames * 4] + } else { + Vec::new() + } } else { Vec::new() + }; + let _ = capture.ReleaseBuffer(frames_to_read); + + if !pcm_bytes.is_empty() + && matches!( + tx.try_send((rate, 2, pcm_bytes)), + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) + ) + { + break 'capture; } - } else { - Vec::new() - }; - let _ = capture.ReleaseBuffer(frames_to_read); - - if !pcm_bytes.is_empty() - && matches!( - tx.try_send((rate, 2, pcm_bytes)), - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) - ) - { - break 'capture; } } } + + // SAFETY: Stop the WASAPI client before dropping. + unsafe { + let _ = client.Stop(); + } + Ok(()) } - // SAFETY: Stop the WASAPI client before dropping. - unsafe { - let _ = client.Stop(); + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn f32_stereo_to_i16le_clamps_and_encodes_little_endian() { + let samples = [0.0_f32, 1.0, -1.0, 2.5, -3.0, f32::NAN]; + let bytes = f32_stereo_to_i16le(&samples); + assert_eq!(bytes.len(), samples.len() * 2); + let decoded: Vec = bytes.as_chunks::<2>().0.iter().map(|&c| i16::from_le_bytes(c)).collect(); + assert_eq!(decoded, vec![0, 32767, -32767, 32767, -32767, 0]); + } + + #[test] + fn set_enabled_toggles_atomic_state_cleanly() { + set_enabled(false); + assert!(!is_enabled()); + set_enabled(true); + assert!(is_enabled()); + set_enabled(false); + assert!(!is_enabled()); + } } - Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn f32_stereo_to_i16le_clamps_and_encodes_little_endian() { - let samples = [0.0_f32, 1.0, -1.0, 2.5, -3.0, f32::NAN]; - let bytes = f32_stereo_to_i16le(&samples); - assert_eq!(bytes.len(), samples.len() * 2); - let decoded: Vec = bytes.as_chunks::<2>().0.iter().map(|&c| i16::from_le_bytes(c)).collect(); - assert_eq!(decoded, vec![0, 32767, -32767, 32767, -32767, 0]); +#[cfg(not(windows))] +mod imp { + #![allow(dead_code)] + + pub fn target() -> Option { + None + } + + pub fn set_target(_phone: Option) {} + + pub fn is_enabled() -> bool { + false } - #[test] - fn set_enabled_toggles_atomic_state_cleanly() { - set_enabled(false); - assert!(!is_enabled()); - set_enabled(true); - assert!(is_enabled()); - set_enabled(false); - assert!(!is_enabled()); + pub fn set_enabled(_enabled: bool) {} + + pub fn f32_stereo_to_i16le(_samples: &[f32]) -> Vec { + Vec::new() } } + +pub use imp::*; diff --git a/desktop/app/src/pc_mirror.rs b/desktop/app/src/pc_mirror.rs index d6f7f6ca..218b4123 100644 --- a/desktop/app/src/pc_mirror.rs +++ b/desktop/app/src/pc_mirror.rs @@ -4,1044 +4,1103 @@ //! streams it over `mirror` / `video` (`session = 0`) to a paired phone, while //! injecting touch, scroll, key, and text input back into Windows (`docs/PLAN.md` §8.6). -#![allow(unsafe_code)] - -use std::{ - mem::{ManuallyDrop, size_of}, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, - time::{Duration, Instant}, -}; - -use nectarlink_core::{ - DeviceId, KeyMod, MirrorConfig, MirrorInput, MirrorSend, MirrorStart, PacketKind, mirror_keys, -}; -use windows::Win32::{ - Foundation::{POINT, RECT}, - Graphics::Gdi::{ - BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleBitmap, CreateCompatibleDC, DESKTOPHORZRES, - DESKTOPVERTRES, DeleteDC, DeleteObject, GetDC, GetDIBits, GetDeviceCaps, HALFTONE, ReleaseDC, - SRCCOPY, SelectObject, SetStretchBltMode, StretchBlt, - }, - Media::MediaFoundation::{ - CODECAPI_AVEncCommonMeanBitRate, CODECAPI_AVEncMPVGOPSize, CODECAPI_AVEncVideoForceKeyFrame, - CODECAPI_AVLowLatencyMode, ICodecAPI, IMFMediaBuffer, IMFMediaType, IMFSample, IMFTransform, - MF_E_NOTACCEPTING, MF_E_TRANSFORM_NEED_MORE_INPUT, MF_E_TRANSFORM_STREAM_CHANGE, MF_LOW_LATENCY, - MF_MT_AVG_BITRATE, MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, MF_MT_MAJOR_TYPE, - MF_MT_MPEG_SEQUENCE_HEADER, MF_MT_PIXEL_ASPECT_RATIO, MF_MT_SUBTYPE, MFCreateMediaType, - MFCreateMemoryBuffer, MFCreateSample, MFMediaType_Video, MFSampleExtension_CleanPoint, - MFT_CATEGORY_VIDEO_ENCODER, MFT_MESSAGE_COMMAND_FLUSH, MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, - MFT_MESSAGE_NOTIFY_END_STREAMING, MFT_MESSAGE_NOTIFY_START_OF_STREAM, MFT_OUTPUT_DATA_BUFFER, - MFT_OUTPUT_STREAM_PROVIDES_SAMPLES, MFVideoFormat_H264, MFVideoFormat_NV12, - MFVideoInterlace_Progressive, - }, - System::Variant::VARIANT, - UI::WindowsAndMessaging::{ - CURSOR_SHOWING, CURSORINFO, DI_NORMAL, DrawIconEx, GetCursorInfo, GetSystemMetrics, SM_CXSCREEN, - SM_CYSCREEN, - }, -}; - -use crate::{ - core_host, - win::{ - self, - h264::{create_mft, ensure_mf_started}, - }, -}; - -struct ActiveSession { - peer: DeviceId, - stop: Arc, - force_keyframe: Arc, - /// Play on phone: the phone watches a player, and its input is ignored. - watching: bool, -} +#[cfg(windows)] +mod imp { -static ACTIVE: Mutex> = Mutex::new(None); + #![allow(unsafe_code)] -/// How long Play on phone looks for the player's window before streaming -/// the screen instead. -const FIND_WINDOW: Duration = Duration::from_secs(5); + use std::{ + mem::{ManuallyDrop, size_of}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, + }; -/// The lowest bitrate the stream backs off to on a slow link. -const MIN_BITRATE: u32 = 600_000; + use nectarlink_core::{ + DeviceId, KeyMod, MirrorConfig, MirrorInput, MirrorSend, MirrorStart, PacketKind, mirror_keys, + }; + use windows::Win32::{ + Foundation::{POINT, RECT}, + Graphics::Gdi::{ + BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleBitmap, CreateCompatibleDC, DESKTOPHORZRES, + DESKTOPVERTRES, DeleteDC, DeleteObject, GetDC, GetDIBits, GetDeviceCaps, HALFTONE, ReleaseDC, + SRCCOPY, SelectObject, SetStretchBltMode, StretchBlt, + }, + Media::MediaFoundation::{ + CODECAPI_AVEncCommonMeanBitRate, CODECAPI_AVEncMPVGOPSize, CODECAPI_AVEncVideoForceKeyFrame, + CODECAPI_AVLowLatencyMode, ICodecAPI, IMFMediaBuffer, IMFMediaType, IMFSample, IMFTransform, + MF_E_NOTACCEPTING, MF_E_TRANSFORM_NEED_MORE_INPUT, MF_E_TRANSFORM_STREAM_CHANGE, MF_LOW_LATENCY, + MF_MT_AVG_BITRATE, MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, MF_MT_MAJOR_TYPE, + MF_MT_MPEG_SEQUENCE_HEADER, MF_MT_PIXEL_ASPECT_RATIO, MF_MT_SUBTYPE, MFCreateMediaType, + MFCreateMemoryBuffer, MFCreateSample, MFMediaType_Video, MFSampleExtension_CleanPoint, + MFT_CATEGORY_VIDEO_ENCODER, MFT_MESSAGE_COMMAND_FLUSH, MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, + MFT_MESSAGE_NOTIFY_END_STREAMING, MFT_MESSAGE_NOTIFY_START_OF_STREAM, MFT_OUTPUT_DATA_BUFFER, + MFT_OUTPUT_STREAM_PROVIDES_SAMPLES, MFVideoFormat_H264, MFVideoFormat_NV12, + MFVideoInterlace_Progressive, + }, + System::Variant::VARIANT, + UI::WindowsAndMessaging::{ + CURSOR_SHOWING, CURSORINFO, DI_NORMAL, DrawIconEx, GetCursorInfo, GetSystemMetrics, SM_CXSCREEN, + SM_CYSCREEN, + }, + }; -/// What a sharing session sent in the last stats window. -#[derive(Default)] -struct Stats { - frames: u64, - keyframes: u64, - bytes: u64, - dropped: u64, - rebuilds: u64, -} + use crate::{ + core_host, + win::{ + self, + h264::{create_mft, ensure_mf_started}, + }, + }; -/// Returns the device currently viewing this PC's screen, if any. -pub fn active_peer() -> Option { - ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|s| s.peer) -} + struct ActiveSession { + peer: DeviceId, + stop: Arc, + force_keyframe: Arc, + /// Play on phone: the phone watches a player, and its input is ignored. + watching: bool, + } -/// Whether this PC's screen is currently being shared with a phone. -pub fn is_active() -> bool { - active_peer().is_some() -} + static ACTIVE: Mutex> = Mutex::new(None); -/// Whether a phone watches one of this PC's players (Play on phone). -pub fn is_watching() -> bool { - ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|s| s.watching) -} + /// How long Play on phone looks for the player's window before streaming + /// the screen instead. + const FIND_WINDOW: Duration = Duration::from_secs(5); -/// Stops a phone watching one of this PC's players; screen sharing goes on. -pub fn stop_watching() { - let peer = - ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().filter(|s| s.watching).map(|s| s.peer); - if let Some(peer) = peer { - stop_for(&peer); + /// The lowest bitrate the stream backs off to on a slow link. + const MIN_BITRATE: u32 = 600_000; + + /// What a sharing session sent in the last stats window. + #[derive(Default)] + struct Stats { + frames: u64, + keyframes: u64, + bytes: u64, + dropped: u64, + rebuilds: u64, } -} -/// Starts or replaces the active PC-to-phone screen mirror stream for `peer`. -pub fn start(peer: DeviceId, options: MirrorStart) { - stop_all(); - let stop = Arc::new(AtomicBool::new(false)); - let force_keyframe = Arc::new(AtomicBool::new(false)); - { - let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); - *guard = Some(ActiveSession { - peer, - stop: Arc::clone(&stop), - force_keyframe: Arc::clone(&force_keyframe), - watching: options.watch.is_some(), - }); + /// Returns the device currently viewing this PC's screen, if any. + pub fn active_peer() -> Option { + ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|s| s.peer) } - core_host::host().hub.changed(crate::state::Changes::REMOTE); - core_host::refresh_capabilities(); - std::thread::Builder::new() - .name("nectarlink-pc-mirror".into()) - .spawn(move || run_capture_loop(peer, options, stop, force_keyframe)) - .ok(); -} + /// Whether this PC's screen is currently being shared with a phone. + pub fn is_active() -> bool { + active_peer().is_some() + } -/// Stops PC-to-phone mirroring if `peer` matches the active viewer. -pub fn stop_for(peer: &DeviceId) { - let stopped = { - let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); - if guard.as_ref().is_some_and(|s| &s.peer == peer) - && let Some(s) = guard.take() - { - s.stop.store(true, Ordering::SeqCst); - true - } else { - false - } - }; - if stopped { - win::input::release_held_buttons(); - crate::input_share::leave_remote(); - core_host::host().hub.changed(crate::state::Changes::REMOTE); - core_host::refresh_capabilities(); + /// Whether a phone watches one of this PC's players (Play on phone). + pub fn is_watching() -> bool { + ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|s| s.watching) } -} -/// Stops any active PC-to-phone mirroring session. -pub fn stop_all() { - let stopped = { - let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(s) = guard.take() { - s.stop.store(true, Ordering::SeqCst); - Some(s.peer) - } else { - None + /// Stops a phone watching one of this PC's players; screen sharing goes on. + pub fn stop_watching() { + let peer = + ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().filter(|s| s.watching).map(|s| s.peer); + if let Some(peer) = peer { + stop_for(&peer); } - }; - if let Some(peer) = stopped { - win::input::release_held_buttons(); - crate::input_share::leave_remote(); - if let Some(node) = core_host::node() { - node.set_pc_mirror_session_allowed(peer, false); - core_host::spawn(async move { - node.mirror_stop(peer, nectarlink_core::MIRROR_SCREEN).await; + } + + /// Starts or replaces the active PC-to-phone screen mirror stream for `peer`. + pub fn start(peer: DeviceId, options: MirrorStart) { + stop_all(); + let stop = Arc::new(AtomicBool::new(false)); + let force_keyframe = Arc::new(AtomicBool::new(false)); + { + let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(ActiveSession { + peer, + stop: Arc::clone(&stop), + force_keyframe: Arc::clone(&force_keyframe), + watching: options.watch.is_some(), }); } core_host::host().hub.changed(crate::state::Changes::REMOTE); core_host::refresh_capabilities(); - } -} -/// Requests an immediate IDR keyframe for `peer`. -pub fn request_keyframe(peer: &DeviceId) { - let guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(s) = guard.as_ref() - && &s.peer == peer - { - s.force_keyframe.store(true, Ordering::SeqCst); + std::thread::Builder::new() + .name("nectarlink-pc-mirror".into()) + .spawn(move || run_capture_loop(peer, options, stop, force_keyframe)) + .ok(); } -} -/// Injects touch, scroll, key, or text input from a phone viewing the PC screen. -pub fn handle_input(peer: &DeviceId, session: u32, input: MirrorInput) { - if session != nectarlink_core::MIRROR_SCREEN { - return; + /// Stops PC-to-phone mirroring if `peer` matches the active viewer. + pub fn stop_for(peer: &DeviceId) { + let stopped = { + let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); + if guard.as_ref().is_some_and(|s| &s.peer == peer) + && let Some(s) = guard.take() + { + s.stop.store(true, Ordering::SeqCst); + true + } else { + false + } + }; + if stopped { + win::input::release_held_buttons(); + crate::input_share::leave_remote(); + core_host::host().hub.changed(crate::state::Changes::REMOTE); + core_host::refresh_capabilities(); + } } - if active_peer() != Some(*peer) || is_watching() { - return; + + /// Stops any active PC-to-phone mirroring session. + pub fn stop_all() { + let stopped = { + let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(s) = guard.take() { + s.stop.store(true, Ordering::SeqCst); + Some(s.peer) + } else { + None + } + }; + if let Some(peer) = stopped { + win::input::release_held_buttons(); + crate::input_share::leave_remote(); + if let Some(node) = core_host::node() { + node.set_pc_mirror_session_allowed(peer, false); + core_host::spawn(async move { + node.mirror_stop(peer, nectarlink_core::MIRROR_SCREEN).await; + }); + } + core_host::host().hub.changed(crate::state::Changes::REMOTE); + core_host::refresh_capabilities(); + } } - let remote_input_on = core_host::node() - .and_then(|n| n.device_toggles(*peer).ok()) - .is_some_and(|toggles| toggles.iter().any(|(k, v)| *k == "remote_input" && *v)); - if !remote_input_on { - return; + + /// Requests an immediate IDR keyframe for `peer`. + pub fn request_keyframe(peer: &DeviceId) { + let guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(s) = guard.as_ref() + && &s.peer == peer + { + s.force_keyframe.store(true, Ordering::SeqCst); + } } - let _ = crate::input_share::enter_remote(*peer); - match input { - MirrorInput::Touch { action, x, y } => { - win::input::mirror_touch(action, x, y); + + /// Injects touch, scroll, key, or text input from a phone viewing the PC screen. + pub fn handle_input(peer: &DeviceId, session: u32, input: MirrorInput) { + if session != nectarlink_core::MIRROR_SCREEN { + return; } - MirrorInput::Scroll { x, y, dx, dy } => { - win::input::move_absolute(x, y); - win::input::scroll(dx, dy); + if active_peer() != Some(*peer) || is_watching() { + return; } - MirrorInput::Text { text } => { - win::input::type_text(&text); + let remote_input_on = core_host::node() + .and_then(|n| n.device_toggles(*peer).ok()) + .is_some_and(|toggles| toggles.iter().any(|(k, v)| *k == "remote_input" && *v)); + if !remote_input_on { + return; + } + let _ = crate::input_share::enter_remote(*peer); + match input { + MirrorInput::Touch { action, x, y } => { + win::input::mirror_touch(action, x, y); + } + MirrorInput::Scroll { x, y, dx, dy } => { + win::input::move_absolute(x, y); + win::input::scroll(dx, dy); + } + MirrorInput::Text { text } => { + win::input::type_text(&text); + } + MirrorInput::Key { key } => match key.as_str() { + mirror_keys::BACK => win::input::press_key("escape", &[]), + mirror_keys::HOME => win::input::press_key("a", &[KeyMod::Win]), + mirror_keys::RECENTS => win::input::press_key("task_view", &[]), + mirror_keys::ENTER => win::input::press_key("enter", &[]), + mirror_keys::BACKSPACE => win::input::press_key("backspace", &[]), + mirror_keys::DELETE => win::input::press_key("delete", &[]), + mirror_keys::LEFT => win::input::press_key("left", &[]), + mirror_keys::RIGHT => win::input::press_key("right", &[]), + mirror_keys::UP => win::input::press_key("up", &[]), + mirror_keys::DOWN => win::input::press_key("down", &[]), + mirror_keys::TAB => win::input::press_key("tab", &[]), + mirror_keys::NOTIFICATIONS => win::input::press_key("n", &[KeyMod::Win]), + _ => {} + }, + MirrorInput::Gamepad { .. } => {} } - MirrorInput::Key { key } => match key.as_str() { - mirror_keys::BACK => win::input::press_key("escape", &[]), - mirror_keys::HOME => win::input::press_key("a", &[KeyMod::Win]), - mirror_keys::RECENTS => win::input::press_key("task_view", &[]), - mirror_keys::ENTER => win::input::press_key("enter", &[]), - mirror_keys::BACKSPACE => win::input::press_key("backspace", &[]), - mirror_keys::DELETE => win::input::press_key("delete", &[]), - mirror_keys::LEFT => win::input::press_key("left", &[]), - mirror_keys::RIGHT => win::input::press_key("right", &[]), - mirror_keys::UP => win::input::press_key("up", &[]), - mirror_keys::DOWN => win::input::press_key("down", &[]), - mirror_keys::TAB => win::input::press_key("tab", &[]), - mirror_keys::NOTIFICATIONS => win::input::press_key("n", &[KeyMod::Win]), - _ => {} - }, - MirrorInput::Gamepad { .. } => {} } -} -pub fn on_event(event: &nectarlink_core::NodeEvent) { - match event { - nectarlink_core::NodeEvent::LinkChanged { - device, - link: nectarlink_core::LinkState::Offline { .. }, - } - | nectarlink_core::NodeEvent::DeviceRemoved(device) => { - stop_for(device); - } - nectarlink_core::NodeEvent::PcMirrorRequested { .. } => { - core_host::host().hub.update(|s| s.apply(event)); - crate::bridge::app::request_activation(); + pub fn on_event(event: &nectarlink_core::NodeEvent) { + match event { + nectarlink_core::NodeEvent::LinkChanged { + device, + link: nectarlink_core::LinkState::Offline { .. }, + } + | nectarlink_core::NodeEvent::DeviceRemoved(device) => { + stop_for(device); + } + nectarlink_core::NodeEvent::PcMirrorRequested { .. } => { + core_host::host().hub.update(|s| s.apply(event)); + crate::bridge::app::request_activation(); + } + _ => {} } - _ => {} } -} -/// Computes target encoder dimensions (multiples of 16) fitting within `max_size`. -pub(crate) fn target_dimensions(src_w: u32, src_h: u32, max_size: u32) -> (u32, u32) { - let max_edge = max_size.clamp(320, 1920) as f64; - let sw = src_w.max(64) as f64; - let sh = src_h.max(64) as f64; - let longest = sw.max(sh); - let scale = if longest > max_edge { max_edge / longest } else { 1.0 }; - let w = (((sw * scale).round() as u32) / 16).max(4) * 16; - let h = (((sh * scale).round() as u32) / 16).max(4) * 16; - (w, h) -} + /// Computes target encoder dimensions (multiples of 16) fitting within `max_size`. + pub(crate) fn target_dimensions(src_w: u32, src_h: u32, max_size: u32) -> (u32, u32) { + let max_edge = max_size.clamp(320, 1920) as f64; + let sw = src_w.max(64) as f64; + let sh = src_h.max(64) as f64; + let longest = sw.max(sh); + let scale = if longest > max_edge { max_edge / longest } else { 1.0 }; + let w = (((sw * scale).round() as u32) / 16).max(4) * 16; + let h = (((sh * scale).round() as u32) / 16).max(4) * 16; + (w, h) + } -/// Converts a top-down 32-bit BGRA image (`width * height * 4` bytes) into -/// BT.709 limited-range NV12 (`width * height * 3 / 2` bytes). -pub(crate) fn bgra_to_nv12(bgra: &[u8], width: usize, height: usize, nv12: &mut Vec) { - let y_size = width * height; - let uv_size = y_size / 2; - nv12.resize(y_size + uv_size, 0); - let (y_plane, uv_plane) = nv12.split_at_mut(y_size); - - for row in 0..height { - let src_row = row * width * 4; - let y_row = row * width; - for col in 0..width { - let px = src_row + col * 4; - let b = i32::from(bgra[px]); - let g = i32::from(bgra[px + 1]); - let r = i32::from(bgra[px + 2]); - let y = ((47 * r + 157 * g + 16 * b + 128) >> 8) + 16; - y_plane[y_row + col] = y.clamp(16, 235) as u8; + /// Converts a top-down 32-bit BGRA image (`width * height * 4` bytes) into + /// BT.709 limited-range NV12 (`width * height * 3 / 2` bytes). + pub(crate) fn bgra_to_nv12(bgra: &[u8], width: usize, height: usize, nv12: &mut Vec) { + let y_size = width * height; + let uv_size = y_size / 2; + nv12.resize(y_size + uv_size, 0); + let (y_plane, uv_plane) = nv12.split_at_mut(y_size); + + for row in 0..height { + let src_row = row * width * 4; + let y_row = row * width; + for col in 0..width { + let px = src_row + col * 4; + let b = i32::from(bgra[px]); + let g = i32::from(bgra[px + 1]); + let r = i32::from(bgra[px + 2]); + let y = ((47 * r + 157 * g + 16 * b + 128) >> 8) + 16; + y_plane[y_row + col] = y.clamp(16, 235) as u8; + } } - } - for row in (0..height).step_by(2) { - let src0 = row * width * 4; - let src1 = (row + 1).min(height - 1) * width * 4; - let uv_row = (row / 2) * width; - for col in (0..width).step_by(2) { - let p00 = src0 + col * 4; - let p01 = src0 + (col + 1).min(width - 1) * 4; - let p10 = src1 + col * 4; - let p11 = src1 + (col + 1).min(width - 1) * 4; - - let b = (i32::from(bgra[p00]) - + i32::from(bgra[p01]) - + i32::from(bgra[p10]) - + i32::from(bgra[p11]) - + 2) - >> 2; - let g = (i32::from(bgra[p00 + 1]) - + i32::from(bgra[p01 + 1]) - + i32::from(bgra[p10 + 1]) - + i32::from(bgra[p11 + 1]) - + 2) - >> 2; - let r = (i32::from(bgra[p00 + 2]) - + i32::from(bgra[p01 + 2]) - + i32::from(bgra[p10 + 2]) - + i32::from(bgra[p11 + 2]) - + 2) - >> 2; - - let u = ((-26 * r - 87 * g + 112 * b + 128) >> 8) + 128; - let v = ((112 * r - 102 * g - 10 * b + 128) >> 8) + 128; - uv_plane[uv_row + col] = u.clamp(16, 240) as u8; - uv_plane[uv_row + col + 1] = v.clamp(16, 240) as u8; + for row in (0..height).step_by(2) { + let src0 = row * width * 4; + let src1 = (row + 1).min(height - 1) * width * 4; + let uv_row = (row / 2) * width; + for col in (0..width).step_by(2) { + let p00 = src0 + col * 4; + let p01 = src0 + (col + 1).min(width - 1) * 4; + let p10 = src1 + col * 4; + let p11 = src1 + (col + 1).min(width - 1) * 4; + + let b = (i32::from(bgra[p00]) + + i32::from(bgra[p01]) + + i32::from(bgra[p10]) + + i32::from(bgra[p11]) + + 2) + >> 2; + let g = (i32::from(bgra[p00 + 1]) + + i32::from(bgra[p01 + 1]) + + i32::from(bgra[p10 + 1]) + + i32::from(bgra[p11 + 1]) + + 2) + >> 2; + let r = (i32::from(bgra[p00 + 2]) + + i32::from(bgra[p01 + 2]) + + i32::from(bgra[p10 + 2]) + + i32::from(bgra[p11 + 2]) + + 2) + >> 2; + + let u = ((-26 * r - 87 * g + 112 * b + 128) >> 8) + 128; + let v = ((112 * r - 102 * g - 10 * b + 128) >> 8) + 128; + uv_plane[uv_row + col] = u.clamp(16, 240) as u8; + uv_plane[uv_row + col + 1] = v.clamp(16, 240) as u8; + } } } -} -pub(crate) struct H264Encoder { - transform: IMFTransform, - /// The encoder's settings interface, when it has one: keyframes on demand and live - /// bitrate changes without rebuilding the encoder. - codec_api: Option, - width: u32, - height: u32, - provides_samples: bool, - output_size: u32, - seq_header: Vec, - sent_first_frame: bool, -} + pub(crate) struct H264Encoder { + transform: IMFTransform, + /// The encoder's settings interface, when it has one: keyframes on demand and live + /// bitrate changes without rebuilding the encoder. + codec_api: Option, + width: u32, + height: u32, + provides_samples: bool, + output_size: u32, + seq_header: Vec, + sent_first_frame: bool, + } -impl Drop for H264Encoder { - fn drop(&mut self) { - // SAFETY: flushes and ends streaming on the MFT before drop. - unsafe { - let _ = self.transform.ProcessMessage(MFT_MESSAGE_COMMAND_FLUSH, 0); - let _ = self.transform.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0); + impl Drop for H264Encoder { + fn drop(&mut self) { + // SAFETY: flushes and ends streaming on the MFT before drop. + unsafe { + let _ = self.transform.ProcessMessage(MFT_MESSAGE_COMMAND_FLUSH, 0); + let _ = self.transform.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0); + } } } -} -impl H264Encoder { - pub(crate) fn new(width: u32, height: u32, fps: u32, bitrate: u32) -> windows::core::Result { - ensure_mf_started()?; - // SAFETY: Media Foundation COM calls with valid parameters. - unsafe { - let transform = create_mft(MFT_CATEGORY_VIDEO_ENCODER, MFVideoFormat_NV12, MFVideoFormat_H264)?; - if let Ok(attrs) = transform.GetAttributes() { - let _ = attrs.SetUINT32(&MF_LOW_LATENCY, 1); - } + impl H264Encoder { + pub(crate) fn new(width: u32, height: u32, fps: u32, bitrate: u32) -> windows::core::Result { + ensure_mf_started()?; + // SAFETY: Media Foundation COM calls with valid parameters. + unsafe { + let transform = + create_mft(MFT_CATEGORY_VIDEO_ENCODER, MFVideoFormat_NV12, MFVideoFormat_H264)?; + if let Ok(attrs) = transform.GetAttributes() { + let _ = attrs.SetUINT32(&MF_LOW_LATENCY, 1); + } - let out_type: IMFMediaType = MFCreateMediaType()?; - out_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?; - out_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?; - let _ = out_type.SetUINT32( - &windows::Win32::Media::MediaFoundation::MF_MT_MPEG2_PROFILE, - windows::Win32::Media::MediaFoundation::eAVEncH264VProfile_Base.0 as u32, - ); - out_type.SetUINT32(&MF_MT_AVG_BITRATE, bitrate.clamp(500_000, 20_000_000))?; - out_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?; - out_type.SetUINT64(&MF_MT_FRAME_SIZE, (u64::from(width) << 32) | u64::from(height))?; - out_type.SetUINT64(&MF_MT_FRAME_RATE, (u64::from(fps.max(1)) << 32) | 1)?; - out_type.SetUINT64(&MF_MT_PIXEL_ASPECT_RATIO, (1u64 << 32) | 1)?; - transform.SetOutputType(0, Some(&out_type), 0)?; - - let in_type: IMFMediaType = MFCreateMediaType()?; - in_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?; - in_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?; - in_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?; - in_type.SetUINT64(&MF_MT_FRAME_SIZE, (u64::from(width) << 32) | u64::from(height))?; - in_type.SetUINT64(&MF_MT_FRAME_RATE, (u64::from(fps.max(1)) << 32) | 1)?; - in_type.SetUINT64(&MF_MT_PIXEL_ASPECT_RATIO, (1u64 << 32) | 1)?; - transform.SetInputType(0, Some(&in_type), 0)?; - - let mut seq_header = Vec::new(); - if let Ok(cur_out) = transform.GetOutputCurrentType(0) - && let Ok(len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) - && len > 0 - { - let mut buf = vec![0u8; len as usize]; - if cur_out.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None).is_ok() { - seq_header = buf; + let out_type: IMFMediaType = MFCreateMediaType()?; + out_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?; + out_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?; + let _ = out_type.SetUINT32( + &windows::Win32::Media::MediaFoundation::MF_MT_MPEG2_PROFILE, + windows::Win32::Media::MediaFoundation::eAVEncH264VProfile_Base.0 as u32, + ); + out_type.SetUINT32(&MF_MT_AVG_BITRATE, bitrate.clamp(500_000, 20_000_000))?; + out_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?; + out_type.SetUINT64(&MF_MT_FRAME_SIZE, (u64::from(width) << 32) | u64::from(height))?; + out_type.SetUINT64(&MF_MT_FRAME_RATE, (u64::from(fps.max(1)) << 32) | 1)?; + out_type.SetUINT64(&MF_MT_PIXEL_ASPECT_RATIO, (1u64 << 32) | 1)?; + transform.SetOutputType(0, Some(&out_type), 0)?; + + let in_type: IMFMediaType = MFCreateMediaType()?; + in_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?; + in_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?; + in_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?; + in_type.SetUINT64(&MF_MT_FRAME_SIZE, (u64::from(width) << 32) | u64::from(height))?; + in_type.SetUINT64(&MF_MT_FRAME_RATE, (u64::from(fps.max(1)) << 32) | 1)?; + in_type.SetUINT64(&MF_MT_PIXEL_ASPECT_RATIO, (1u64 << 32) | 1)?; + transform.SetInputType(0, Some(&in_type), 0)?; + + let mut seq_header = Vec::new(); + if let Ok(cur_out) = transform.GetOutputCurrentType(0) + && let Ok(len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) + && len > 0 + { + let mut buf = vec![0u8; len as usize]; + if cur_out.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None).is_ok() { + seq_header = buf; + } } - } - let info = transform.GetOutputStreamInfo(0)?; - let provides_samples = (info.dwFlags & (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0 as u32)) != 0; - let output_size = info.cbSize.max(256 * 1024); + let info = transform.GetOutputStreamInfo(0)?; + let provides_samples = (info.dwFlags & (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0 as u32)) != 0; + let output_size = info.cbSize.max(256 * 1024); - // A keyframe every two seconds keeps late joiners and lost packets recoverable - // without rebuilding the encoder, and low-latency mode stops it holding frames back. - let codec_api: Option = windows::core::Interface::cast(&transform).ok(); - if let Some(api) = codec_api.as_ref() { - let _ = api.SetValue(&CODECAPI_AVLowLatencyMode, &VARIANT::from(true)); - let _ = api.SetValue(&CODECAPI_AVEncMPVGOPSize, &VARIANT::from(fps.max(1) * 2)); + // A keyframe every two seconds keeps late joiners and lost packets recoverable + // without rebuilding the encoder, and low-latency mode stops it holding frames back. + let codec_api: Option = windows::core::Interface::cast(&transform).ok(); + if let Some(api) = codec_api.as_ref() { + let _ = api.SetValue(&CODECAPI_AVLowLatencyMode, &VARIANT::from(true)); + let _ = api.SetValue(&CODECAPI_AVEncMPVGOPSize, &VARIANT::from(fps.max(1) * 2)); + } + + let _ = transform.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0); + let _ = transform.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0); + + Ok(Self { + transform, + codec_api, + width, + height, + provides_samples, + output_size, + seq_header, + sent_first_frame: false, + }) } + } - let _ = transform.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0); - let _ = transform.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0); - - Ok(Self { - transform, - codec_api, - width, - height, - provides_samples, - output_size, - seq_header, - sent_first_frame: false, + /// Asks for the next frame to be a keyframe. False when the encoder can't, so the + /// caller must rebuild it instead. + pub(crate) fn request_keyframe(&self) -> bool { + // SAFETY: ICodecAPI::SetValue with a valid GUID and VARIANT. + self.codec_api.as_ref().is_some_and(|api| { + unsafe { api.SetValue(&CODECAPI_AVEncVideoForceKeyFrame, &VARIANT::from(1u32)) }.is_ok() }) } - } - - /// Asks for the next frame to be a keyframe. False when the encoder can't, so the - /// caller must rebuild it instead. - pub(crate) fn request_keyframe(&self) -> bool { - // SAFETY: ICodecAPI::SetValue with a valid GUID and VARIANT. - self.codec_api.as_ref().is_some_and(|api| { - unsafe { api.SetValue(&CODECAPI_AVEncVideoForceKeyFrame, &VARIANT::from(1u32)) }.is_ok() - }) - } - - /// Changes the target bitrate in place. False when the encoder can't. - pub(crate) fn set_bitrate(&self, bitrate: u32) -> bool { - // SAFETY: ICodecAPI::SetValue with a valid GUID and VARIANT. - self.codec_api.as_ref().is_some_and(|api| { - unsafe { api.SetValue(&CODECAPI_AVEncCommonMeanBitRate, &VARIANT::from(bitrate)) }.is_ok() - }) - } - /// Feeds one NV12 frame (`width * height * 3 / 2` bytes) and drains encoded - /// Annex-B H.264 access units `(is_keyframe, bytes)`. - pub(crate) fn encode( - &mut self, - nv12: &[u8], - hns: i64, - dur_hns: i64, - ) -> windows::core::Result)>> { - let expected = (self.width as usize) * (self.height as usize) * 3 / 2; - if nv12.len() < expected { - return Ok(Vec::new()); + /// Changes the target bitrate in place. False when the encoder can't. + pub(crate) fn set_bitrate(&self, bitrate: u32) -> bool { + // SAFETY: ICodecAPI::SetValue with a valid GUID and VARIANT. + self.codec_api.as_ref().is_some_and(|api| { + unsafe { api.SetValue(&CODECAPI_AVEncCommonMeanBitRate, &VARIANT::from(bitrate)) }.is_ok() + }) } - // SAFETY: Media Foundation sample creation and MFT ProcessInput/ProcessOutput. - unsafe { - let sample = make_sample(&nv12[..expected], hns, dur_hns)?; - let mut out = Vec::new(); - match self.transform.ProcessInput(0, &sample, 0) { - Ok(()) => self.drain(&mut out)?, - Err(e) if e.code() == MF_E_NOTACCEPTING => { - self.drain(&mut out)?; - self.transform.ProcessInput(0, &sample, 0)?; - self.drain(&mut out)?; + + /// Feeds one NV12 frame (`width * height * 3 / 2` bytes) and drains encoded + /// Annex-B H.264 access units `(is_keyframe, bytes)`. + pub(crate) fn encode( + &mut self, + nv12: &[u8], + hns: i64, + dur_hns: i64, + ) -> windows::core::Result)>> { + let expected = (self.width as usize) * (self.height as usize) * 3 / 2; + if nv12.len() < expected { + return Ok(Vec::new()); + } + // SAFETY: Media Foundation sample creation and MFT ProcessInput/ProcessOutput. + unsafe { + let sample = make_sample(&nv12[..expected], hns, dur_hns)?; + let mut out = Vec::new(); + match self.transform.ProcessInput(0, &sample, 0) { + Ok(()) => self.drain(&mut out)?, + Err(e) if e.code() == MF_E_NOTACCEPTING => { + self.drain(&mut out)?; + self.transform.ProcessInput(0, &sample, 0)?; + self.drain(&mut out)?; + } + Err(e) => return Err(e), } - Err(e) => return Err(e), + Ok(out) } - Ok(out) } - } - unsafe fn drain(&mut self, out: &mut Vec<(bool, Vec)>) -> windows::core::Result<()> { - loop { - // SAFETY: MFT output draining with allocated or MFT-provided sample. - unsafe { - let allocated = if self.provides_samples { - None - } else { - let s: IMFSample = MFCreateSample()?; - let b: IMFMediaBuffer = MFCreateMemoryBuffer(self.output_size)?; - s.AddBuffer(&b)?; - Some(s) - }; - let mut data = MFT_OUTPUT_DATA_BUFFER { - dwStreamID: 0, - pSample: ManuallyDrop::new(allocated), - dwStatus: 0, - pEvents: ManuallyDrop::new(None), - }; - let mut status = 0u32; - let res = self.transform.ProcessOutput(0, std::slice::from_mut(&mut data), &mut status); - let sample = ManuallyDrop::take(&mut data.pSample); - let _ = ManuallyDrop::take(&mut data.pEvents); - - match res { - Ok(()) => { - if let Some(sample) = sample { - let clean = sample - .GetUINT32(&MFSampleExtension_CleanPoint) - .map(|v| v != 0) - .unwrap_or(false); - let buf: IMFMediaBuffer = sample.ConvertToContiguousBuffer()?; - let mut ptr = std::ptr::null_mut(); - let mut len = 0u32; - buf.Lock(&mut ptr, None, Some(&mut len))?; - let slice = std::slice::from_raw_parts(ptr, len as usize); - let is_idr = !self.sent_first_frame || clean || has_h264_idr(slice); - let mut bytes = Vec::new(); - if is_idr && !has_h264_sps(slice) { - if self.seq_header.is_empty() - && let Ok(cur_out) = self.transform.GetOutputCurrentType(0) - && let Ok(hdr_len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) - && hdr_len > 0 - { - let mut hdr = vec![0u8; hdr_len as usize]; - if cur_out.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut hdr, None).is_ok() { - self.seq_header = hdr; + unsafe fn drain(&mut self, out: &mut Vec<(bool, Vec)>) -> windows::core::Result<()> { + loop { + // SAFETY: MFT output draining with allocated or MFT-provided sample. + unsafe { + let allocated = if self.provides_samples { + None + } else { + let s: IMFSample = MFCreateSample()?; + let b: IMFMediaBuffer = MFCreateMemoryBuffer(self.output_size)?; + s.AddBuffer(&b)?; + Some(s) + }; + let mut data = MFT_OUTPUT_DATA_BUFFER { + dwStreamID: 0, + pSample: ManuallyDrop::new(allocated), + dwStatus: 0, + pEvents: ManuallyDrop::new(None), + }; + let mut status = 0u32; + let res = self.transform.ProcessOutput(0, std::slice::from_mut(&mut data), &mut status); + let sample = ManuallyDrop::take(&mut data.pSample); + let _ = ManuallyDrop::take(&mut data.pEvents); + + match res { + Ok(()) => { + if let Some(sample) = sample { + let clean = sample + .GetUINT32(&MFSampleExtension_CleanPoint) + .map(|v| v != 0) + .unwrap_or(false); + let buf: IMFMediaBuffer = sample.ConvertToContiguousBuffer()?; + let mut ptr = std::ptr::null_mut(); + let mut len = 0u32; + buf.Lock(&mut ptr, None, Some(&mut len))?; + let slice = std::slice::from_raw_parts(ptr, len as usize); + let is_idr = !self.sent_first_frame || clean || has_h264_idr(slice); + let mut bytes = Vec::new(); + if is_idr && !has_h264_sps(slice) { + if self.seq_header.is_empty() + && let Ok(cur_out) = self.transform.GetOutputCurrentType(0) + && let Ok(hdr_len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) + && hdr_len > 0 + { + let mut hdr = vec![0u8; hdr_len as usize]; + if cur_out + .GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut hdr, None) + .is_ok() + { + self.seq_header = hdr; + } + } + if !self.seq_header.is_empty() { + bytes.extend_from_slice(&self.seq_header); } } - if !self.seq_header.is_empty() { - bytes.extend_from_slice(&self.seq_header); + bytes.extend_from_slice(slice); + buf.Unlock()?; + if !bytes.is_empty() { + self.sent_first_frame = true; + out.push((is_idr, bytes)); } } - bytes.extend_from_slice(slice); - buf.Unlock()?; - if !bytes.is_empty() { - self.sent_first_frame = true; - out.push((is_idr, bytes)); - } } - } - Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => return Ok(()), - Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => { - if let Ok(cur_out) = self.transform.GetOutputAvailableType(0, 0) { - self.transform.SetOutputType(0, Some(&cur_out), 0)?; - if let Ok(len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) - && len > 0 - { - let mut buf = vec![0u8; len as usize]; - if cur_out.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None).is_ok() { - self.seq_header = buf; + Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => return Ok(()), + Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => { + if let Ok(cur_out) = self.transform.GetOutputAvailableType(0, 0) { + self.transform.SetOutputType(0, Some(&cur_out), 0)?; + if let Ok(len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) + && len > 0 + { + let mut buf = vec![0u8; len as usize]; + if cur_out.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None).is_ok() { + self.seq_header = buf; + } } } } + Err(e) => return Err(e), } - Err(e) => return Err(e), } } } } -} -unsafe fn make_sample(bytes: &[u8], hns: i64, dur_hns: i64) -> windows::core::Result { - // SAFETY: allocates an IMFMediaBuffer of `bytes.len()` and copies `bytes` into it. - unsafe { - let buf: IMFMediaBuffer = MFCreateMemoryBuffer(bytes.len() as u32)?; - let mut dst = std::ptr::null_mut(); - buf.Lock(&mut dst, None, None)?; - std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len()); - buf.Unlock()?; - buf.SetCurrentLength(bytes.len() as u32)?; - let sample: IMFSample = MFCreateSample()?; - sample.AddBuffer(&buf)?; - sample.SetSampleTime(hns)?; - sample.SetSampleDuration(dur_hns.max(1))?; - Ok(sample) + unsafe fn make_sample(bytes: &[u8], hns: i64, dur_hns: i64) -> windows::core::Result { + // SAFETY: allocates an IMFMediaBuffer of `bytes.len()` and copies `bytes` into it. + unsafe { + let buf: IMFMediaBuffer = MFCreateMemoryBuffer(bytes.len() as u32)?; + let mut dst = std::ptr::null_mut(); + buf.Lock(&mut dst, None, None)?; + std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len()); + buf.Unlock()?; + buf.SetCurrentLength(bytes.len() as u32)?; + let sample: IMFSample = MFCreateSample()?; + sample.AddBuffer(&buf)?; + sample.SetSampleTime(hns)?; + sample.SetSampleDuration(dur_hns.max(1))?; + Ok(sample) + } } -} -fn has_h264_idr(data: &[u8]) -> bool { - data.windows(4).any(|w| w[..3] == [0, 0, 1] && (w[3] & 0x1f) == 5) -} + fn has_h264_idr(data: &[u8]) -> bool { + data.windows(4).any(|w| w[..3] == [0, 0, 1] && (w[3] & 0x1f) == 5) + } -fn has_h264_sps(data: &[u8]) -> bool { - data.windows(4).any(|w| w[..3] == [0, 0, 1] && (w[3] & 0x1f) == 7) -} + fn has_h264_sps(data: &[u8]) -> bool { + data.windows(4).any(|w| w[..3] == [0, 0, 1] && (w[3] & 0x1f) == 7) + } -/// Captures the primary desktop monitor into `bgra` scaled to `(dst_w, dst_h)`, -/// with the pointer, and returns the current physical screen dimensions -/// `(src_w, src_h)`. With `region` (physical screen pixels), captures only -/// that, without the pointer (Play on phone), and returns its size. -fn capture_screen(region: Option, dst_w: u32, dst_h: u32, bgra: &mut Vec) -> Option<(u32, u32)> { - // SAFETY: Win32 GDI device context and bitmap calls with balanced cleanup. - unsafe { - let hdc_screen = GetDC(None); - if hdc_screen.is_invalid() { - return None; - } - let (src_x, src_y, src_w, src_h) = match region { - Some(r) => (r.left, r.top, (r.right - r.left).max(1) as u32, (r.bottom - r.top).max(1) as u32), - None => { - let phys_w = GetDeviceCaps(Some(hdc_screen), DESKTOPHORZRES); - let phys_h = GetDeviceCaps(Some(hdc_screen), DESKTOPVERTRES); - let w = if phys_w > 0 { phys_w } else { GetSystemMetrics(SM_CXSCREEN) }.max(1) as u32; - let h = if phys_h > 0 { phys_h } else { GetSystemMetrics(SM_CYSCREEN) }.max(1) as u32; - (0, 0, w, h) + /// Captures the primary desktop monitor into `bgra` scaled to `(dst_w, dst_h)`, + /// with the pointer, and returns the current physical screen dimensions + /// `(src_w, src_h)`. With `region` (physical screen pixels), captures only + /// that, without the pointer (Play on phone), and returns its size. + fn capture_screen( + region: Option, + dst_w: u32, + dst_h: u32, + bgra: &mut Vec, + ) -> Option<(u32, u32)> { + // SAFETY: Win32 GDI device context and bitmap calls with balanced cleanup. + unsafe { + let hdc_screen = GetDC(None); + if hdc_screen.is_invalid() { + return None; } - }; + let (src_x, src_y, src_w, src_h) = match region { + Some(r) => { + (r.left, r.top, (r.right - r.left).max(1) as u32, (r.bottom - r.top).max(1) as u32) + } + None => { + let phys_w = GetDeviceCaps(Some(hdc_screen), DESKTOPHORZRES); + let phys_h = GetDeviceCaps(Some(hdc_screen), DESKTOPVERTRES); + let w = if phys_w > 0 { phys_w } else { GetSystemMetrics(SM_CXSCREEN) }.max(1) as u32; + let h = if phys_h > 0 { phys_h } else { GetSystemMetrics(SM_CYSCREEN) }.max(1) as u32; + (0, 0, w, h) + } + }; - let hdc_mem = CreateCompatibleDC(Some(hdc_screen)); - if hdc_mem.is_invalid() { - let _ = ReleaseDC(None, hdc_screen); - return None; - } - let hbm = CreateCompatibleBitmap(hdc_screen, dst_w as i32, dst_h as i32); - if hbm.is_invalid() { - let _ = DeleteDC(hdc_mem); - let _ = ReleaseDC(None, hdc_screen); - return None; - } - let old = SelectObject(hdc_mem, hbm.into()); - let _ = SetStretchBltMode(hdc_mem, HALFTONE); - let ok = StretchBlt( - hdc_mem, - 0, - 0, - dst_w as i32, - dst_h as i32, - Some(hdc_screen), - src_x, - src_y, - src_w as i32, - src_h as i32, - SRCCOPY, - ) - .as_bool(); - - // Draw the mouse cursor at its scaled position so the phone user sees where they point. - let mut ci = CURSORINFO { - cbSize: size_of::() as u32, - flags: Default::default(), - hCursor: Default::default(), - ptScreenPos: POINT::default(), - }; - if region.is_none() && GetCursorInfo(&mut ci).is_ok() && (ci.flags.0 & CURSOR_SHOWING.0) != 0 { - let cx = ((ci.ptScreenPos.x as i64) * (dst_w as i64) / (src_w as i64)) as i32; - let cy = ((ci.ptScreenPos.y as i64) * (dst_h as i64) / (src_h as i64)) as i32; - let _ = DrawIconEx(hdc_mem, cx, cy, ci.hCursor.into(), 0, 0, 0, None, DI_NORMAL); - } + let hdc_mem = CreateCompatibleDC(Some(hdc_screen)); + if hdc_mem.is_invalid() { + let _ = ReleaseDC(None, hdc_screen); + return None; + } + let hbm = CreateCompatibleBitmap(hdc_screen, dst_w as i32, dst_h as i32); + if hbm.is_invalid() { + let _ = DeleteDC(hdc_mem); + let _ = ReleaseDC(None, hdc_screen); + return None; + } + let old = SelectObject(hdc_mem, hbm.into()); + let _ = SetStretchBltMode(hdc_mem, HALFTONE); + let ok = StretchBlt( + hdc_mem, + 0, + 0, + dst_w as i32, + dst_h as i32, + Some(hdc_screen), + src_x, + src_y, + src_w as i32, + src_h as i32, + SRCCOPY, + ) + .as_bool(); + + // Draw the mouse cursor at its scaled position so the phone user sees where they point. + let mut ci = CURSORINFO { + cbSize: size_of::() as u32, + flags: Default::default(), + hCursor: Default::default(), + ptScreenPos: POINT::default(), + }; + if region.is_none() && GetCursorInfo(&mut ci).is_ok() && (ci.flags.0 & CURSOR_SHOWING.0) != 0 { + let cx = ((ci.ptScreenPos.x as i64) * (dst_w as i64) / (src_w as i64)) as i32; + let cy = ((ci.ptScreenPos.y as i64) * (dst_h as i64) / (src_h as i64)) as i32; + let _ = DrawIconEx(hdc_mem, cx, cy, ci.hCursor.into(), 0, 0, 0, None, DI_NORMAL); + } - let needed = (dst_w as usize) * (dst_h as usize) * 4; - bgra.resize(needed, 0); - - let mut bmi = BITMAPINFO { - bmiHeader: BITMAPINFOHEADER { - biSize: size_of::() as u32, - biWidth: dst_w as i32, - biHeight: -(dst_h as i32), // Top-down DIB. - biPlanes: 1, - biBitCount: 32, - biCompression: BI_RGB.0, - biSizeImage: needed as u32, + let needed = (dst_w as usize) * (dst_h as usize) * 4; + bgra.resize(needed, 0); + + let mut bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: size_of::() as u32, + biWidth: dst_w as i32, + biHeight: -(dst_h as i32), // Top-down DIB. + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + biSizeImage: needed as u32, + ..Default::default() + }, ..Default::default() - }, - ..Default::default() - }; + }; + + let lines = GetDIBits( + hdc_mem, + hbm, + 0, + dst_h, + Some(bgra.as_mut_ptr().cast()), + &mut bmi, + windows::Win32::Graphics::Gdi::DIB_RGB_COLORS, + ); + + let _ = SelectObject(hdc_mem, old); + let _ = DeleteObject(hbm.into()); + let _ = DeleteDC(hdc_mem); + let _ = ReleaseDC(None, hdc_screen); - let lines = GetDIBits( - hdc_mem, - hbm, - 0, - dst_h, - Some(bgra.as_mut_ptr().cast()), - &mut bmi, - windows::Win32::Graphics::Gdi::DIB_RGB_COLORS, - ); - - let _ = SelectObject(hdc_mem, old); - let _ = DeleteObject(hbm.into()); - let _ = DeleteDC(hdc_mem); - let _ = ReleaseDC(None, hdc_screen); - - if ok && lines > 0 { Some((src_w, src_h)) } else { None } + if ok && lines > 0 { Some((src_w, src_h)) } else { None } + } } -} -fn run_capture_loop( - peer: DeviceId, - options: MirrorStart, - stop: Arc, - force_keyframe: Arc, -) { - let Some(node) = core_host::node() else { - cleanup_session(&stop); - return; - }; - let runtime = &core_host::host().runtime; - let Ok(sender) = runtime.block_on(node.mirror_open(peer)) else { - tracing::warn!(%peer, "couldn't open PC mirror stream to phone"); - cleanup_session(&stop); - return; - }; + fn run_capture_loop( + peer: DeviceId, + options: MirrorStart, + stop: Arc, + force_keyframe: Arc, + ) { + let Some(node) = core_host::node() else { + cleanup_session(&stop); + return; + }; + let runtime = &core_host::host().runtime; + let Ok(sender) = runtime.block_on(node.mirror_open(peer)) else { + tracing::warn!(%peer, "couldn't open PC mirror stream to phone"); + cleanup_session(&stop); + return; + }; - // Give the phone's PcMirrorView TextureView and MediaCodec a brief moment to - // mount before sending the initial MirrorConfig and IDR keyframe. - std::thread::sleep(Duration::from_millis(120)); - - let fps = options.fps.clamp(10, 60); - let max_bitrate = options.bitrate.clamp(500_000, 20_000_000); - // Lowered when the link can't keep up (frames dropped to hold the delay down), - // raised back once it has been clean for a while. - let mut bitrate = max_bitrate; - let frame_interval = Duration::from_micros(1_000_000 / u64::from(fps)); - let dur_hns = (10_000_000 / u64::from(fps)) as i64; - - let mut bgra = Vec::new(); - let mut nv12 = Vec::new(); - let mut stream_dims: Option<(u32, u32)> = None; - let mut encoder: Option = None; - let started = Instant::now(); - let mut last_reinit = Instant::now() - Duration::from_secs(5); - let mut last_drop: Option = None; - let mut last_rate_change = Instant::now(); - let mut stats = Stats::default(); - let mut stats_since = Instant::now(); - // Play on phone: the player's picture instead of the screen, and the - // PC's sound to that phone while it watches. The picture's place comes - // in physical pixels; so does this thread's capture. - if options.watch.is_some() { - win::browser::use_physical_pixels(); - } - let watched = options.watch.as_deref().map(crate::play_on_phone::Watched::follow); - let mut window_capture: Option = None; - let _sound = watched.is_some().then(|| SoundToPhone::start(peer)); - if watched.is_some() { - tracing::info!(%peer, fps, bitrate, "streaming a player to the phone"); - } else { - tracing::info!(%peer, fps, bitrate, "sharing this PC's screen"); - } + // Give the phone's PcMirrorView TextureView and MediaCodec a brief moment to + // mount before sending the initial MirrorConfig and IDR keyframe. + std::thread::sleep(Duration::from_millis(120)); - while !stop.load(Ordering::Relaxed) { - let tick_start = Instant::now(); + let fps = options.fps.clamp(10, 60); + let max_bitrate = options.bitrate.clamp(500_000, 20_000_000); + // Lowered when the link can't keep up (frames dropped to hold the delay down), + // raised back once it has been clean for a while. + let mut bitrate = max_bitrate; + let frame_interval = Duration::from_micros(1_000_000 / u64::from(fps)); + let dur_hns = (10_000_000 / u64::from(fps)) as i64; - let place = watched.as_ref().and_then(crate::play_on_phone::Watched::place); - if watched.is_some() && place.is_none() && started.elapsed() < FIND_WINDOW { - // Not found yet: nothing to show. - std::thread::sleep(frame_interval); - continue; + let mut bgra = Vec::new(); + let mut nv12 = Vec::new(); + let mut stream_dims: Option<(u32, u32)> = None; + let mut encoder: Option = None; + let started = Instant::now(); + let mut last_reinit = Instant::now() - Duration::from_secs(5); + let mut last_drop: Option = None; + let mut last_rate_change = Instant::now(); + let mut stats = Stats::default(); + let mut stats_since = Instant::now(); + // Play on phone: the player's picture instead of the screen, and the + // PC's sound to that phone while it watches. The picture's place comes + // in physical pixels; so does this thread's capture. + if options.watch.is_some() { + win::browser::use_physical_pixels(); } - if watched.is_some() && place.is_none() { - // The player's window can't be found. Not the screen instead: - // that would show the phone whatever else is on it. - tracing::info!(%peer, "can't find the player's window; stopping"); - crate::bridge::app::show_message(crate::i18n::tr!( - "PcMirror", - "Can't find the player's window. Play the video on this PC again, then turn on Play on phone." - )); - break; + let watched = options.watch.as_deref().map(crate::play_on_phone::Watched::follow); + let mut window_capture: Option = None; + let _sound = watched.is_some().then(|| SoundToPhone::start(peer)); + if watched.is_some() { + tracing::info!(%peer, fps, bitrate, "streaming a player to the phone"); + } else { + tracing::info!(%peer, fps, bitrate, "sharing this PC's screen"); } - if let Some(place) = place { - let r = place.on_screen; - let dims = - target_dimensions((r.right - r.left) as u32, (r.bottom - r.top) as u32, options.max_size); - if stream_dims != Some(dims) { - // A new size (the window moved to full screen, say): a new stream config. - stream_dims = Some(dims); - encoder = None; + + while !stop.load(Ordering::Relaxed) { + let tick_start = Instant::now(); + + let place = watched.as_ref().and_then(crate::play_on_phone::Watched::place); + if watched.is_some() && place.is_none() && started.elapsed() < FIND_WINDOW { + // Not found yet: nothing to show. + std::thread::sleep(frame_interval); + continue; } - // The window's own pixels, whatever is in front of it; the - // screen there when Windows can't capture the window. - if window_capture.as_ref().is_none_or(|c| c.window() != place.window()) { - window_capture = win::window_capture::WindowCapture::new(place.window()) - .inspect_err( - |e| tracing::info!(error = %e, "can't capture the window; capturing the screen"), - ) - .ok(); + if watched.is_some() && place.is_none() { + // The player's window can't be found. Not the screen instead: + // that would show the phone whatever else is on it. + tracing::info!(%peer, "can't find the player's window; stopping"); + crate::bridge::app::show_message(crate::i18n::tr!( + "PcMirror", + "Can't find the player's window. Play the video on this PC again, then turn on Play on phone." + )); + break; } - } - - let (dst_w, dst_h) = match stream_dims { - Some(dims) => dims, - None => { - let (src_w, src_h) = unsafe { - let hdc = GetDC(None); - let pw = if !hdc.is_invalid() { GetDeviceCaps(Some(hdc), DESKTOPHORZRES) } else { 0 }; - let ph = if !hdc.is_invalid() { GetDeviceCaps(Some(hdc), DESKTOPVERTRES) } else { 0 }; - if !hdc.is_invalid() { - let _ = ReleaseDC(None, hdc); - } - ( - if pw > 0 { pw as u32 } else { GetSystemMetrics(SM_CXSCREEN).max(640) as u32 }, - if ph > 0 { ph as u32 } else { GetSystemMetrics(SM_CYSCREEN).max(480) as u32 }, - ) - }; - let dims = target_dimensions(src_w, src_h, options.max_size); - stream_dims = Some(dims); - dims + if let Some(place) = place { + let r = place.on_screen; + let dims = + target_dimensions((r.right - r.left) as u32, (r.bottom - r.top) as u32, options.max_size); + if stream_dims != Some(dims) { + // A new size (the window moved to full screen, say): a new stream config. + stream_dims = Some(dims); + encoder = None; + } + // The window's own pixels, whatever is in front of it; the + // screen there when Windows can't capture the window. + if window_capture.as_ref().is_none_or(|c| c.window() != place.window()) { + window_capture = win::window_capture::WindowCapture::new(place.window()) + .inspect_err( + |e| tracing::info!(error = %e, "can't capture the window; capturing the screen"), + ) + .ok(); + } } - }; - // A keyframe on request comes from the running encoder. Rebuilding it (a new - // stream config plus a full keyframe) is the fallback for encoders that can't. - let requested_kf = force_keyframe.swap(false, Ordering::SeqCst); - let keyframe_in_place = requested_kf && encoder.as_ref().is_some_and(H264Encoder::request_keyframe); - let need_reinit = encoder.is_none() - || (requested_kf && !keyframe_in_place && last_reinit.elapsed() >= Duration::from_millis(1400)); - - if need_reinit { - match H264Encoder::new(dst_w, dst_h, fps, bitrate) { - Ok(enc) => { - let cfg = MirrorConfig { - codec: nectarlink_core::MIRROR_H264.into(), - width: dst_w, - height: dst_h, - session: nectarlink_core::MIRROR_SCREEN, + let (dst_w, dst_h) = match stream_dims { + Some(dims) => dims, + None => { + let (src_w, src_h) = unsafe { + let hdc = GetDC(None); + let pw = if !hdc.is_invalid() { GetDeviceCaps(Some(hdc), DESKTOPHORZRES) } else { 0 }; + let ph = if !hdc.is_invalid() { GetDeviceCaps(Some(hdc), DESKTOPVERTRES) } else { 0 }; + if !hdc.is_invalid() { + let _ = ReleaseDC(None, hdc); + } + ( + if pw > 0 { pw as u32 } else { GetSystemMetrics(SM_CXSCREEN).max(640) as u32 }, + if ph > 0 { ph as u32 } else { GetSystemMetrics(SM_CYSCREEN).max(480) as u32 }, + ) }; - if matches!(sender.send(PacketKind::Config, 0, cfg.to_cbor()), MirrorSend::Closed) { + let dims = target_dimensions(src_w, src_h, options.max_size); + stream_dims = Some(dims); + dims + } + }; + + // A keyframe on request comes from the running encoder. Rebuilding it (a new + // stream config plus a full keyframe) is the fallback for encoders that can't. + let requested_kf = force_keyframe.swap(false, Ordering::SeqCst); + let keyframe_in_place = + requested_kf && encoder.as_ref().is_some_and(H264Encoder::request_keyframe); + let need_reinit = encoder.is_none() + || (requested_kf + && !keyframe_in_place + && last_reinit.elapsed() >= Duration::from_millis(1400)); + + if need_reinit { + match H264Encoder::new(dst_w, dst_h, fps, bitrate) { + Ok(enc) => { + let cfg = MirrorConfig { + codec: nectarlink_core::MIRROR_H264.into(), + width: dst_w, + height: dst_h, + session: nectarlink_core::MIRROR_SCREEN, + }; + if matches!(sender.send(PacketKind::Config, 0, cfg.to_cbor()), MirrorSend::Closed) { + break; + } + encoder = Some(enc); + last_reinit = Instant::now(); + stats.rebuilds += 1; + } + Err(e) => { + tracing::warn!(error = %e, "PC mirror H.264 encoder init failed"); break; } - encoder = Some(enc); - last_reinit = Instant::now(); - stats.rebuilds += 1; - } - Err(e) => { - tracing::warn!(error = %e, "PC mirror H.264 encoder init failed"); - break; } } - } - let captured = match (place, window_capture.as_mut()) { - (Some(place), Some(capture)) => capture.capture(place.in_window, dst_w, dst_h, &mut bgra), - (Some(place), None) => capture_screen(Some(place.on_screen), dst_w, dst_h, &mut bgra).is_some(), - (None, _) => capture_screen(None, dst_w, dst_h, &mut bgra).is_some(), - }; - if captured && let Some(enc) = encoder.as_mut() { - bgra_to_nv12(&bgra, dst_w as usize, dst_h as usize, &mut nv12); - let time_us = started.elapsed().as_micros() as u64; - let hns = (time_us * 10) as i64; - match enc.encode(&nv12, hns, dur_hns) { - Ok(packets) => { - let mut send_failed = false; - for (is_keyframe, bytes) in packets { - let kind = if is_keyframe { PacketKind::Keyframe } else { PacketKind::Frame }; - let len = bytes.len(); - match sender.send(kind, time_us, bytes) { - MirrorSend::Queued => { - stats.frames += 1; - stats.bytes += len as u64; - if is_keyframe { - stats.keyframes += 1; + let captured = match (place, window_capture.as_mut()) { + (Some(place), Some(capture)) => capture.capture(place.in_window, dst_w, dst_h, &mut bgra), + (Some(place), None) => { + capture_screen(Some(place.on_screen), dst_w, dst_h, &mut bgra).is_some() + } + (None, _) => capture_screen(None, dst_w, dst_h, &mut bgra).is_some(), + }; + if captured && let Some(enc) = encoder.as_mut() { + bgra_to_nv12(&bgra, dst_w as usize, dst_h as usize, &mut nv12); + let time_us = started.elapsed().as_micros() as u64; + let hns = (time_us * 10) as i64; + match enc.encode(&nv12, hns, dur_hns) { + Ok(packets) => { + let mut send_failed = false; + for (is_keyframe, bytes) in packets { + let kind = if is_keyframe { PacketKind::Keyframe } else { PacketKind::Frame }; + let len = bytes.len(); + match sender.send(kind, time_us, bytes) { + MirrorSend::Queued => { + stats.frames += 1; + stats.bytes += len as u64; + if is_keyframe { + stats.keyframes += 1; + } } - } - MirrorSend::Dropped => stats.dropped += 1, - MirrorSend::NeedKeyframe => { - stats.dropped += 1; - last_drop = Some(Instant::now()); - force_keyframe.store(true, Ordering::SeqCst); - // The link is slower than the stream: ask for less, at most every 2 s. - if last_rate_change.elapsed() >= Duration::from_secs(2) { - let lower = (bitrate / 4 * 3).max(MIN_BITRATE); - if lower < bitrate && enc.set_bitrate(lower) { - bitrate = lower; - last_rate_change = Instant::now(); + MirrorSend::Dropped => stats.dropped += 1, + MirrorSend::NeedKeyframe => { + stats.dropped += 1; + last_drop = Some(Instant::now()); + force_keyframe.store(true, Ordering::SeqCst); + // The link is slower than the stream: ask for less, at most every 2 s. + if last_rate_change.elapsed() >= Duration::from_secs(2) { + let lower = (bitrate / 4 * 3).max(MIN_BITRATE); + if lower < bitrate && enc.set_bitrate(lower) { + bitrate = lower; + last_rate_change = Instant::now(); + } } } + MirrorSend::Closed => { + send_failed = true; + break; + } } - MirrorSend::Closed => { - send_failed = true; - break; + } + if send_failed { + break; + } + // Clean for 10 s: step the bitrate back up toward what the phone asked for. + let clean = last_drop.is_none_or(|t| t.elapsed() >= Duration::from_secs(10)); + if clean + && bitrate < max_bitrate + && last_rate_change.elapsed() >= Duration::from_secs(10) + { + let higher = (bitrate / 4 * 5).min(max_bitrate); + if enc.set_bitrate(higher) { + bitrate = higher; + last_rate_change = Instant::now(); } } } - if send_failed { - break; - } - // Clean for 10 s: step the bitrate back up toward what the phone asked for. - let clean = last_drop.is_none_or(|t| t.elapsed() >= Duration::from_secs(10)); - if clean && bitrate < max_bitrate && last_rate_change.elapsed() >= Duration::from_secs(10) - { - let higher = (bitrate / 4 * 5).min(max_bitrate); - if enc.set_bitrate(higher) { - bitrate = higher; - last_rate_change = Instant::now(); - } + Err(e) => { + tracing::warn!(error = %e, "PC mirror frame encode error; rebuilding the encoder"); + encoder = None; } } - Err(e) => { - tracing::warn!(error = %e, "PC mirror frame encode error; rebuilding the encoder"); - encoder = None; - } } - } - if stats_since.elapsed() >= Duration::from_secs(10) { - let secs = stats_since.elapsed().as_secs_f64(); - tracing::info!( - %peer, - fps = format!("{:.1}", stats.frames as f64 / secs), - kbps = (stats.bytes as f64 * 8.0 / secs / 1000.0) as u64, - keyframes = stats.keyframes, - dropped = stats.dropped, - rebuilds = stats.rebuilds, - bitrate, - "sharing this PC's screen" - ); - stats = Stats::default(); - stats_since = Instant::now(); - } + if stats_since.elapsed() >= Duration::from_secs(10) { + let secs = stats_since.elapsed().as_secs_f64(); + tracing::info!( + %peer, + fps = format!("{:.1}", stats.frames as f64 / secs), + kbps = (stats.bytes as f64 * 8.0 / secs / 1000.0) as u64, + keyframes = stats.keyframes, + dropped = stats.dropped, + rebuilds = stats.rebuilds, + bitrate, + "sharing this PC's screen" + ); + stats = Stats::default(); + stats_since = Instant::now(); + } - let elapsed = tick_start.elapsed(); - if let Some(rem) = frame_interval.checked_sub(elapsed) { - std::thread::sleep(rem); + let elapsed = tick_start.elapsed(); + if let Some(rem) = frame_interval.checked_sub(elapsed) { + std::thread::sleep(rem); + } } - } - tracing::info!(%peer, "stopped sharing this PC's screen"); - crate::input_share::leave_remote(); - sender.close(); - cleanup_session(&stop); -} - -/// Mute this PC's speakers while a phone watches (the setting). -static MUTE_WHILE_WATCHED: AtomicBool = AtomicBool::new(true); + tracing::info!(%peer, "stopped sharing this PC's screen"); + crate::input_share::leave_remote(); + sender.close(); + cleanup_session(&stop); + } -pub fn set_mute_while_watched(on: bool) { - MUTE_WHILE_WATCHED.store(on, Ordering::Relaxed); -} + /// Mute this PC's speakers while a phone watches (the setting). + static MUTE_WHILE_WATCHED: AtomicBool = AtomicBool::new(true); -/// The PC's sound going to a phone that watches one of its players (as -/// Phone speaker sends it), with the PC's speakers muted if the user wants -/// (capturing the sound doesn't depend on them), for as long as this lives; -/// then as before. -struct SoundToPhone { - was_on: bool, - was_target: Option, - /// This muted the speakers (they weren't already). - muted: bool, -} + pub fn set_mute_while_watched(on: bool) { + MUTE_WHILE_WATCHED.store(on, Ordering::Relaxed); + } -impl SoundToPhone { - fn start(peer: DeviceId) -> SoundToPhone { - let muted = MUTE_WHILE_WATCHED.load(Ordering::Relaxed) - && win::audio::speaker_state().is_some_and(|(_, muted)| !muted) - && win::audio::set_speaker_mute(true).is_ok(); - let sound = SoundToPhone { - was_on: crate::pc_audio_stream::is_enabled(), - was_target: crate::pc_audio_stream::target(), - muted, - }; - crate::pc_audio_stream::set_target(Some(peer)); - crate::pc_audio_stream::set_enabled(true); - sound + /// The PC's sound going to a phone that watches one of its players (as + /// Phone speaker sends it), with the PC's speakers muted if the user wants + /// (capturing the sound doesn't depend on them), for as long as this lives; + /// then as before. + struct SoundToPhone { + was_on: bool, + was_target: Option, + /// This muted the speakers (they weren't already). + muted: bool, } -} -impl Drop for SoundToPhone { - fn drop(&mut self) { - crate::pc_audio_stream::set_target(self.was_target); - crate::pc_audio_stream::set_enabled(self.was_on); - // Unmuted only if this muted it. - if self.muted { - let _ = win::audio::set_speaker_mute(false); + impl SoundToPhone { + fn start(peer: DeviceId) -> SoundToPhone { + let muted = MUTE_WHILE_WATCHED.load(Ordering::Relaxed) + && win::audio::speaker_state().is_some_and(|(_, muted)| !muted) + && win::audio::set_speaker_mute(true).is_ok(); + let sound = SoundToPhone { + was_on: crate::pc_audio_stream::is_enabled(), + was_target: crate::pc_audio_stream::target(), + muted, + }; + crate::pc_audio_stream::set_target(Some(peer)); + crate::pc_audio_stream::set_enabled(true); + sound } } -} -fn cleanup_session(stop: &Arc) { - let removed = { - let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); - if guard.as_ref().is_some_and(|s| Arc::ptr_eq(&s.stop, stop)) { - guard.take().is_some() - } else { - false + impl Drop for SoundToPhone { + fn drop(&mut self) { + crate::pc_audio_stream::set_target(self.was_target); + crate::pc_audio_stream::set_enabled(self.was_on); + // Unmuted only if this muted it. + if self.muted { + let _ = win::audio::set_speaker_mute(false); + } } - }; - if removed { - core_host::host().hub.changed(crate::state::Changes::REMOTE); - core_host::refresh_capabilities(); } -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encodes_nv12_frames_and_decodes_back_with_mft() { - let (w, h) = target_dimensions(1920, 1080, 1280); - assert_eq!(w % 16, 0); - assert_eq!(h % 16, 0); - - let mut enc = H264Encoder::new(w, h, 30, 4_000_000).expect("H264Encoder MFT"); - let mut dec = crate::win::h264::Decoder::new_for_codec("h264").expect("H264 Decoder MFT"); - - let mut bgra = vec![0u8; (w as usize) * (h as usize) * 4]; - for (i, px) in bgra.as_chunks_mut::<4>().0.iter_mut().enumerate() { - px[0] = (i % 255) as u8; - px[1] = ((i / 7) % 255) as u8; - px[2] = ((i / 13) % 255) as u8; - px[3] = 255; + fn cleanup_session(stop: &Arc) { + let removed = { + let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); + if guard.as_ref().is_some_and(|s| Arc::ptr_eq(&s.stop, stop)) { + guard.take().is_some() + } else { + false + } + }; + if removed { + core_host::host().hub.changed(crate::state::Changes::REMOTE); + core_host::refresh_capabilities(); } - let mut nv12 = Vec::new(); - bgra_to_nv12(&bgra, w as usize, h as usize, &mut nv12); - - let mut decoded_any = false; - let mut produced_packets = 0usize; - for i in 0..8 { - let packets = enc.encode(&nv12, i * 333_333, 333_333).expect("encode frame"); - for (_keyframe, data) in packets { - produced_packets += 1; - let pics = dec.decode(&data, (i as u64) * 33_333).expect("decode packet"); - if let Some(pic) = pics.first() { - assert_eq!(pic.width, w); - assert_eq!(pic.height, h); - decoded_any = true; + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn encodes_nv12_frames_and_decodes_back_with_mft() { + let (w, h) = target_dimensions(1920, 1080, 1280); + assert_eq!(w % 16, 0); + assert_eq!(h % 16, 0); + + let mut enc = H264Encoder::new(w, h, 30, 4_000_000).expect("H264Encoder MFT"); + let mut dec = crate::win::h264::Decoder::new_for_codec("h264").expect("H264 Decoder MFT"); + + let mut bgra = vec![0u8; (w as usize) * (h as usize) * 4]; + for (i, px) in bgra.as_chunks_mut::<4>().0.iter_mut().enumerate() { + px[0] = (i % 255) as u8; + px[1] = ((i / 7) % 255) as u8; + px[2] = ((i / 13) % 255) as u8; + px[3] = 255; + } + let mut nv12 = Vec::new(); + bgra_to_nv12(&bgra, w as usize, h as usize, &mut nv12); + + let mut decoded_any = false; + let mut produced_packets = 0usize; + for i in 0..8 { + let packets = enc.encode(&nv12, i * 333_333, 333_333).expect("encode frame"); + for (_keyframe, data) in packets { + produced_packets += 1; + let pics = dec.decode(&data, (i as u64) * 33_333).expect("decode packet"); + if let Some(pic) = pics.first() { + assert_eq!(pic.width, w); + assert_eq!(pic.height, h); + decoded_any = true; + } } } + assert!(produced_packets > 0, "encoder must emit H.264 access units"); + assert!(decoded_any, "decoder must reconstruct at least one frame from H264Encoder"); + } + + /// With a Chromium browser showing a video on a page whose title has + /// `NECTARLINK_TEST_TAB` in it, in view: captures just the video, as + /// Play on phone does. + #[test] + #[ignore] + fn captures_a_browsers_video() { + use crate::win::browser::{Automation, window_for}; + let wanted = std::env::var("NECTARLINK_TEST_TAB").expect("NECTARLINK_TEST_TAB"); + // SAFETY: COM for this test's thread. + let _ = unsafe { + windows::Win32::System::Com::CoInitializeEx( + None, + windows::Win32::System::Com::COINIT_MULTITHREADED, + ) + }; + crate::win::browser::use_physical_pixels(); + let window = window_for("Chrome", &wanted).expect("the test tab's window"); + let rect = Automation::new().unwrap().video_rect(&window).expect("the video"); + let (sw, sh) = ((rect.right - rect.left) as u32, (rect.bottom - rect.top) as u32); + let (w, h) = target_dimensions(sw, sh, 1280); + let mut bgra = Vec::new(); + assert_eq!(capture_screen(Some(rect), w, h, &mut bgra), Some((sw, sh))); + let distinct: std::collections::HashSet<&[u8; 4]> = bgra.as_chunks::<4>().0.iter().collect(); + eprintln!("{rect:?} as {w}x{h}: {} colors", distinct.len()); + assert!(distinct.len() > 50, "a moving test pattern, not a blank"); + } + + #[test] + fn keyframes_come_on_request_without_rebuilding_the_encoder() { + let (w, h) = target_dimensions(1280, 720, 1280); + let mut enc = H264Encoder::new(w, h, 30, 2_000_000).expect("H264Encoder MFT"); + let mut nv12 = Vec::new(); + bgra_to_nv12(&vec![128u8; (w as usize) * (h as usize) * 4], w as usize, h as usize, &mut nv12); + + let keyframes_after = |enc: &mut H264Encoder, start: i64, count: i64| { + (start..start + count) + .flat_map(|i| enc.encode(&nv12, i * 333_333, 333_333).expect("encode frame")) + .filter(|(keyframe, data)| *keyframe && has_h264_idr(data)) + .count() + }; + // The first frame is a keyframe; the next few (well inside the 2 s GOP) are not. + assert_eq!(keyframes_after(&mut enc, 0, 1), 1, "the stream starts with a keyframe"); + assert_eq!(keyframes_after(&mut enc, 1, 5), 0, "no keyframes mid-GOP unprompted"); + assert!(enc.request_keyframe(), "the Windows H.264 encoder takes keyframe requests"); + assert_eq!(keyframes_after(&mut enc, 6, 2), 1, "a requested keyframe arrives promptly"); + assert!(enc.set_bitrate(1_000_000), "the bitrate changes in place"); } - assert!(produced_packets > 0, "encoder must emit H.264 access units"); - assert!(decoded_any, "decoder must reconstruct at least one frame from H264Encoder"); } +} - /// With a Chromium browser showing a video on a page whose title has - /// `NECTARLINK_TEST_TAB` in it, in view: captures just the video, as - /// Play on phone does. - #[test] - #[ignore] - fn captures_a_browsers_video() { - use crate::win::browser::{Automation, window_for}; - let wanted = std::env::var("NECTARLINK_TEST_TAB").expect("NECTARLINK_TEST_TAB"); - // SAFETY: COM for this test's thread. - let _ = unsafe { - windows::Win32::System::Com::CoInitializeEx( - None, - windows::Win32::System::Com::COINIT_MULTITHREADED, - ) - }; - crate::win::browser::use_physical_pixels(); - let window = window_for("Chrome", &wanted).expect("the test tab's window"); - let rect = Automation::new().unwrap().video_rect(&window).expect("the video"); - let (sw, sh) = ((rect.right - rect.left) as u32, (rect.bottom - rect.top) as u32); - let (w, h) = target_dimensions(sw, sh, 1280); - let mut bgra = Vec::new(); - assert_eq!(capture_screen(Some(rect), w, h, &mut bgra), Some((sw, sh))); - let distinct: std::collections::HashSet<&[u8; 4]> = bgra.as_chunks::<4>().0.iter().collect(); - eprintln!("{rect:?} as {w}x{h}: {} colors", distinct.len()); - assert!(distinct.len() > 50, "a moving test pattern, not a blank"); +#[cfg(not(windows))] +mod imp { + #![allow(dead_code)] + + use nectarlink_core::{DeviceId, MirrorInput, MirrorStart, NodeEvent}; + + pub fn active_peer() -> Option { + None } - #[test] - fn keyframes_come_on_request_without_rebuilding_the_encoder() { - let (w, h) = target_dimensions(1280, 720, 1280); - let mut enc = H264Encoder::new(w, h, 30, 2_000_000).expect("H264Encoder MFT"); - let mut nv12 = Vec::new(); - bgra_to_nv12(&vec![128u8; (w as usize) * (h as usize) * 4], w as usize, h as usize, &mut nv12); + pub fn is_active() -> bool { + false + } - let keyframes_after = |enc: &mut H264Encoder, start: i64, count: i64| { - (start..start + count) - .flat_map(|i| enc.encode(&nv12, i * 333_333, 333_333).expect("encode frame")) - .filter(|(keyframe, data)| *keyframe && has_h264_idr(data)) - .count() - }; - // The first frame is a keyframe; the next few (well inside the 2 s GOP) are not. - assert_eq!(keyframes_after(&mut enc, 0, 1), 1, "the stream starts with a keyframe"); - assert_eq!(keyframes_after(&mut enc, 1, 5), 0, "no keyframes mid-GOP unprompted"); - assert!(enc.request_keyframe(), "the Windows H.264 encoder takes keyframe requests"); - assert_eq!(keyframes_after(&mut enc, 6, 2), 1, "a requested keyframe arrives promptly"); - assert!(enc.set_bitrate(1_000_000), "the bitrate changes in place"); + pub fn is_watching() -> bool { + false } + + pub fn stop_watching() {} + + pub fn start(_peer: DeviceId, _options: MirrorStart) {} + + pub fn stop_for(_peer: &DeviceId) {} + + pub fn stop_all() {} + + pub fn request_keyframe(_peer: &DeviceId) {} + + pub fn handle_input(_peer: &DeviceId, _session: u32, _input: MirrorInput) {} + + pub fn on_event(_event: &NodeEvent) {} + + pub fn set_mute_while_watched(_on: bool) {} } + +pub use imp::*; diff --git a/desktop/app/src/play_on_phone.rs b/desktop/app/src/play_on_phone.rs index 55fd7565..79386b6f 100644 --- a/desktop/app/src/play_on_phone.rs +++ b/desktop/app/src/play_on_phone.rs @@ -4,347 +4,411 @@ //! the phone in its app and the PC pauses; anything else the phone watches //! streamed from the PC, which keeps playing it. -#![allow(unsafe_code)] - -use crate::i18n::tr; -use std::{ - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, - time::{Duration, Instant}, -}; - -use nectarlink_core::{DeviceId, Error, FeatureState, LinkState, MediaAction, PlayOnPhone}; -use windows::Win32::{ - Foundation::{HWND, RECT}, - System::Com::{COINIT_MULTITHREADED, CoInitializeEx}, -}; - -use crate::{ - bridge::app::{describe, show_message}, - core_host, - win::browser::{self, Automation, Window}, -}; - -/// The phone to play on: the first one online that can watch a PC's -/// playback, else the first one online. -pub fn phone() -> Option<(DeviceId, String)> { - core_host::host().hub.read(|s| { - let online: Vec<_> = - s.devices.iter().filter(|d| matches!(d.link, LinkState::Online { .. })).collect(); - let watches = |id: &DeviceId| { - s.matrices.get(id).and_then(|m| m.state("media.play_on_phone")) == Some(FeatureState::Available) - }; - online.iter().find(|d| watches(&d.id)).or_else(|| online.first()).map(|d| (d.id, d.info.name.clone())) - }) -} +#[cfg(windows)] +mod imp { + + #![allow(unsafe_code)] + + use crate::i18n::tr; + use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, + }; + + use nectarlink_core::{DeviceId, Error, FeatureState, LinkState, MediaAction, PlayOnPhone}; + use windows::Win32::{ + Foundation::{HWND, RECT}, + System::Com::{COINIT_MULTITHREADED, CoInitializeEx}, + }; + + use crate::{ + bridge::app::{describe, show_message}, + core_host, + win::browser::{self, Automation, Window}, + }; -/// Apps (by player ID or name) that play music, never a video worth -/// watching on a phone, unless they say otherwise. -const MUSIC_APPS: &[&str] = &[ - "spotify", - "applemusic", - "itunes", - "zunemusic", - "foobar2000", - "musicbee", - "aimp", - "winamp", - "tidal", - "deezer", - "amazonmusic", - "youtube music", - "soundcloud", -]; - -/// Whether a player looks like it plays a video: it says so, or it is -/// anything but a music app (browsers say "music" for every video). -pub(crate) fn plays_video(id: &str, app: &str, says_video: bool) -> bool { - if says_video { - return true; + /// The phone to play on: the first one online that can watch a PC's + /// playback, else the first one online. + pub fn phone() -> Option<(DeviceId, String)> { + core_host::host().hub.read(|s| { + let online: Vec<_> = + s.devices.iter().filter(|d| matches!(d.link, LinkState::Online { .. })).collect(); + let watches = |id: &DeviceId| { + s.matrices.get(id).and_then(|m| m.state("media.play_on_phone")) + == Some(FeatureState::Available) + }; + online + .iter() + .find(|d| watches(&d.id)) + .or_else(|| online.first()) + .map(|d| (d.id, d.info.name.clone())) + }) } - let (id, app) = (id.to_ascii_lowercase(), app.to_ascii_lowercase()); - !MUSIC_APPS.iter().any(|m| id.contains(m) || app.contains(m)) -} -/// The player of this PC's to offer for Play on phone: the first one playing -/// a video. -pub fn watchable() -> Option { - crate::media::local_views() - .into_iter() - .map(|v| v.player) - .find(|p| p.playing && plays_video(&p.id, &p.app, crate::win::media_sessions::says_video(&p.id))) - .map(|p| p.id) -} + /// Apps (by player ID or name) that play music, never a video worth + /// watching on a phone, unless they say otherwise. + const MUSIC_APPS: &[&str] = &[ + "spotify", + "applemusic", + "itunes", + "zunemusic", + "foobar2000", + "musicbee", + "aimp", + "winamp", + "tidal", + "deezer", + "amazonmusic", + "youtube music", + "soundcloud", + ]; -/// When a phone was last asked to watch (its notification is up). -static ASKED: Mutex> = Mutex::new(None); -/// How long a phone has to accept before the toggle gives up waiting. -const ASK_FOR: Duration = Duration::from_secs(60); + /// Whether a player looks like it plays a video: it says so, or it is + /// anything but a music app (browsers say "music" for every video). + pub(crate) fn plays_video(id: &str, app: &str, says_video: bool) -> bool { + if says_video { + return true; + } + let (id, app) = (id.to_ascii_lowercase(), app.to_ascii_lowercase()); + !MUSIC_APPS.iter().any(|m| id.contains(m) || app.contains(m)) + } -/// Where Play on phone is: `"off"`, `"asking"` (the phone's notification -/// is up) or `"watching"`. -pub fn state() -> &'static str { - if crate::pc_mirror::is_watching() { - *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = None; - return "watching"; + /// The player of this PC's to offer for Play on phone: the first one playing + /// a video. + pub fn watchable() -> Option { + crate::media::local_views() + .into_iter() + .map(|v| v.player) + .find(|p| p.playing && plays_video(&p.id, &p.app, crate::win::media_sessions::says_video(&p.id))) + .map(|p| p.id) } - match *ASKED.lock().unwrap_or_else(|e| e.into_inner()) { - Some(at) if at.elapsed() < ASK_FOR => "asking", - _ => "off", + + /// When a phone was last asked to watch (its notification is up). + static ASKED: Mutex> = Mutex::new(None); + /// How long a phone has to accept before the toggle gives up waiting. + const ASK_FOR: Duration = Duration::from_secs(60); + + /// Where Play on phone is: `"off"`, `"asking"` (the phone's notification + /// is up) or `"watching"`. + pub fn state() -> &'static str { + if crate::pc_mirror::is_watching() { + *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = None; + return "watching"; + } + match *ASKED.lock().unwrap_or_else(|e| e.into_inner()) { + Some(at) if at.elapsed() < ASK_FOR => "asking", + _ => "off", + } } -} -fn asked() { - *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); - core_host::host().hub.changed(crate::state::Changes::MEDIA); - // The toggle stops waiting when the phone doesn't answer. - core_host::spawn(async { - tokio::time::sleep(ASK_FOR).await; + fn asked() { + *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); core_host::host().hub.changed(crate::state::Changes::MEDIA); - }); -} + // The toggle stops waiting when the phone doesn't answer. + core_host::spawn(async { + tokio::time::sleep(ASK_FOR).await; + core_host::host().hub.changed(crate::state::Changes::MEDIA); + }); + } -/// The Play on phone toggle: on plays the video playing now on the phone; -/// off stops the phone watching (or waiting to). -pub fn set_on(on: bool) { - if on { - match watchable() { - Some(player) => play(player), - None => core_host::host().hub.changed(crate::state::Changes::MEDIA), + /// The Play on phone toggle: on plays the video playing now on the phone; + /// off stops the phone watching (or waiting to). + pub fn set_on(on: bool) { + if on { + match watchable() { + Some(player) => play(player), + None => core_host::host().hub.changed(crate::state::Changes::MEDIA), + } + return; } - return; + *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = None; + crate::pc_mirror::stop_watching(); + core_host::host().hub.changed(crate::state::Changes::MEDIA); } - *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = None; - crate::pc_mirror::stop_watching(); - core_host::host().hub.changed(crate::state::Changes::MEDIA); -} -/// What this PC's `player` plays: its title, and where it is, in seconds. -fn now_playing(player: &str) -> (String, Option) { - let views = crate::media::local_views(); - let Some(view) = views.into_iter().find(|v| v.player.id == player) else { - return (String::new(), None); - }; - let position = view.player.position.map(|ms| { - let since = - if view.player.playing { view.at.elapsed().map_or(0, |d| d.as_millis() as u64) } else { 0 }; - ((ms + since) / 1000) as u32 - }); - (view.player.title.unwrap_or_default(), position.filter(|&s| s > 0)) -} + /// What this PC's `player` plays: its title, and where it is, in seconds. + fn now_playing(player: &str) -> (String, Option) { + let views = crate::media::local_views(); + let Some(view) = views.into_iter().find(|v| v.player.id == player) else { + return (String::new(), None); + }; + let position = view.player.position.map(|ms| { + let since = + if view.player.playing { view.at.elapsed().map_or(0, |d| d.as_millis() as u64) } else { 0 }; + ((ms + since) / 1000) as u32 + }); + (view.player.title.unwrap_or_default(), position.filter(|&s| s > 0)) + } -/// Plays what this PC's `player` plays on a phone (the Play on phone -/// button on the PC's media card). -pub fn play(player: String) { - let Some((phone, name)) = phone() else { - return show_message(tr!("PlayOnPhone", "Connect your phone to play on it.")); - }; - let spawned = std::thread::Builder::new().name("play-on-phone".into()).spawn(move || { - // SAFETY: COM for this short-lived thread (UI Automation). - let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; - let (title, position) = now_playing(&player); - let window = browser::window_for(&player, &title); - // A video file in a desktop player: the phone plays the file itself. - let file = crate::media_file::file_for(&player, &title); - let url = window - .as_ref() - .filter(|w| browser::is_browser(&w.program)) - .and_then(|w| Automation::new().ok()?.page_url(w)); - let plan = nectarlink_core::play_on_phone::plan(url.as_deref(), position); - let Some(node) = core_host::node() else { return }; - core_host::spawn(async move { - if let Some(file) = file { - let shown = (!title.is_empty()).then_some(title); - let at = u64::from(position.unwrap_or(0)) * 1000; - match node.offer_watch_file(phone, player, shown, file, at).await { - Ok(()) => { - asked(); - show_message(tr!("PlayOnPhone", "Tap the notification on %1 to watch.", name)); - } - Err(e) => show_message(failed(&e, &name)), - } - return; - } - match plan { - PlayOnPhone::Open { url, service } => match node.open_link(phone, url).await { - Ok(()) => { - crate::media::command(crate::media::LOCAL_DEVICE, player, MediaAction::Pause, None); - show_message(tr!( - "PlayOnPhone", - "Opening in %1 on %2. Tap the notification there.", - service, - name - )); - } - Err(e) => show_message(failed(&e, &name)), - }, - PlayOnPhone::Stream { open_instead } => { + /// Plays what this PC's `player` plays on a phone (the Play on phone + /// button on the PC's media card). + pub fn play(player: String) { + let Some((phone, name)) = phone() else { + return show_message(tr!("PlayOnPhone", "Connect your phone to play on it.")); + }; + let spawned = std::thread::Builder::new().name("play-on-phone".into()).spawn(move || { + // SAFETY: COM for this short-lived thread (UI Automation). + let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + let (title, position) = now_playing(&player); + let window = browser::window_for(&player, &title); + // A video file in a desktop player: the phone plays the file itself. + let file = crate::media_file::file_for(&player, &title); + let url = window + .as_ref() + .filter(|w| browser::is_browser(&w.program)) + .and_then(|w| Automation::new().ok()?.page_url(w)); + let plan = nectarlink_core::play_on_phone::plan(url.as_deref(), position); + let Some(node) = core_host::node() else { return }; + core_host::spawn(async move { + if let Some(file) = file { let shown = (!title.is_empty()).then_some(title); - match node.offer_watch(phone, player.clone(), shown).await { + let at = u64::from(position.unwrap_or(0)) * 1000; + match node.offer_watch_file(phone, player, shown, file, at).await { Ok(()) => { asked(); - show_message(tr!("PlayOnPhone", "Tap the notification on %1 to watch.", name)) + show_message(tr!("PlayOnPhone", "Tap the notification on %1 to watch.", name)); } - // A phone that can't watch yet: the page opens there instead. - Err(Error::Unsupported) if let Some(url) = open_instead => { - match node.open_link(phone, url).await { - Ok(()) => { - crate::media::command( - crate::media::LOCAL_DEVICE, - player, - MediaAction::Pause, - None, - ); - show_message(tr!( - "PlayOnPhone", - "Sent to %1. Tap the notification there.", - name - )); + Err(e) => show_message(failed(&e, &name)), + } + return; + } + match plan { + PlayOnPhone::Open { url, service } => match node.open_link(phone, url).await { + Ok(()) => { + crate::media::command( + crate::media::LOCAL_DEVICE, + player, + MediaAction::Pause, + None, + ); + show_message(tr!( + "PlayOnPhone", + "Opening in %1 on %2. Tap the notification there.", + service, + name + )); + } + Err(e) => show_message(failed(&e, &name)), + }, + PlayOnPhone::Stream { open_instead } => { + let shown = (!title.is_empty()).then_some(title); + match node.offer_watch(phone, player.clone(), shown).await { + Ok(()) => { + asked(); + show_message(tr!("PlayOnPhone", "Tap the notification on %1 to watch.", name)) + } + // A phone that can't watch yet: the page opens there instead. + Err(Error::Unsupported) if let Some(url) = open_instead => { + match node.open_link(phone, url).await { + Ok(()) => { + crate::media::command( + crate::media::LOCAL_DEVICE, + player, + MediaAction::Pause, + None, + ); + show_message(tr!( + "PlayOnPhone", + "Sent to %1. Tap the notification there.", + name + )); + } + Err(e) => show_message(failed(&e, &name)), } - Err(e) => show_message(failed(&e, &name)), } + Err(e) => show_message(failed(&e, &name)), } - Err(e) => show_message(failed(&e, &name)), } } - } + }); }); - }); - if let Err(e) = spawned { - tracing::warn!(error = %e, "can't start Play on phone"); + if let Err(e) = spawned { + tracing::warn!(error = %e, "can't start Play on phone"); + } } -} -fn failed(error: &Error, phone: &str) -> String { - match error { - Error::Unsupported => tr!("PlayOnPhone", "Update NectarLink on %1 to play on it.", phone), - Error::Denied => tr!("PlayOnPhone", "Media is turned off for %1.", phone), - other => describe(other), + fn failed(error: &Error, phone: &str) -> String { + match error { + Error::Unsupported => tr!("PlayOnPhone", "Update NectarLink on %1 to play on it.", phone), + Error::Denied => tr!("PlayOnPhone", "Media is turned off for %1.", phone), + other => describe(other), + } } -} -/// Where a watched player's picture is, kept up to date while a phone -/// watches it: the video moves when the page scrolls, the window moves or -/// goes full screen. -pub struct Watched { - place: Arc>>, - stop: Arc, -} + /// Where a watched player's picture is, kept up to date while a phone + /// watches it: the video moves when the page scrolls, the window moves or + /// goes full screen. + pub struct Watched { + place: Arc>>, + stop: Arc, + } -/// A watched player's picture: its window, and where in it the picture is. -#[derive(Debug, Clone, Copy)] -pub struct Place { - /// The window's handle (as a number: handles go between threads). - window: isize, - /// The picture, in physical screen pixels. - pub on_screen: RECT, - /// The picture, in the window's own pixels (from its frame's top-left), - /// as a capture of the window has it. - pub in_window: RECT, -} + /// A watched player's picture: its window, and where in it the picture is. + #[derive(Debug, Clone, Copy)] + pub struct Place { + /// The window's handle (as a number: handles go between threads). + window: isize, + /// The picture, in physical screen pixels. + pub on_screen: RECT, + /// The picture, in the window's own pixels (from its frame's top-left), + /// as a capture of the window has it. + pub in_window: RECT, + } -/// How often the picture's place is looked up again. -const FOLLOW_EVERY: Duration = Duration::from_millis(500); -/// How long a browser's player may take to show up in UI Automation -/// before the whole page is streamed instead. -const FIND_PLAYER: Duration = Duration::from_secs(2); + /// How often the picture's place is looked up again. + const FOLLOW_EVERY: Duration = Duration::from_millis(500); + /// How long a browser's player may take to show up in UI Automation + /// before the whole page is streamed instead. + const FIND_PLAYER: Duration = Duration::from_secs(2); -impl Place { - /// The window the picture is in. - pub fn window(&self) -> HWND { - HWND(self.window as *mut core::ffi::c_void) + impl Place { + /// The window the picture is in. + pub fn window(&self) -> HWND { + HWND(self.window as *mut core::ffi::c_void) + } } -} -impl Watched { - /// Starts following `player`'s picture. - pub fn follow(player: &str) -> Watched { - let place = Arc::new(Mutex::new(None)); - let stop = Arc::new(AtomicBool::new(false)); - let (found, stopped, player) = (place.clone(), stop.clone(), player.to_owned()); - let spawned = std::thread::Builder::new().name("play-on-phone-follow".into()).spawn(move || { - // SAFETY: COM for this thread, which ends with the watching. - let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; - // Places in the pixels the capture uses. - browser::use_physical_pixels(); - let automation = Automation::new().ok(); - let mut window: Option = None; - let started = std::time::Instant::now(); - while !stopped.load(Ordering::Relaxed) { - // The window it played in, while it's still there; else find it again. - if window.as_ref().is_none_or(|w| browser::frame_of(w.hwnd).is_none()) { - let (title, _) = now_playing(&player); - window = browser::window_for(&player, &title); - } - // Minimized, it draws nothing and the phone's picture stops: - // back behind the other windows, it plays on out of the way. - if let Some(w) = window.as_ref().filter(|w| browser::is_minimized(w.hwnd)) { - tracing::info!( - "the watched player's window was minimized; restoring it behind the others" - ); - browser::restore_behind(w.hwnd); + impl Watched { + /// Starts following `player`'s picture. + pub fn follow(player: &str) -> Watched { + let place = Arc::new(Mutex::new(None)); + let stop = Arc::new(AtomicBool::new(false)); + let (found, stopped, player) = (place.clone(), stop.clone(), player.to_owned()); + let spawned = std::thread::Builder::new().name("play-on-phone-follow".into()).spawn(move || { + // SAFETY: COM for this thread, which ends with the watching. + let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + // Places in the pixels the capture uses. + browser::use_physical_pixels(); + let automation = Automation::new().ok(); + let mut window: Option = None; + let started = std::time::Instant::now(); + while !stopped.load(Ordering::Relaxed) { + // The window it played in, while it's still there; else find it again. + if window.as_ref().is_none_or(|w| browser::frame_of(w.hwnd).is_none()) { + let (title, _) = now_playing(&player); + window = browser::window_for(&player, &title); + } + // Minimized, it draws nothing and the phone's picture stops: + // back behind the other windows, it plays on out of the way. + if let Some(w) = window.as_ref().filter(|w| browser::is_minimized(w.hwnd)) { + tracing::info!( + "the watched player's window was minimized; restoring it behind the others" + ); + browser::restore_behind(w.hwnd); + std::thread::sleep(FOLLOW_EVERY); + continue; + } + let place = window.as_ref().and_then(|w| { + let frame = browser::frame_of(w.hwnd)?; + let on_screen = match &automation { + // A browser builds what UI Automation sees of a page + // only once asked: give the player a moment to show + // up before streaming the whole page. + Some(a) if browser::is_browser(&w.program) => match a.player_rect(w) { + Some(player) => player, + None if started.elapsed() < FIND_PLAYER => return None, + None => a.page_rect(w).unwrap_or(frame), + }, + _ => frame, + }; + let in_window = RECT { + left: on_screen.left - frame.left, + top: on_screen.top - frame.top, + right: on_screen.right - frame.left, + bottom: on_screen.bottom - frame.top, + }; + Some(Place { window: w.hwnd.0 as isize, on_screen, in_window }) + }); + if place.is_some() { + *found.lock().unwrap_or_else(|e| e.into_inner()) = place; + } std::thread::sleep(FOLLOW_EVERY); - continue; - } - let place = window.as_ref().and_then(|w| { - let frame = browser::frame_of(w.hwnd)?; - let on_screen = match &automation { - // A browser builds what UI Automation sees of a page - // only once asked: give the player a moment to show - // up before streaming the whole page. - Some(a) if browser::is_browser(&w.program) => match a.player_rect(w) { - Some(player) => player, - None if started.elapsed() < FIND_PLAYER => return None, - None => a.page_rect(w).unwrap_or(frame), - }, - _ => frame, - }; - let in_window = RECT { - left: on_screen.left - frame.left, - top: on_screen.top - frame.top, - right: on_screen.right - frame.left, - bottom: on_screen.bottom - frame.top, - }; - Some(Place { window: w.hwnd.0 as isize, on_screen, in_window }) - }); - if place.is_some() { - *found.lock().unwrap_or_else(|e| e.into_inner()) = place; } - std::thread::sleep(FOLLOW_EVERY); + }); + if let Err(e) = spawned { + tracing::warn!(error = %e, "can't follow the watched player"); } - }); - if let Err(e) = spawned { - tracing::warn!(error = %e, "can't follow the watched player"); + Watched { place, stop } + } + + /// Where the picture is now, once known. + pub fn place(&self) -> Option { + *self.place.lock().unwrap_or_else(|e| e.into_inner()) } - Watched { place, stop } } - /// Where the picture is now, once known. - pub fn place(&self) -> Option { - *self.place.lock().unwrap_or_else(|e| e.into_inner()) + impl Drop for Watched { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } } -} -impl Drop for Watched { - fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); + #[cfg(test)] + mod tests { + use super::plays_video; + + #[test] + fn music_apps_are_not_offered_unless_they_say_video() { + assert!(plays_video("chrome", "YouTube", false), "a browser's video says music"); + assert!(plays_video("F0DC299D809B9700", "Zen", false)); + assert!(plays_video(r"C:/Program Files/VideoLAN/VLC/vlc.exe", "vlc", false)); + assert!(!plays_video("Spotify.exe", "Spotify", false)); + assert!(!plays_video("chrome", "YouTube Music", false), "named by its site"); + assert!(!plays_video( + "Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic", + "Media Player", + false + )); + assert!(plays_video( + "Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic", + "Media Player", + true + )); + } } } -#[cfg(test)] -mod tests { - use super::plays_video; - - #[test] - fn music_apps_are_not_offered_unless_they_say_video() { - assert!(plays_video("chrome", "YouTube", false), "a browser's video says music"); - assert!(plays_video("F0DC299D809B9700", "Zen", false)); - assert!(plays_video(r"C:/Program Files/VideoLAN/VLC/vlc.exe", "vlc", false)); - assert!(!plays_video("Spotify.exe", "Spotify", false)); - assert!(!plays_video("chrome", "YouTube Music", false), "named by its site"); - assert!(!plays_video("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic", "Media Player", false)); - assert!(plays_video("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic", "Media Player", true)); +#[cfg(not(windows))] +mod imp { + #![allow(dead_code)] + + use nectarlink_core::DeviceId; + + pub fn phone() -> Option<(DeviceId, String)> { + None + } + + pub fn watchable() -> Option { + None + } + + pub fn state() -> &'static str { + "idle" + } + + pub fn set_on(_on: bool) {} + + pub fn play(_player: String) {} + + pub struct Watched; + + #[derive(Debug, Clone, Copy)] + pub struct Place { + pub window: isize, + } + + impl Watched { + pub fn follow(_player: &str) -> Watched { + Watched + } + + pub fn place(&self) -> Option { + None + } } } + +pub use imp::*; diff --git a/desktop/app/src/recordings.rs b/desktop/app/src/recordings.rs index a6d7f713..dc7f3cc3 100644 --- a/desktop/app/src/recordings.rs +++ b/desktop/app/src/recordings.rs @@ -15,6 +15,7 @@ use std::{ }; use nectarlink_core::{RecordingMarker, Transfer, TransferState}; +#[cfg(windows)] use windows::{ Win32::{ Media::MediaFoundation::{ @@ -31,14 +32,13 @@ use windows::{ core::{HSTRING, Interface}, }; +#[cfg(windows)] +use crate::win::h264::ensure_mf_started; use crate::{ core_host, settings::{RecordingFormat, Settings}, transfers::{ACTION_OPEN, ACTION_SHOW, TOAST_GROUP}, - win::{ - h264::ensure_mf_started, - toast::{self, Toast}, - }, + win::toast::{self, Toast}, }; #[derive(Debug, Default)] @@ -109,12 +109,48 @@ pub fn format_recording_stem(year: u16, month: u16, day: u16, hour: u16, minute: } /// Base name for a recording saved right now, in local time. +#[cfg(windows)] pub fn current_recording_stem() -> String { // SAFETY: GetLocalTime always succeeds and writes a SYSTEMTIME struct. let st = unsafe { GetLocalTime() }; format_recording_stem(st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute) } +#[cfg(not(windows))] +pub fn current_recording_stem() -> String { + #[repr(C)] + struct Tm { + tm_sec: i32, + tm_min: i32, + tm_hour: i32, + tm_mday: i32, + tm_mon: i32, + tm_year: i32, + tm_wday: i32, + tm_yday: i32, + tm_isdst: i32, + tm_gmtoff: i64, + tm_zone: *const std::ffi::c_char, + } + unsafe extern "C" { + fn time(tloc: *mut i64) -> i64; + fn localtime_r(timep: *const i64, result: *mut Tm) -> *mut Tm; + } + unsafe { + let mut t: i64 = 0; + time(&mut t); + let mut tm = std::mem::zeroed::(); + localtime_r(&t, &mut tm); + format_recording_stem( + (tm.tm_year + 1900) as u16, + (tm.tm_mon + 1) as u16, + tm.tm_mday as u16, + tm.tm_hour as u16, + tm.tm_min as u16, + ) + } +} + /// The name the phone gave a recording (`Recording 2026-10-07 14.32`, from /// when it started), so one that waited for the PC keeps its own time. /// `None` for anything else. @@ -246,6 +282,12 @@ fn move_file(src: &Path, dest: &Path) -> std::io::Result<()> { Ok(()) } +#[cfg(not(windows))] +pub fn convert_audio(_src: &Path, _dest: &Path, _format: RecordingFormat) -> Result<(), String> { + Err("audio conversion via Media Foundation is only supported on Windows".into()) +} + +#[cfg(windows)] /// Converts `src` (`.m4a`) to `dest` in `format` (`Mp3`, `Wav`, or `Flac`) /// using Windows Media Foundation. pub fn convert_audio(src: &Path, dest: &Path, format: RecordingFormat) -> windows::core::Result<()> { @@ -285,6 +327,7 @@ pub fn convert_audio(src: &Path, dest: &Path, format: RecordingFormat) -> window } } +#[cfg(windows)] unsafe fn write_decoded_wav( reader: &IMFSourceReader, stream_idx: u32, @@ -340,6 +383,7 @@ unsafe fn write_decoded_wav( fs::write(dest, wav).map_err(|_| windows::core::Error::from_thread()) } +#[cfg(windows)] unsafe fn transcode_with_sink_writer( reader: &IMFSourceReader, stream_idx: u32, @@ -390,6 +434,7 @@ unsafe fn transcode_with_sink_writer( Ok(()) } +#[cfg(windows)] unsafe fn choose_encoder_output_type( subtype: &windows::core::GUID, sample_rate: u32, @@ -555,6 +600,7 @@ mod tests { } /// Builds a valid 48 kHz mono AAC-LC `.m4a` file for Media Foundation tests. + #[allow(dead_code)] fn test_m4a(duration_secs: u32) -> Vec { const AAC_FRAME: [u8; 6] = [0x01, 0x48, 0x00, 0x84, 0x21, 0x7E]; const SAMPLE_RATE: u32 = 48_000; @@ -762,6 +808,7 @@ mod tests { assert_eq!(text, "00:01.250 Intro\n01:05.000 Marker 2\n01:01:01.005 Wrap-up\n"); } + #[cfg(windows)] #[test] fn media_foundation_converts_m4a_to_m4a_mp3_wav_and_flac_with_markers() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/app/src/settings.rs b/desktop/app/src/settings.rs index 8a00937c..ba3739f0 100644 --- a/desktop/app/src/settings.rs +++ b/desktop/app/src/settings.rs @@ -118,6 +118,10 @@ fn default_true() -> bool { true } +fn default_close_to_tray() -> bool { + !cfg!(target_os = "macos") +} + fn default_webcam_camera() -> String { "back".to_owned() } @@ -316,7 +320,7 @@ pub struct Settings { #[serde(default = "default_true", alias = "comb_motion")] pub comb_motion: bool, /// Closing the window keeps NectarLink running in the tray. - #[serde(alias = "close_to_tray")] + #[serde(default = "default_close_to_tray", alias = "close_to_tray")] pub close_to_tray: bool, /// Send what's copied on this PC to connected phones. #[serde(alias = "auto_clipboard", alias = "clipboard_sync")] @@ -493,7 +497,7 @@ impl Default for Settings { amoled: false, backdrop: true, comb_motion: true, - close_to_tray: true, + close_to_tray: default_close_to_tray(), auto_clipboard: true, clipboard_history: true, suggest_clipboard_actions: true, @@ -567,6 +571,10 @@ impl Settings { self.window_x = None; self.window_y = None; } + #[cfg(target_os = "macos")] + { + self.close_to_tray = false; + } self } diff --git a/desktop/app/src/setup.rs b/desktop/app/src/setup.rs index ec9b2b96..9928acd7 100644 --- a/desktop/app/src/setup.rs +++ b/desktop/app/src/setup.rs @@ -20,8 +20,9 @@ //! `nectarlink-desktop.exe` by name, which would otherwise include these. use crate::i18n::tr; +#[cfg(windows)] +use std::os::windows::process::CommandExt; use std::{ - os::windows::process::CommandExt, path::{Path, PathBuf}, time::{Duration, Instant}, }; @@ -34,11 +35,14 @@ pub const INSTALLED_ARG: &str = "--installed"; /// Where the Android app is downloaded (the done screen's QR code and link). pub const ANDROID_URL: &str = "https://github.com/thtbee/nectarlink/releases/latest"; -const APP_EXE: &str = "nectarlink-desktop.exe"; +const APP_EXE: &str = if cfg!(windows) { "nectarlink-desktop.exe" } else { "nectarlink-desktop" }; +#[cfg(windows)] const UNINSTALL_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\Nectarlink"; -const SETUP_COPY_NAME: &str = "Nectarlink Uninstall.exe"; +const SETUP_COPY_NAME: &str = if cfg!(windows) { "Nectarlink Uninstall.exe" } else { "Nectarlink Uninstall" }; +#[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; +#[cfg(windows)] const DETACHED_PROCESS: u32 = 0x0000_0008; /// What the window is for. @@ -58,12 +62,19 @@ pub struct Installed { } pub fn installed() -> Option { - use windows::Win32::System::Registry::HKEY_LOCAL_MACHINE; - let dir = crate::win::reg_string(HKEY_LOCAL_MACHINE, UNINSTALL_KEY, "InstallLocation")?; - let version = - crate::win::reg_string(HKEY_LOCAL_MACHINE, UNINSTALL_KEY, "DisplayVersion").unwrap_or_default(); - let dir = PathBuf::from(dir); - dir.join(APP_EXE).is_file().then_some(Installed { dir, version }) + #[cfg(windows)] + { + use windows::Win32::System::Registry::HKEY_LOCAL_MACHINE; + let dir = crate::win::reg_string(HKEY_LOCAL_MACHINE, UNINSTALL_KEY, "InstallLocation")?; + let version = + crate::win::reg_string(HKEY_LOCAL_MACHINE, UNINSTALL_KEY, "DisplayVersion").unwrap_or_default(); + let dir = PathBuf::from(dir); + dir.join(APP_EXE).is_file().then_some(Installed { dir, version }) + } + #[cfg(not(windows))] + { + None + } } /// How this install relates to the copy already on the PC. @@ -288,12 +299,19 @@ pub fn open_app(dir: &Path) -> Result<(), String> { if !app.is_file() { return Err(format!("{} is missing", app.display())); } - std::process::Command::new("explorer.exe") - .arg(&app) - .creation_flags(DETACHED_PROCESS) - .spawn() - .map(|_| ()) - .map_err(|e| e.to_string()) + #[cfg(windows)] + { + std::process::Command::new("explorer.exe") + .arg(&app) + .creation_flags(DETACHED_PROCESS) + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()) + } + #[cfg(not(windows))] + { + std::process::Command::new("open").arg(&app).spawn().map(|_| ()).map_err(|e| e.to_string()) + } } /// For `--remove` started from the installed folder: carries on from a copy @@ -333,11 +351,18 @@ pub fn delete_own_copy_later() { if !ours { return; } - let script = format!("/c ping -n 4 127.0.0.1 >nul & rmdir /s /q \"{}\"", dir.display()); - let _ = std::process::Command::new("cmd.exe") - .raw_arg(script) - .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) - .spawn(); + #[cfg(windows)] + { + let script = format!("/c ping -n 4 127.0.0.1 >nul & rmdir /s /q \"{}\"", dir.display()); + let _ = std::process::Command::new("cmd.exe") + .raw_arg(script) + .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) + .spawn(); + } + #[cfg(not(windows))] + { + let _ = std::fs::remove_dir_all(dir); + } } fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> { diff --git a/desktop/app/src/storage.rs b/desktop/app/src/storage.rs index 229fb531..f767b85b 100644 --- a/desktop/app/src/storage.rs +++ b/desktop/app/src/storage.rs @@ -8,717 +8,712 @@ //! reads (`CF_CALLBACK_TYPE_FETCH_DATA`). Renames, deletes, and new or modified //! files dropped into the sync root are forwarded to the phone. -#![allow(unsafe_code)] - -use std::{ - collections::{HashMap, HashSet}, - ffi::{OsStr, c_void}, - os::windows::ffi::{OsStrExt, OsStringExt}, - path::{Path, PathBuf}, - sync::{ - Arc, Mutex, MutexGuard, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; - -use nectarlink_core::{DeviceId, LinkState, NodeEvent, StorageEntry}; -use windows::{ - Security::Cryptography::{BinaryStringEncoding, CryptographicBuffer}, - Storage::{ - Provider::{ - StorageProviderHardlinkPolicy, StorageProviderHydrationPolicy, - StorageProviderHydrationPolicyModifier, StorageProviderInSyncPolicy, - StorageProviderPopulationPolicy, StorageProviderSyncRootInfo, StorageProviderSyncRootManager, - }, - StorageFolder, - }, - Win32::{ - Foundation::{ - CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, NTSTATUS, - STATUS_ACCESS_DENIED, STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE, STATUS_CLOUD_FILE_UNSUCCESSFUL, - STATUS_SUCCESS, +#[cfg(windows)] +mod imp { + + #![allow(unsafe_code)] + + use std::{ + collections::{HashMap, HashSet}, + ffi::{OsStr, c_void}, + os::windows::ffi::{OsStrExt, OsStringExt}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, MutexGuard, + atomic::{AtomicBool, Ordering}, }, + time::Duration, + }; + + use nectarlink_core::{DeviceId, LinkState, NodeEvent, StorageEntry}; + use windows::{ + Security::Cryptography::{BinaryStringEncoding, CryptographicBuffer}, Storage::{ - CloudFilters::{ - CF_CALLBACK_INFO, CF_CALLBACK_PARAMETERS, CF_CALLBACK_REGISTRATION, - CF_CALLBACK_TYPE_FETCH_DATA, CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS, CF_CALLBACK_TYPE_NONE, - CF_CALLBACK_TYPE_NOTIFY_DELETE, CF_CALLBACK_TYPE_NOTIFY_RENAME, - CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH, CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO, - CF_CONNECTION_KEY, CF_CONVERT_FLAG_MARK_IN_SYNC, CF_FS_METADATA, CF_IN_SYNC_STATE_IN_SYNC, - CF_OPERATION_INFO, CF_OPERATION_PARAMETERS, CF_OPERATION_PARAMETERS_0, - CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAG_DISABLE_ON_DEMAND_POPULATION, CF_OPERATION_TYPE, - CF_OPERATION_TYPE_ACK_DELETE, CF_OPERATION_TYPE_ACK_RENAME, CF_OPERATION_TYPE_TRANSFER_DATA, - CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS, CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC, - CF_PLACEHOLDER_CREATE_INFO, CF_PLACEHOLDER_STATE_IN_SYNC, CF_PLACEHOLDER_STATE_PLACEHOLDER, - CF_SET_IN_SYNC_FLAG_NONE, CfConnectSyncRoot, CfConvertToPlaceholder, CfCreatePlaceholders, - CfDisconnectSyncRoot, CfExecute, CfGetPlaceholderStateFromAttributeTag, - CfReportProviderProgress, CfSetInSyncState, + Provider::{ + StorageProviderHardlinkPolicy, StorageProviderHydrationPolicy, + StorageProviderHydrationPolicyModifier, StorageProviderInSyncPolicy, + StorageProviderPopulationPolicy, StorageProviderSyncRootInfo, StorageProviderSyncRootManager, + }, + StorageFolder, + }, + Win32::{ + Foundation::{ + CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, NTSTATUS, + STATUS_ACCESS_DENIED, STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE, STATUS_CLOUD_FILE_UNSUCCESSFUL, + STATUS_SUCCESS, }, - FileSystem::{ - CreateFileW, FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_RENAMED_NEW_NAME, - FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS, - FILE_LIST_DIRECTORY, FILE_NOTIFY_CHANGE_DIR_NAME, FILE_NOTIFY_CHANGE_FILE_NAME, - FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_CHANGE_SIZE, FILE_NOTIFY_INFORMATION, - FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FindClose, FindFirstFileW, - OPEN_EXISTING, ReadDirectoryChangesW, WIN32_FIND_DATAW, + Storage::{ + CloudFilters::{ + CF_CALLBACK_INFO, CF_CALLBACK_PARAMETERS, CF_CALLBACK_REGISTRATION, + CF_CALLBACK_TYPE_FETCH_DATA, CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS, CF_CALLBACK_TYPE_NONE, + CF_CALLBACK_TYPE_NOTIFY_DELETE, CF_CALLBACK_TYPE_NOTIFY_RENAME, + CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH, CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO, + CF_CONNECTION_KEY, CF_CONVERT_FLAG_MARK_IN_SYNC, CF_FS_METADATA, + CF_IN_SYNC_STATE_IN_SYNC, CF_OPERATION_INFO, CF_OPERATION_PARAMETERS, + CF_OPERATION_PARAMETERS_0, + CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAG_DISABLE_ON_DEMAND_POPULATION, CF_OPERATION_TYPE, + CF_OPERATION_TYPE_ACK_DELETE, CF_OPERATION_TYPE_ACK_RENAME, + CF_OPERATION_TYPE_TRANSFER_DATA, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS, + CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC, CF_PLACEHOLDER_CREATE_INFO, + CF_PLACEHOLDER_STATE_IN_SYNC, CF_PLACEHOLDER_STATE_PLACEHOLDER, CF_SET_IN_SYNC_FLAG_NONE, + CfConnectSyncRoot, CfConvertToPlaceholder, CfCreatePlaceholders, CfDisconnectSyncRoot, + CfExecute, CfGetPlaceholderStateFromAttributeTag, CfReportProviderProgress, + CfSetInSyncState, + }, + FileSystem::{ + CreateFileW, FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_RENAMED_NEW_NAME, + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_BASIC_INFO, + FILE_FLAG_BACKUP_SEMANTICS, FILE_LIST_DIRECTORY, FILE_NOTIFY_CHANGE_DIR_NAME, + FILE_NOTIFY_CHANGE_FILE_NAME, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_CHANGE_SIZE, + FILE_NOTIFY_INFORMATION, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FindClose, + FindFirstFileW, OPEN_EXISTING, ReadDirectoryChangesW, WIN32_FIND_DATAW, + }, }, }, - }, - core::{HSTRING, PCWSTR}, -}; - -use crate::{ - core_host, - state::{Changes, CoreStatus}, - win, -}; + core::{HSTRING, PCWSTR}, + }; -/// Provider prefix for sync root IDs (`NectarLink!`). -const PROVIDER_PREFIX: &str = "Nectarlink!"; + use crate::{ + core_host, + state::{Changes, CoreStatus}, + win, + }; -/// Difference between Windows FILETIME epoch (1601-01-01) and Unix epoch -/// (1970-01-01) in 100-nanosecond intervals. -const EPOCH_DIFF_100NS: i64 = 116_444_736_000_000_000; + /// Provider prefix for sync root IDs (`NectarLink!`). + const PROVIDER_PREFIX: &str = "Nectarlink!"; -/// Cloud file network unavailable NTSTATUS (`STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE`). -const STATUS_CLOUD_NETWORK_UNAVAILABLE: NTSTATUS = STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE; + /// Difference between Windows FILETIME epoch (1601-01-01) and Unix epoch + /// (1970-01-01) in 100-nanosecond intervals. + const EPOCH_DIFF_100NS: i64 = 116_444_736_000_000_000; -/// Cloud file unsuccessful / cancelled operation NTSTATUS (`STATUS_CLOUD_FILE_UNSUCCESSFUL`). -const STATUS_CLOUD_UNSUCCESSFUL: NTSTATUS = STATUS_CLOUD_FILE_UNSUCCESSFUL; + /// Cloud file network unavailable NTSTATUS (`STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE`). + const STATUS_CLOUD_NETWORK_UNAVAILABLE: NTSTATUS = STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE; -/// Chunk size for hydrating files via `CF_OPERATION_TYPE_TRANSFER_DATA` (must be -/// a multiple of 4096 bytes for non-EOF chunks). -const HYDRATE_CHUNK_BYTES: usize = 256 * 1024; + /// Cloud file unsuccessful / cancelled operation NTSTATUS (`STATUS_CLOUD_FILE_UNSUCCESSFUL`). + const STATUS_CLOUD_UNSUCCESSFUL: NTSTATUS = STATUS_CLOUD_FILE_UNSUCCESSFUL; -fn lock(m: &Mutex) -> MutexGuard<'_, T> { - m.lock().unwrap_or_else(|e| e.into_inner()) -} + /// Chunk size for hydrating files via `CF_OPERATION_TYPE_TRANSFER_DATA` (must be + /// a multiple of 4096 bytes for non-EOF chunks). + const HYDRATE_CHUNK_BYTES: usize = 256 * 1024; -struct ActiveRoot { - device: DeviceId, - name: String, - root_path: PathBuf, - connection_key: CF_CONNECTION_KEY, - stop_watcher: Arc, - populated_dirs: Mutex>, - uploading: Mutex>, -} - -static ACTIVE_ROOTS: Mutex>>> = Mutex::new(None); -static RECONCILING: Mutex<()> = Mutex::new(()); + fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) + } -fn active_roots() -> MutexGuard<'static, Option>>> { - lock(&ACTIVE_ROOTS) -} + struct ActiveRoot { + device: DeviceId, + name: String, + root_path: PathBuf, + connection_key: CF_CONNECTION_KEY, + stop_watcher: Arc, + populated_dirs: Mutex>, + uploading: Mutex>, + } -fn get_active_root(device: &DeviceId) -> Option> { - active_roots().as_ref().and_then(|m| m.get(device).cloned()) -} + static ACTIVE_ROOTS: Mutex>>> = Mutex::new(None); + static RECONCILING: Mutex<()> = Mutex::new(()); -/// Starts watching paired devices and their `storage` toggles to register or -/// unregister File Explorer sync roots. -pub fn start() { - { - let mut map = active_roots(); - if map.is_none() { - *map = Some(HashMap::new()); - } + fn active_roots() -> MutexGuard<'static, Option>>> { + lock(&ACTIVE_ROOTS) } - core_host::host().hub.subscribe( - Changes::DEVICES | Changes::STATUS | Changes::CAPABILITIES | Changes::TOGGLES, - || { - sync(); - true - }, - ); - sync(); -} -/// Triggers a reconciliation of active sync roots (e.g. after a device toggle changes). -pub fn sync() { - let host = core_host::host(); - let Some(node) = core_host::node() else { return }; - let ready = host.hub.read(|s| matches!(s.status, Some(CoreStatus::Ready { .. }))); - if !ready { - return; + fn get_active_root(device: &DeviceId) -> Option> { + active_roots().as_ref().and_then(|m| m.get(device).cloned()) } - let devices = host.hub.read(|s| { - s.devices - .iter() - .map(|d| (d.id, d.info.name.clone(), matches!(d.link, LinkState::Online { .. }))) - .collect::>() - }); - let mut desired: Vec<(DeviceId, String, bool)> = Vec::new(); - let mut disabled: Vec<(DeviceId, String)> = Vec::new(); - for (id, name, online) in devices { - let enabled = node - .device_toggles(id) - .ok() - .and_then(|t| t.into_iter().find(|(k, _)| *k == "storage").map(|(_, on)| on)) - .unwrap_or(false); - if enabled { - desired.push((id, name, online)); - } else { - disabled.push((id, name)); + + /// Starts watching paired devices and their `storage` toggles to register or + /// unregister File Explorer sync roots. + pub fn start() { + { + let mut map = active_roots(); + if map.is_none() { + *map = Some(HashMap::new()); + } } + core_host::host().hub.subscribe( + Changes::DEVICES | Changes::STATUS | Changes::CAPABILITIES | Changes::TOGGLES, + || { + sync(); + true + }, + ); + sync(); } - let _ = std::thread::Builder::new().name("storage-sync".into()).spawn(move || { - let _guard = lock(&RECONCILING); - win::with_com(|| reconcile(&desired, &disabled)); - }); -} - -/// Handles core events (`StorageChanged`, `DeviceRemoved`, `LinkChanged`). -pub fn on_event(event: &NodeEvent) { - match event { - NodeEvent::DeviceRemoved(device) => { - let device = *device; - let _ = std::thread::Builder::new().name("storage-unpair".into()).spawn(move || { - let _guard = lock(&RECONCILING); - win::with_com(|| remove_device_root(device, None)); - }); + /// Triggers a reconciliation of active sync roots (e.g. after a device toggle changes). + pub fn sync() { + let host = core_host::host(); + let Some(node) = core_host::node() else { return }; + let ready = host.hub.read(|s| matches!(s.status, Some(CoreStatus::Ready { .. }))); + if !ready { + return; } - NodeEvent::DeviceAdded(_) - | NodeEvent::PeerInfoChanged { .. } - | NodeEvent::Capabilities(_) - | NodeEvent::LinkChanged { .. } => { - sync(); + let devices = host.hub.read(|s| { + s.devices + .iter() + .map(|d| (d.id, d.info.name.clone(), matches!(d.link, LinkState::Online { .. }))) + .collect::>() + }); + let mut desired: Vec<(DeviceId, String, bool)> = Vec::new(); + let mut disabled: Vec<(DeviceId, String)> = Vec::new(); + for (id, name, online) in devices { + let enabled = node + .device_toggles(id) + .ok() + .and_then(|t| t.into_iter().find(|(k, _)| *k == "storage").map(|(_, on)| on)) + .unwrap_or(false); + if enabled { + desired.push((id, name, online)); + } else { + disabled.push((id, name)); + } } - NodeEvent::StorageChanged { device, path } => { - let device = *device; - let path = path.clone(); - if let Some(root) = get_active_root(&device) { - core_host::spawn(async move { - refresh_folder(root, &path).await; + + let _ = std::thread::Builder::new().name("storage-sync".into()).spawn(move || { + let _guard = lock(&RECONCILING); + win::with_com(|| reconcile(&desired, &disabled)); + }); + } + + /// Handles core events (`StorageChanged`, `DeviceRemoved`, `LinkChanged`). + pub fn on_event(event: &NodeEvent) { + match event { + NodeEvent::DeviceRemoved(device) => { + let device = *device; + let _ = std::thread::Builder::new().name("storage-unpair".into()).spawn(move || { + let _guard = lock(&RECONCILING); + win::with_com(|| remove_device_root(device, None)); }); } + NodeEvent::DeviceAdded(_) + | NodeEvent::PeerInfoChanged { .. } + | NodeEvent::Capabilities(_) + | NodeEvent::LinkChanged { .. } => { + sync(); + } + NodeEvent::StorageChanged { device, path } => { + let device = *device; + let path = path.clone(); + if let Some(root) = get_active_root(&device) { + core_host::spawn(async move { + refresh_folder(root, &path).await; + }); + } + } + _ => {} } - _ => {} } -} -/// Disconnects active sync roots when the desktop app exits. -pub fn shutdown() { - let roots: Vec> = - active_roots().as_mut().map(|m| m.drain().map(|(_, v)| v).collect()).unwrap_or_default(); - for root in roots { - root.stop_watcher.store(true, Ordering::Relaxed); - // SAFETY: connection_key was obtained from CfConnectSyncRoot. - let _ = unsafe { CfDisconnectSyncRoot(root.connection_key) }; + /// Disconnects active sync roots when the desktop app exits. + pub fn shutdown() { + let roots: Vec> = + active_roots().as_mut().map(|m| m.drain().map(|(_, v)| v).collect()).unwrap_or_default(); + for root in roots { + root.stop_watcher.store(true, Ordering::Relaxed); + // SAFETY: connection_key was obtained from CfConnectSyncRoot. + let _ = unsafe { CfDisconnectSyncRoot(root.connection_key) }; + } } -} -/// Unregisters all `NectarLink!*` sync roots from Windows (`--uninstall`). -pub fn unregister_all() { - shutdown(); - win::with_com(|| { - if let Ok(roots) = StorageProviderSyncRootManager::GetCurrentSyncRoots() { - let count = roots.Size().unwrap_or(0); - for i in 0..count { - if let Ok(info) = roots.GetAt(i) - && let Ok(id) = info.Id() - { - let id_str = id.to_string_lossy(); - if id_str.starts_with(PROVIDER_PREFIX) { - let folder_path = info - .Path() - .ok() - .and_then(|f| f.Path().ok()) - .map(|p| PathBuf::from(p.to_string_lossy())); - let _ = StorageProviderSyncRootManager::Unregister(&id); - if let Some(path) = folder_path { - cleanup_sync_root_dir(&path); + /// Unregisters all `NectarLink!*` sync roots from Windows (`--uninstall`). + pub fn unregister_all() { + shutdown(); + win::with_com(|| { + if let Ok(roots) = StorageProviderSyncRootManager::GetCurrentSyncRoots() { + let count = roots.Size().unwrap_or(0); + for i in 0..count { + if let Ok(info) = roots.GetAt(i) + && let Ok(id) = info.Id() + { + let id_str = id.to_string_lossy(); + if id_str.starts_with(PROVIDER_PREFIX) { + let folder_path = info + .Path() + .ok() + .and_then(|f| f.Path().ok()) + .map(|p| PathBuf::from(p.to_string_lossy())); + let _ = StorageProviderSyncRootManager::Unregister(&id); + if let Some(path) = folder_path { + cleanup_sync_root_dir(&path); + } } } } } - } - if let Some(base) = base_sync_dir() { - let _ = std::fs::remove_dir(&base); - } - }); -} - -/// Returns the local sync root path for a paired phone (`%USERPROFILE%\Nectarlink\`). -pub fn sync_root_path_for(device: DeviceId) -> Option { - if let Some(active) = get_active_root(&device) { - return Some(active.root_path.clone()); + if let Some(base) = base_sync_dir() { + let _ = std::fs::remove_dir(&base); + } + }); } - let name = core_host::host().hub.read(|s| s.name_of(&device))?; - Some(base_sync_dir()?.join(sanitize_folder_name(&name))) -} -/// Opens a paired phone's sync root folder in File Explorer. -pub fn open_in_explorer(device: DeviceId) -> Result<(), String> { - let path = - sync_root_path_for(device).ok_or_else(|| "Phone storage folder is not available".to_owned())?; - if !path.exists() { - std::fs::create_dir_all(&path).map_err(|e| e.to_string())?; + /// Returns the local sync root path for a paired phone (`%USERPROFILE%\Nectarlink\`). + pub fn sync_root_path_for(device: DeviceId) -> Option { + if let Some(active) = get_active_root(&device) { + return Some(active.root_path.clone()); + } + let name = core_host::host().hub.read(|s| s.name_of(&device))?; + Some(base_sync_dir()?.join(sanitize_folder_name(&name))) } - win::shell::run(Path::new("explorer.exe"), &format!("\"{}\"", path.display())) -} -fn base_sync_dir() -> Option { - dirs::home_dir().map(|h| h.join("Nectarlink")) -} - -fn sync_root_id(device: DeviceId) -> HSTRING { - HSTRING::from(format!("{PROVIDER_PREFIX}{device}")) -} - -/// Sanitizes a phone's display name into a valid Windows directory name. -pub fn sanitize_folder_name(device_name: &str) -> String { - let cleaned: String = device_name - .chars() - .map(|c| if c.is_control() || r#"<>:"/\|?*"#.contains(c) { ' ' } else { c }) - .collect(); - let mut name = cleaned.split_whitespace().collect::>().join(" "); - name = name.trim_end_matches(['.', ' ']).chars().take(64).collect(); - let stem = name.split('.').next().unwrap_or_default().to_ascii_uppercase(); - let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") - || ((stem.starts_with("COM") || stem.starts_with("LPT")) - && stem.len() == 4 - && stem.as_bytes()[3].is_ascii_digit()); - if name.is_empty() { - "Phone".into() - } else if reserved { - format!("{name} phone") - } else { - name + /// Opens a paired phone's sync root folder in File Explorer. + pub fn open_in_explorer(device: DeviceId) -> Result<(), String> { + let path = + sync_root_path_for(device).ok_or_else(|| "Phone storage folder is not available".to_owned())?; + if !path.exists() { + std::fs::create_dir_all(&path).map_err(|e| e.to_string())?; + } + win::shell::run(Path::new("explorer.exe"), &format!("\"{}\"", path.display())) } -} -/// Converts a Unix timestamp in milliseconds to Windows FILETIME 100ns ticks -/// since 1601-01-01. -pub fn unix_ms_to_filetime_ticks(unix_ms: i64) -> i64 { - EPOCH_DIFF_100NS.saturating_add(unix_ms.max(0).saturating_mul(10_000)) -} + fn base_sync_dir() -> Option { + dirs::home_dir().map(|h| h.join("Nectarlink")) + } -/// Converts Windows FILETIME 100ns ticks since 1601-01-01 to Unix milliseconds. -#[cfg(test)] -pub fn filetime_ticks_to_unix_ms(ticks: i64) -> i64 { - ticks.saturating_sub(EPOCH_DIFF_100NS).max(0) / 10_000 -} + fn sync_root_id(device: DeviceId) -> HSTRING { + HSTRING::from(format!("{PROVIDER_PREFIX}{device}")) + } -/// Computes the slash-separated relative path inside `root_path` from a full -/// local path (`""` for the sync root itself). Returns `None` if `full_path` is -/// not inside `root_path`. -pub fn relative_path_under_root(root_path: &Path, full_path: &Path) -> Option { - let root_norm = normalize_win_path(root_path); - let full_norm = normalize_win_path(full_path); - let root_lower = root_norm.to_lowercase(); - let full_lower = full_norm.to_lowercase(); - if full_lower == root_lower { - return Some(String::new()); + /// Sanitizes a phone's display name into a valid Windows directory name. + pub fn sanitize_folder_name(device_name: &str) -> String { + let cleaned: String = device_name + .chars() + .map(|c| if c.is_control() || r#"<>:"/\|?*"#.contains(c) { ' ' } else { c }) + .collect(); + let mut name = cleaned.split_whitespace().collect::>().join(" "); + name = name.trim_end_matches(['.', ' ']).chars().take(64).collect(); + let stem = name.split('.').next().unwrap_or_default().to_ascii_uppercase(); + let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || ((stem.starts_with("COM") || stem.starts_with("LPT")) + && stem.len() == 4 + && stem.as_bytes()[3].is_ascii_digit()); + if name.is_empty() { + "Phone".into() + } else if reserved { + format!("{name} phone") + } else { + name + } } - let prefix = format!("{root_lower}\\"); - if !full_lower.starts_with(&prefix) { - return None; + + /// Converts a Unix timestamp in milliseconds to Windows FILETIME 100ns ticks + /// since 1601-01-01. + pub fn unix_ms_to_filetime_ticks(unix_ms: i64) -> i64 { + EPOCH_DIFF_100NS.saturating_add(unix_ms.max(0).saturating_mul(10_000)) } - let rel = &full_norm[prefix.len()..]; - let parts: Vec<&str> = rel.split('\\').filter(|s| !s.is_empty()).collect(); - if parts.iter().any(|p| *p == "." || *p == "..") { - return None; + + /// Converts Windows FILETIME 100ns ticks since 1601-01-01 to Unix milliseconds. + #[cfg(test)] + pub fn filetime_ticks_to_unix_ms(ticks: i64) -> i64 { + ticks.saturating_sub(EPOCH_DIFF_100NS).max(0) / 10_000 } - Some(parts.join("/")) -} -fn normalize_win_path(path: &Path) -> String { - let s = path.to_string_lossy(); - let stripped = s.strip_prefix(r"\\?\").unwrap_or(&s); - stripped.trim_end_matches('\\').to_owned() -} + /// Computes the slash-separated relative path inside `root_path` from a full + /// local path (`""` for the sync root itself). Returns `None` if `full_path` is + /// not inside `root_path`. + pub fn relative_path_under_root(root_path: &Path, full_path: &Path) -> Option { + let root_norm = normalize_win_path(root_path); + let full_norm = normalize_win_path(full_path); + let root_lower = root_norm.to_lowercase(); + let full_lower = full_norm.to_lowercase(); + if full_lower == root_lower { + return Some(String::new()); + } + let prefix = format!("{root_lower}\\"); + if !full_lower.starts_with(&prefix) { + return None; + } + let rel = &full_norm[prefix.len()..]; + let parts: Vec<&str> = rel.split('\\').filter(|s| !s.is_empty()).collect(); + if parts.iter().any(|p| *p == "." || *p == "..") { + return None; + } + Some(parts.join("/")) + } -fn reconcile(desired: &[(DeviceId, String, bool)], disabled: &[(DeviceId, String)]) { - // Unregister any disabled devices that belong to this instance. - for (id, name) in disabled { - remove_device_root(*id, Some(name)); + fn normalize_win_path(path: &Path) -> String { + let s = path.to_string_lossy(); + let stripped = s.strip_prefix(r"\\?\").unwrap_or(&s); + stripped.trim_end_matches('\\').to_owned() } - // Remove any active roots no longer in `desired`. - let active_ids: Vec = - active_roots().as_ref().map(|m| m.keys().copied().collect()).unwrap_or_default(); - for id in active_ids { - if !desired.iter().any(|(d, _, _)| *d == id) { - remove_device_root(id, None); + fn reconcile(desired: &[(DeviceId, String, bool)], disabled: &[(DeviceId, String)]) { + // Unregister any disabled devices that belong to this instance. + for (id, name) in disabled { + remove_device_root(*id, Some(name)); } - } - // Register and connect desired roots. - for (id, name, online) in desired { - let already = get_active_root(id); - if let Some(existing) = already { - if existing.name == *name { - if *online { - let root = existing.clone(); - core_host::spawn(async move { - let is_root_populated = lock(&root.populated_dirs).contains(""); - if is_root_populated { - refresh_folder(root, "").await; - } - }); - } - continue; + // Remove any active roots no longer in `desired`. + let active_ids: Vec = + active_roots().as_ref().map(|m| m.keys().copied().collect()).unwrap_or_default(); + for id in active_ids { + if !desired.iter().any(|(d, _, _)| *d == id) { + remove_device_root(id, None); } - // Name changed: replace root. - remove_device_root(*id, Some(&existing.name)); } - if let Err(e) = register_and_connect(*id, name, *online) { - tracing::warn!(device = %id.short(), error = %e, "can't register phone storage sync root"); + // Register and connect desired roots. + for (id, name, online) in desired { + let already = get_active_root(id); + if let Some(existing) = already { + if existing.name == *name { + if *online { + let root = existing.clone(); + core_host::spawn(async move { + let is_root_populated = lock(&root.populated_dirs).contains(""); + if is_root_populated { + refresh_folder(root, "").await; + } + }); + } + continue; + } + // Name changed: replace root. + remove_device_root(*id, Some(&existing.name)); + } + + if let Err(e) = register_and_connect(*id, name, *online) { + tracing::warn!(device = %id.short(), error = %e, "can't register phone storage sync root"); + } } } -} -fn remove_device_root(device: DeviceId, known_name: Option<&str>) { - let removed = active_roots().as_mut().and_then(|m| m.remove(&device)); - let mut folder_to_clean: Option = None; - if let Some(root) = removed { - root.stop_watcher.store(true, Ordering::Relaxed); - // SAFETY: connection_key was obtained from CfConnectSyncRoot. - let _ = unsafe { CfDisconnectSyncRoot(root.connection_key) }; - folder_to_clean = Some(root.root_path.clone()); - } else if let Some(name) = known_name - && let Some(base) = base_sync_dir() - { - folder_to_clean = Some(base.join(sanitize_folder_name(name))); - } + fn remove_device_root(device: DeviceId, known_name: Option<&str>) { + let removed = active_roots().as_mut().and_then(|m| m.remove(&device)); + let mut folder_to_clean: Option = None; + if let Some(root) = removed { + root.stop_watcher.store(true, Ordering::Relaxed); + // SAFETY: connection_key was obtained from CfConnectSyncRoot. + let _ = unsafe { CfDisconnectSyncRoot(root.connection_key) }; + folder_to_clean = Some(root.root_path.clone()); + } else if let Some(name) = known_name + && let Some(base) = base_sync_dir() + { + folder_to_clean = Some(base.join(sanitize_folder_name(name))); + } - let id = sync_root_id(device); - let _ = StorageProviderSyncRootManager::Unregister(&id); + let id = sync_root_id(device); + let _ = StorageProviderSyncRootManager::Unregister(&id); - if let Some(path) = folder_to_clean { - cleanup_sync_root_dir(&path); - } - if let Some(base) = base_sync_dir() { - let _ = std::fs::remove_dir(&base); + if let Some(path) = folder_to_clean { + cleanup_sync_root_dir(&path); + } + if let Some(base) = base_sync_dir() { + let _ = std::fs::remove_dir(&base); + } } -} -fn cleanup_sync_root_dir(path: &Path) { - if !path.exists() { - return; + fn cleanup_sync_root_dir(path: &Path) { + if !path.exists() { + return; + } + let _ = std::fs::remove_dir_all(path); } - let _ = std::fs::remove_dir_all(path); -} -fn register_and_connect(device: DeviceId, name: &str, online: bool) -> Result<(), String> { - let base = base_sync_dir().ok_or_else(|| "no user home directory".to_owned())?; - let folder_name = sanitize_folder_name(name); - let root_path = base.join(&folder_name); - std::fs::create_dir_all(&root_path).map_err(|e| format!("create sync root dir: {e}"))?; - - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - let icon_resource = format!("{},0", exe.display()); - - let info = StorageProviderSyncRootInfo::new().map_err(|e| format!("SyncRootInfo::new: {e}"))?; - info.SetId(&sync_root_id(device)).map_err(|e| format!("SetId: {e}"))?; - - let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(root_path.as_os_str())) - .map_err(|e| format!("GetFolderFromPathAsync: {e}"))? - .join() - .map_err(|e| format!("GetFolderFromPathAsync.join: {e}"))?; - info.SetPath(&folder).map_err(|e| format!("SetPath: {e}"))?; - info.SetDisplayNameResource(&HSTRING::from(name)).map_err(|e| format!("SetDisplayNameResource: {e}"))?; - info.SetIconResource(&HSTRING::from(&icon_resource)).map_err(|e| format!("SetIconResource: {e}"))?; - info.SetVersion(&HSTRING::from(env!("CARGO_PKG_VERSION"))).map_err(|e| format!("SetVersion: {e}"))?; - info.SetHydrationPolicy(StorageProviderHydrationPolicy::Full) - .map_err(|e| format!("SetHydrationPolicy: {e}"))?; - info.SetHydrationPolicyModifier(StorageProviderHydrationPolicyModifier::None) - .map_err(|e| format!("SetHydrationPolicyModifier: {e}"))?; - info.SetPopulationPolicy(StorageProviderPopulationPolicy::Full) - .map_err(|e| format!("SetPopulationPolicy: {e}"))?; - info.SetInSyncPolicy( - StorageProviderInSyncPolicy::FileCreationTime | StorageProviderInSyncPolicy::DirectoryCreationTime, - ) - .map_err(|e| format!("SetInSyncPolicy: {e}"))?; - info.SetHardlinkPolicy(StorageProviderHardlinkPolicy::None) - .map_err(|e| format!("SetHardlinkPolicy: {e}"))?; - info.SetShowSiblingsAsGroup(false).map_err(|e| format!("SetShowSiblingsAsGroup: {e}"))?; - - let context_buf = CryptographicBuffer::ConvertStringToBinary( - &HSTRING::from(device.to_string()), - BinaryStringEncoding::Utf8, - ) - .map_err(|e| format!("ConvertStringToBinary: {e}"))?; - info.SetContext(&context_buf).map_err(|e| format!("SetContext: {e}"))?; - - StorageProviderSyncRootManager::Register(&info).map_err(|e| format!("Register: {e}"))?; - - let callbacks = [ - CF_CALLBACK_REGISTRATION { - Type: CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS, - Callback: Some(on_fetch_placeholders), - }, - CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_FETCH_DATA, Callback: Some(on_fetch_data) }, - CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_NOTIFY_RENAME, Callback: Some(on_notify_rename) }, - CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_NOTIFY_DELETE, Callback: Some(on_notify_delete) }, - CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_NONE, Callback: None }, - ]; - - // Keep the DeviceId in a heap allocation for the process lifetime so async - // cfapi callbacks always read a valid DeviceId and look up `ACTIVE_ROOTS`. - let ctx_ptr: *const c_void = Box::into_raw(Box::new(device)).cast(); - let wide_root = to_wide_null(&root_path); - // SAFETY: wide_root is null-terminated, callbacks ends with CF_CALLBACK_TYPE_NONE. - let connection_key = unsafe { - CfConnectSyncRoot( - PCWSTR(wide_root.as_ptr()), - callbacks.as_ptr(), - Some(ctx_ptr), - CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO | CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH, + fn register_and_connect(device: DeviceId, name: &str, online: bool) -> Result<(), String> { + let base = base_sync_dir().ok_or_else(|| "no user home directory".to_owned())?; + let folder_name = sanitize_folder_name(name); + let root_path = base.join(&folder_name); + std::fs::create_dir_all(&root_path).map_err(|e| format!("create sync root dir: {e}"))?; + + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let icon_resource = format!("{},0", exe.display()); + + let info = StorageProviderSyncRootInfo::new().map_err(|e| format!("SyncRootInfo::new: {e}"))?; + info.SetId(&sync_root_id(device)).map_err(|e| format!("SetId: {e}"))?; + + let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(root_path.as_os_str())) + .map_err(|e| format!("GetFolderFromPathAsync: {e}"))? + .join() + .map_err(|e| format!("GetFolderFromPathAsync.join: {e}"))?; + info.SetPath(&folder).map_err(|e| format!("SetPath: {e}"))?; + info.SetDisplayNameResource(&HSTRING::from(name)) + .map_err(|e| format!("SetDisplayNameResource: {e}"))?; + info.SetIconResource(&HSTRING::from(&icon_resource)).map_err(|e| format!("SetIconResource: {e}"))?; + info.SetVersion(&HSTRING::from(env!("CARGO_PKG_VERSION"))).map_err(|e| format!("SetVersion: {e}"))?; + info.SetHydrationPolicy(StorageProviderHydrationPolicy::Full) + .map_err(|e| format!("SetHydrationPolicy: {e}"))?; + info.SetHydrationPolicyModifier(StorageProviderHydrationPolicyModifier::None) + .map_err(|e| format!("SetHydrationPolicyModifier: {e}"))?; + info.SetPopulationPolicy(StorageProviderPopulationPolicy::Full) + .map_err(|e| format!("SetPopulationPolicy: {e}"))?; + info.SetInSyncPolicy( + StorageProviderInSyncPolicy::FileCreationTime + | StorageProviderInSyncPolicy::DirectoryCreationTime, ) - } - .map_err(|e| format!("CfConnectSyncRoot: {e}"))?; - - let stop_watcher = Arc::new(AtomicBool::new(false)); - let active = Arc::new(ActiveRoot { - device, - name: name.to_owned(), - root_path: root_path.clone(), - connection_key, - stop_watcher: stop_watcher.clone(), - populated_dirs: Mutex::new(HashSet::new()), - uploading: Mutex::new(HashSet::new()), - }); - - if let Some(map) = active_roots().as_mut() { - map.insert(device, active.clone()); - } + .map_err(|e| format!("SetInSyncPolicy: {e}"))?; + info.SetHardlinkPolicy(StorageProviderHardlinkPolicy::None) + .map_err(|e| format!("SetHardlinkPolicy: {e}"))?; + info.SetShowSiblingsAsGroup(false).map_err(|e| format!("SetShowSiblingsAsGroup: {e}"))?; + + let context_buf = CryptographicBuffer::ConvertStringToBinary( + &HSTRING::from(device.to_string()), + BinaryStringEncoding::Utf8, + ) + .map_err(|e| format!("ConvertStringToBinary: {e}"))?; + info.SetContext(&context_buf).map_err(|e| format!("SetContext: {e}"))?; - start_directory_watcher(active.clone(), stop_watcher); + StorageProviderSyncRootManager::Register(&info).map_err(|e| format!("Register: {e}"))?; - if online { - core_host::spawn(async move { - // Pre-populate top-level placeholders when connected so opening the - // sync root in Explorer or inspecting it immediately shows folders. - refresh_folder(active, "").await; + let callbacks = [ + CF_CALLBACK_REGISTRATION { + Type: CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS, + Callback: Some(on_fetch_placeholders), + }, + CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_FETCH_DATA, Callback: Some(on_fetch_data) }, + CF_CALLBACK_REGISTRATION { + Type: CF_CALLBACK_TYPE_NOTIFY_RENAME, + Callback: Some(on_notify_rename), + }, + CF_CALLBACK_REGISTRATION { + Type: CF_CALLBACK_TYPE_NOTIFY_DELETE, + Callback: Some(on_notify_delete), + }, + CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_NONE, Callback: None }, + ]; + + // Keep the DeviceId in a heap allocation for the process lifetime so async + // cfapi callbacks always read a valid DeviceId and look up `ACTIVE_ROOTS`. + let ctx_ptr: *const c_void = Box::into_raw(Box::new(device)).cast(); + let wide_root = to_wide_null(&root_path); + // SAFETY: wide_root is null-terminated, callbacks ends with CF_CALLBACK_TYPE_NONE. + let connection_key = unsafe { + CfConnectSyncRoot( + PCWSTR(wide_root.as_ptr()), + callbacks.as_ptr(), + Some(ctx_ptr), + CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO | CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH, + ) + } + .map_err(|e| format!("CfConnectSyncRoot: {e}"))?; + + let stop_watcher = Arc::new(AtomicBool::new(false)); + let active = Arc::new(ActiveRoot { + device, + name: name.to_owned(), + root_path: root_path.clone(), + connection_key, + stop_watcher: stop_watcher.clone(), + populated_dirs: Mutex::new(HashSet::new()), + uploading: Mutex::new(HashSet::new()), }); - } - tracing::info!(device = %device.short(), "registered phone storage sync root"); - Ok(()) -} + if let Some(map) = active_roots().as_mut() { + map.insert(device, active.clone()); + } -fn to_wide_null(s: impl AsRef) -> Vec { - s.as_ref().encode_wide().chain(std::iter::once(0)).collect() -} + start_directory_watcher(active.clone(), stop_watcher); + + if online { + core_host::spawn(async move { + // Pre-populate top-level placeholders when connected so opening the + // sync root in Explorer or inspecting it immediately shows folders. + refresh_folder(active, "").await; + }); + } -unsafe fn pcwstr_to_string(ptr: PCWSTR) -> String { - if ptr.is_null() { - return String::new(); + tracing::info!(device = %device.short(), "registered phone storage sync root"); + Ok(()) } - // SAFETY: caller guarantees `ptr` is a valid null-terminated wide string. - let slice = unsafe { ptr.as_wide() }; - std::ffi::OsString::from_wide(slice).to_string_lossy().into_owned() -} -unsafe fn callback_root_and_path( - info: *const CF_CALLBACK_INFO, -) -> Option<(Arc, String, CF_CONNECTION_KEY, i64, i64)> { - // SAFETY: Windows passes a valid CF_CALLBACK_INFO pointer to registered callbacks. - let cb = unsafe { info.as_ref()? }; - if cb.CallbackContext.is_null() { - return None; + fn to_wide_null(s: impl AsRef) -> Vec { + s.as_ref().encode_wide().chain(std::iter::once(0)).collect() } - // SAFETY: CallbackContext points to a leaked `Box`. - let device = unsafe { *(cb.CallbackContext as *const DeviceId) }; - let root = get_active_root(&device)?; - // SAFETY: VolumeDosName and NormalizedPath are valid PCWSTRs in CF_CALLBACK_INFO. - let vol = unsafe { pcwstr_to_string(cb.VolumeDosName) }; - // SAFETY: NormalizedPath is valid when CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH is set. - let norm = unsafe { pcwstr_to_string(cb.NormalizedPath) }; - let full = PathBuf::from(format!("{vol}{norm}")); - let rel = relative_path_under_root(&root.root_path, &full)?; - Some((root, rel, cb.ConnectionKey, cb.TransferKey, cb.RequestKey)) -} -fn make_op_info( - connection_key: CF_CONNECTION_KEY, - transfer_key: i64, - request_key: i64, - op_type: CF_OPERATION_TYPE, -) -> CF_OPERATION_INFO { - CF_OPERATION_INFO { - StructSize: std::mem::size_of::() as u32, - Type: op_type, - ConnectionKey: connection_key, - TransferKey: transfer_key, - CorrelationVector: std::ptr::null(), - SyncStatus: std::ptr::null(), - RequestKey: request_key, + unsafe fn pcwstr_to_string(ptr: PCWSTR) -> String { + if ptr.is_null() { + return String::new(); + } + // SAFETY: caller guarantees `ptr` is a valid null-terminated wide string. + let slice = unsafe { ptr.as_wide() }; + std::ffi::OsString::from_wide(slice).to_string_lossy().into_owned() } -} -unsafe extern "system" fn on_fetch_placeholders( - callback_info: *const CF_CALLBACK_INFO, - _callback_parameters: *const CF_CALLBACK_PARAMETERS, -) { - // SAFETY: callback_info is provided by Windows cfapi. - let Some((root, rel_path, connection_key, transfer_key, request_key)) = - (unsafe { callback_root_and_path(callback_info) }) - else { - return; - }; + unsafe fn callback_root_and_path( + info: *const CF_CALLBACK_INFO, + ) -> Option<(Arc, String, CF_CONNECTION_KEY, i64, i64)> { + // SAFETY: Windows passes a valid CF_CALLBACK_INFO pointer to registered callbacks. + let cb = unsafe { info.as_ref()? }; + if cb.CallbackContext.is_null() { + return None; + } + // SAFETY: CallbackContext points to a leaked `Box`. + let device = unsafe { *(cb.CallbackContext as *const DeviceId) }; + let root = get_active_root(&device)?; + // SAFETY: VolumeDosName and NormalizedPath are valid PCWSTRs in CF_CALLBACK_INFO. + let vol = unsafe { pcwstr_to_string(cb.VolumeDosName) }; + // SAFETY: NormalizedPath is valid when CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH is set. + let norm = unsafe { pcwstr_to_string(cb.NormalizedPath) }; + let full = PathBuf::from(format!("{vol}{norm}")); + let rel = relative_path_under_root(&root.root_path, &full)?; + Some((root, rel, cb.ConnectionKey, cb.TransferKey, cb.RequestKey)) + } + + fn make_op_info( + connection_key: CF_CONNECTION_KEY, + transfer_key: i64, + request_key: i64, + op_type: CF_OPERATION_TYPE, + ) -> CF_OPERATION_INFO { + CF_OPERATION_INFO { + StructSize: std::mem::size_of::() as u32, + Type: op_type, + ConnectionKey: connection_key, + TransferKey: transfer_key, + CorrelationVector: std::ptr::null(), + SyncStatus: std::ptr::null(), + RequestKey: request_key, + } + } - core_host::spawn(async move { - let Some(node) = core_host::node() else { - ack_placeholders_failed(connection_key, transfer_key, request_key); + unsafe extern "system" fn on_fetch_placeholders( + callback_info: *const CF_CALLBACK_INFO, + _callback_parameters: *const CF_CALLBACK_PARAMETERS, + ) { + // SAFETY: callback_info is provided by Windows cfapi. + let Some((root, rel_path, connection_key, transfer_key, request_key)) = + (unsafe { callback_root_and_path(callback_info) }) + else { return; }; - match node.storage_list(root.device, &rel_path).await { - Ok(entries) => { - lock(&root.populated_dirs).insert(rel_path.clone()); - transfer_placeholders(connection_key, transfer_key, request_key, &rel_path, &entries); - } - Err(e) => { - tracing::debug!( - device = %root.device.short(), - error = %e, - "fetch placeholders failed" - ); + + core_host::spawn(async move { + let Some(node) = core_host::node() else { ack_placeholders_failed(connection_key, transfer_key, request_key); + return; + }; + match node.storage_list(root.device, &rel_path).await { + Ok(entries) => { + lock(&root.populated_dirs).insert(rel_path.clone()); + transfer_placeholders(connection_key, transfer_key, request_key, &rel_path, &entries); + } + Err(e) => { + tracing::debug!( + device = %root.device.short(), + error = %e, + "fetch placeholders failed" + ); + ack_placeholders_failed(connection_key, transfer_key, request_key); + } } - } - }); -} - -fn transfer_placeholders( - connection_key: CF_CONNECTION_KEY, - transfer_key: i64, - request_key: i64, - parent_rel: &str, - entries: &[StorageEntry], -) { - let mut wide_names: Vec> = Vec::with_capacity(entries.len()); - let mut identities: Vec> = Vec::with_capacity(entries.len()); - for entry in entries { - wide_names.push(to_wide_null(&entry.name)); - let rel = - if parent_rel.is_empty() { entry.name.clone() } else { format!("{parent_rel}/{}", entry.name) }; - identities.push(rel.into_bytes()); + }); } - let mut create_infos: Vec = entries - .iter() - .enumerate() - .map(|(i, entry)| build_placeholder_create_info(entry, &wide_names[i], &identities[i])) - .collect(); - - let op_info = - make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS); - let mut op_params = CF_OPERATION_PARAMETERS { - ParamSize: std::mem::size_of::() as u32, - Anonymous: CF_OPERATION_PARAMETERS_0 { - TransferPlaceholders: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_4 { - Flags: CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAG_DISABLE_ON_DEMAND_POPULATION, - CompletionStatus: STATUS_SUCCESS, - PlaceholderTotalCount: create_infos.len() as i64, - PlaceholderArray: if create_infos.is_empty() { - std::ptr::null_mut() - } else { - create_infos.as_mut_ptr() + fn transfer_placeholders( + connection_key: CF_CONNECTION_KEY, + transfer_key: i64, + request_key: i64, + parent_rel: &str, + entries: &[StorageEntry], + ) { + let mut wide_names: Vec> = Vec::with_capacity(entries.len()); + let mut identities: Vec> = Vec::with_capacity(entries.len()); + for entry in entries { + wide_names.push(to_wide_null(&entry.name)); + let rel = if parent_rel.is_empty() { + entry.name.clone() + } else { + format!("{parent_rel}/{}", entry.name) + }; + identities.push(rel.into_bytes()); + } + + let mut create_infos: Vec = entries + .iter() + .enumerate() + .map(|(i, entry)| build_placeholder_create_info(entry, &wide_names[i], &identities[i])) + .collect(); + + let op_info = + make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS); + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + TransferPlaceholders: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_4 { + Flags: CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAG_DISABLE_ON_DEMAND_POPULATION, + CompletionStatus: STATUS_SUCCESS, + PlaceholderTotalCount: create_infos.len() as i64, + PlaceholderArray: if create_infos.is_empty() { + std::ptr::null_mut() + } else { + create_infos.as_mut_ptr() + }, + PlaceholderCount: create_infos.len() as u32, + EntriesProcessed: 0, }, - PlaceholderCount: create_infos.len() as u32, - EntriesProcessed: 0, }, - }, - }; - // SAFETY: op_info and op_params point to valid stack structures and buffers. - let _ = unsafe { CfExecute(&op_info, &mut op_params) }; -} + }; + // SAFETY: op_info and op_params point to valid stack structures and buffers. + let _ = unsafe { CfExecute(&op_info, &mut op_params) }; + } -fn ack_placeholders_failed(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64) { - let op_info = - make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS); - let mut op_params = CF_OPERATION_PARAMETERS { - ParamSize: std::mem::size_of::() as u32, - Anonymous: CF_OPERATION_PARAMETERS_0 { - TransferPlaceholders: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_4 { - Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAGS(0), - CompletionStatus: STATUS_CLOUD_NETWORK_UNAVAILABLE, - PlaceholderTotalCount: 0, - PlaceholderArray: std::ptr::null_mut(), - PlaceholderCount: 0, - EntriesProcessed: 0, + fn ack_placeholders_failed(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64) { + let op_info = + make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS); + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + TransferPlaceholders: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_4 { + Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAGS(0), + CompletionStatus: STATUS_CLOUD_NETWORK_UNAVAILABLE, + PlaceholderTotalCount: 0, + PlaceholderArray: std::ptr::null_mut(), + PlaceholderCount: 0, + EntriesProcessed: 0, + }, }, - }, - }; - // SAFETY: op_info and op_params are valid. - let _ = unsafe { CfExecute(&op_info, &mut op_params) }; -} + }; + // SAFETY: op_info and op_params are valid. + let _ = unsafe { CfExecute(&op_info, &mut op_params) }; + } -fn build_placeholder_create_info( - entry: &StorageEntry, - wide_name: &[u16], - identity: &[u8], -) -> CF_PLACEHOLDER_CREATE_INFO { - let ticks = unix_ms_to_filetime_ticks(entry.modified); - let attrs = if entry.is_dir { FILE_ATTRIBUTE_DIRECTORY.0 } else { FILE_ATTRIBUTE_NORMAL.0 }; - let file_size = if entry.is_dir { 0 } else { entry.size as i64 }; - CF_PLACEHOLDER_CREATE_INFO { - RelativeFileName: PCWSTR(wide_name.as_ptr()), - FsMetadata: CF_FS_METADATA { - BasicInfo: FILE_BASIC_INFO { - CreationTime: ticks, - LastAccessTime: ticks, - LastWriteTime: ticks, - ChangeTime: ticks, - FileAttributes: attrs, + fn build_placeholder_create_info( + entry: &StorageEntry, + wide_name: &[u16], + identity: &[u8], + ) -> CF_PLACEHOLDER_CREATE_INFO { + let ticks = unix_ms_to_filetime_ticks(entry.modified); + let attrs = if entry.is_dir { FILE_ATTRIBUTE_DIRECTORY.0 } else { FILE_ATTRIBUTE_NORMAL.0 }; + let file_size = if entry.is_dir { 0 } else { entry.size as i64 }; + CF_PLACEHOLDER_CREATE_INFO { + RelativeFileName: PCWSTR(wide_name.as_ptr()), + FsMetadata: CF_FS_METADATA { + BasicInfo: FILE_BASIC_INFO { + CreationTime: ticks, + LastAccessTime: ticks, + LastWriteTime: ticks, + ChangeTime: ticks, + FileAttributes: attrs, + }, + FileSize: file_size, }, - FileSize: file_size, - }, - FileIdentity: identity.as_ptr().cast(), - FileIdentityLength: identity.len() as u32, - Flags: CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC, - Result: windows::core::HRESULT(0), - CreateUsn: 0, + FileIdentity: identity.as_ptr().cast(), + FileIdentityLength: identity.len() as u32, + Flags: CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC, + Result: windows::core::HRESULT(0), + CreateUsn: 0, + } } -} -unsafe extern "system" fn on_fetch_data( - callback_info: *const CF_CALLBACK_INFO, - callback_parameters: *const CF_CALLBACK_PARAMETERS, -) { - // SAFETY: callback_info and callback_parameters are provided by Windows cfapi. - let Some((root, rel_path, connection_key, transfer_key, request_key)) = - (unsafe { callback_root_and_path(callback_info) }) - else { - return; - }; - let Some(params) = (unsafe { callback_parameters.as_ref() }) else { - return; - }; - // SAFETY: Union variant matches CF_CALLBACK_TYPE_FETCH_DATA. - let fetch = unsafe { params.Anonymous.FetchData }; - let required_offset = fetch.RequiredFileOffset.max(0) as u64; - let required_length = fetch.RequiredLength.max(0) as u64; - - core_host::spawn(async move { - let Some(node) = core_host::node() else { - ack_data_failed( - connection_key, - transfer_key, - request_key, - required_offset as i64, - STATUS_CLOUD_NETWORK_UNAVAILABLE, - ); + unsafe extern "system" fn on_fetch_data( + callback_info: *const CF_CALLBACK_INFO, + callback_parameters: *const CF_CALLBACK_PARAMETERS, + ) { + // SAFETY: callback_info and callback_parameters are provided by Windows cfapi. + let Some((root, rel_path, connection_key, transfer_key, request_key)) = + (unsafe { callback_root_and_path(callback_info) }) + else { return; }; + let Some(params) = (unsafe { callback_parameters.as_ref() }) else { + return; + }; + // SAFETY: Union variant matches CF_CALLBACK_TYPE_FETCH_DATA. + let fetch = unsafe { params.Anonymous.FetchData }; + let required_offset = fetch.RequiredFileOffset.max(0) as u64; + let required_length = fetch.RequiredLength.max(0) as u64; - let (meta, mut recv) = match node - .storage_read_stream(root.device, &rel_path, required_offset, Some(required_length)) - .await - { - Ok(res) => res, - Err(e) => { - tracing::debug!( - device = %root.device.short(), - error = %e, - "fetch data stream failed" - ); + core_host::spawn(async move { + let Some(node) = core_host::node() else { ack_data_failed( connection_key, transfer_key, @@ -727,505 +722,588 @@ unsafe extern "system" fn on_fetch_data( STATUS_CLOUD_NETWORK_UNAVAILABLE, ); return; - } - }; - let total_size = meta.size; - - if required_length == 0 { - let _ = transfer_data_chunk( - connection_key, - transfer_key, - request_key, - &[], - required_offset as i64, - STATUS_SUCCESS, - ); - return; - } + }; - let mut current_offset = required_offset; - let mut remaining = required_length; - let mut buf = Vec::with_capacity(HYDRATE_CHUNK_BYTES); - - while remaining > 0 { - let want = (remaining as usize).min(HYDRATE_CHUNK_BYTES); - buf.clear(); - while buf.len() < want { - match recv.read_chunk(want - buf.len()).await { - Ok(Some(chunk)) => buf.extend_from_slice(&chunk), - Ok(None) => break, - Err(e) => { - tracing::debug!(error = %e, "hydration stream read error"); - ack_data_failed( - connection_key, - transfer_key, - request_key, - current_offset as i64, - STATUS_CLOUD_NETWORK_UNAVAILABLE, - ); - return; - } + let (meta, mut recv) = match node + .storage_read_stream(root.device, &rel_path, required_offset, Some(required_length)) + .await + { + Ok(res) => res, + Err(e) => { + tracing::debug!( + device = %root.device.short(), + error = %e, + "fetch data stream failed" + ); + ack_data_failed( + connection_key, + transfer_key, + request_key, + required_offset as i64, + STATUS_CLOUD_NETWORK_UNAVAILABLE, + ); + return; } - } - if buf.is_empty() { - break; - } - let len = buf.len() as u64; - let completed = (current_offset + len).min(total_size); - // SAFETY: valid connection_key and transfer_key during active fetch. - let _ = unsafe { - CfReportProviderProgress(connection_key, transfer_key, total_size as i64, completed as i64) }; - if !transfer_data_chunk( - connection_key, - transfer_key, - request_key, - &buf, - current_offset as i64, - STATUS_SUCCESS, - ) { + let total_size = meta.size; + + if required_length == 0 { + let _ = transfer_data_chunk( + connection_key, + transfer_key, + request_key, + &[], + required_offset as i64, + STATUS_SUCCESS, + ); return; } - current_offset += len; - remaining = remaining.saturating_sub(len); - } - }); -} -fn transfer_data_chunk( - connection_key: CF_CONNECTION_KEY, - transfer_key: i64, - request_key: i64, - data: &[u8], - offset: i64, - status: NTSTATUS, -) -> bool { - let op_info = make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_DATA); - let mut op_params = CF_OPERATION_PARAMETERS { - ParamSize: std::mem::size_of::() as u32, - Anonymous: CF_OPERATION_PARAMETERS_0 { - TransferData: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_0 { - Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_TRANSFER_DATA_FLAGS(0), - CompletionStatus: status, - Buffer: if data.is_empty() { std::ptr::null() } else { data.as_ptr().cast() }, - Offset: offset, - Length: data.len() as i64, + let mut current_offset = required_offset; + let mut remaining = required_length; + let mut buf = Vec::with_capacity(HYDRATE_CHUNK_BYTES); + + while remaining > 0 { + let want = (remaining as usize).min(HYDRATE_CHUNK_BYTES); + buf.clear(); + while buf.len() < want { + match recv.read_chunk(want - buf.len()).await { + Ok(Some(chunk)) => buf.extend_from_slice(&chunk), + Ok(None) => break, + Err(e) => { + tracing::debug!(error = %e, "hydration stream read error"); + ack_data_failed( + connection_key, + transfer_key, + request_key, + current_offset as i64, + STATUS_CLOUD_NETWORK_UNAVAILABLE, + ); + return; + } + } + } + if buf.is_empty() { + break; + } + let len = buf.len() as u64; + let completed = (current_offset + len).min(total_size); + // SAFETY: valid connection_key and transfer_key during active fetch. + let _ = unsafe { + CfReportProviderProgress( + connection_key, + transfer_key, + total_size as i64, + completed as i64, + ) + }; + if !transfer_data_chunk( + connection_key, + transfer_key, + request_key, + &buf, + current_offset as i64, + STATUS_SUCCESS, + ) { + return; + } + current_offset += len; + remaining = remaining.saturating_sub(len); + } + }); + } + + fn transfer_data_chunk( + connection_key: CF_CONNECTION_KEY, + transfer_key: i64, + request_key: i64, + data: &[u8], + offset: i64, + status: NTSTATUS, + ) -> bool { + let op_info = + make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_DATA); + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + TransferData: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_0 { + Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_TRANSFER_DATA_FLAGS(0), + CompletionStatus: status, + Buffer: if data.is_empty() { std::ptr::null() } else { data.as_ptr().cast() }, + Offset: offset, + Length: data.len() as i64, + }, }, - }, - }; - // SAFETY: op_info and op_params reference valid memory for the duration of CfExecute. - unsafe { CfExecute(&op_info, &mut op_params) }.is_ok() -} + }; + // SAFETY: op_info and op_params reference valid memory for the duration of CfExecute. + unsafe { CfExecute(&op_info, &mut op_params) }.is_ok() + } -fn ack_data_failed( - connection_key: CF_CONNECTION_KEY, - transfer_key: i64, - request_key: i64, - offset: i64, - status: NTSTATUS, -) { - let _ = transfer_data_chunk(connection_key, transfer_key, request_key, &[], offset, status); -} + fn ack_data_failed( + connection_key: CF_CONNECTION_KEY, + transfer_key: i64, + request_key: i64, + offset: i64, + status: NTSTATUS, + ) { + let _ = transfer_data_chunk(connection_key, transfer_key, request_key, &[], offset, status); + } -unsafe extern "system" fn on_notify_rename( - callback_info: *const CF_CALLBACK_INFO, - callback_parameters: *const CF_CALLBACK_PARAMETERS, -) { - // SAFETY: callback_info and callback_parameters are provided by Windows cfapi. - let Some((root, old_rel, connection_key, transfer_key, request_key)) = - (unsafe { callback_root_and_path(callback_info) }) - else { - return; - }; - let Some(cb) = (unsafe { callback_info.as_ref() }) else { return }; - let Some(params) = (unsafe { callback_parameters.as_ref() }) else { return }; - // SAFETY: Union variant matches CF_CALLBACK_TYPE_NOTIFY_RENAME. - let rename = unsafe { params.Anonymous.Rename }; - // SAFETY: VolumeDosName and TargetPath are valid PCWSTRs. - let vol = unsafe { pcwstr_to_string(cb.VolumeDosName) }; - let target_norm = unsafe { pcwstr_to_string(rename.TargetPath) }; - let target_full = PathBuf::from(format!("{vol}{target_norm}")); - let new_rel = relative_path_under_root(&root.root_path, &target_full); - - core_host::spawn(async move { - let Some(node) = core_host::node() else { - ack_rename(connection_key, transfer_key, request_key, STATUS_CLOUD_NETWORK_UNAVAILABLE); + unsafe extern "system" fn on_notify_rename( + callback_info: *const CF_CALLBACK_INFO, + callback_parameters: *const CF_CALLBACK_PARAMETERS, + ) { + // SAFETY: callback_info and callback_parameters are provided by Windows cfapi. + let Some((root, old_rel, connection_key, transfer_key, request_key)) = + (unsafe { callback_root_and_path(callback_info) }) + else { return; }; - let status = match new_rel { - Some(new_rel) if !old_rel.is_empty() && !new_rel.is_empty() => { - match node.storage_rename(root.device, &old_rel, &new_rel).await { - Ok(()) => STATUS_SUCCESS, - Err(nectarlink_core::Error::Offline) => STATUS_CLOUD_NETWORK_UNAVAILABLE, - Err(nectarlink_core::Error::Denied) => STATUS_ACCESS_DENIED, - Err(_) => STATUS_CLOUD_UNSUCCESSFUL, + let Some(cb) = (unsafe { callback_info.as_ref() }) else { return }; + let Some(params) = (unsafe { callback_parameters.as_ref() }) else { return }; + // SAFETY: Union variant matches CF_CALLBACK_TYPE_NOTIFY_RENAME. + let rename = unsafe { params.Anonymous.Rename }; + // SAFETY: VolumeDosName and TargetPath are valid PCWSTRs. + let vol = unsafe { pcwstr_to_string(cb.VolumeDosName) }; + let target_norm = unsafe { pcwstr_to_string(rename.TargetPath) }; + let target_full = PathBuf::from(format!("{vol}{target_norm}")); + let new_rel = relative_path_under_root(&root.root_path, &target_full); + + core_host::spawn(async move { + let Some(node) = core_host::node() else { + ack_rename(connection_key, transfer_key, request_key, STATUS_CLOUD_NETWORK_UNAVAILABLE); + return; + }; + let status = match new_rel { + Some(new_rel) if !old_rel.is_empty() && !new_rel.is_empty() => { + match node.storage_rename(root.device, &old_rel, &new_rel).await { + Ok(()) => STATUS_SUCCESS, + Err(nectarlink_core::Error::Offline) => STATUS_CLOUD_NETWORK_UNAVAILABLE, + Err(nectarlink_core::Error::Denied) => STATUS_ACCESS_DENIED, + Err(_) => STATUS_CLOUD_UNSUCCESSFUL, + } } - } - _ => STATUS_ACCESS_DENIED, - }; - ack_rename(connection_key, transfer_key, request_key, status); - }); -} + _ => STATUS_ACCESS_DENIED, + }; + ack_rename(connection_key, transfer_key, request_key, status); + }); + } -fn ack_rename(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64, status: NTSTATUS) { - let op_info = make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_ACK_RENAME); - let mut op_params = CF_OPERATION_PARAMETERS { - ParamSize: std::mem::size_of::() as u32, - Anonymous: CF_OPERATION_PARAMETERS_0 { - AckRename: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_6 { - Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_ACK_RENAME_FLAGS(0), - CompletionStatus: status, + fn ack_rename(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64, status: NTSTATUS) { + let op_info = make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_ACK_RENAME); + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + AckRename: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_6 { + Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_ACK_RENAME_FLAGS(0), + CompletionStatus: status, + }, }, - }, - }; - // SAFETY: op_info and op_params are valid. - let _ = unsafe { CfExecute(&op_info, &mut op_params) }; -} - -unsafe extern "system" fn on_notify_delete( - callback_info: *const CF_CALLBACK_INFO, - _callback_parameters: *const CF_CALLBACK_PARAMETERS, -) { - // SAFETY: callback_info is provided by Windows cfapi. - let Some((root, rel_path, connection_key, transfer_key, request_key)) = - (unsafe { callback_root_and_path(callback_info) }) - else { - return; - }; + }; + // SAFETY: op_info and op_params are valid. + let _ = unsafe { CfExecute(&op_info, &mut op_params) }; + } - core_host::spawn(async move { - if rel_path.is_empty() { - ack_delete(connection_key, transfer_key, request_key, STATUS_ACCESS_DENIED); + unsafe extern "system" fn on_notify_delete( + callback_info: *const CF_CALLBACK_INFO, + _callback_parameters: *const CF_CALLBACK_PARAMETERS, + ) { + // SAFETY: callback_info is provided by Windows cfapi. + let Some((root, rel_path, connection_key, transfer_key, request_key)) = + (unsafe { callback_root_and_path(callback_info) }) + else { return; - } - let Some(node) = core_host::node() else { - ack_delete(connection_key, transfer_key, request_key, STATUS_CLOUD_NETWORK_UNAVAILABLE); - return; - }; - let status = match node.storage_delete(root.device, &rel_path, true).await { - Ok(()) => STATUS_SUCCESS, - Err(nectarlink_core::Error::Offline) => STATUS_CLOUD_NETWORK_UNAVAILABLE, - Err(nectarlink_core::Error::Denied) => STATUS_ACCESS_DENIED, - Err(nectarlink_core::Error::Storage(msg)) if msg.contains("not_found") => STATUS_SUCCESS, - Err(_) => STATUS_CLOUD_UNSUCCESSFUL, }; - ack_delete(connection_key, transfer_key, request_key, status); - }); -} - -fn ack_delete(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64, status: NTSTATUS) { - let op_info = make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_ACK_DELETE); - let mut op_params = CF_OPERATION_PARAMETERS { - ParamSize: std::mem::size_of::() as u32, - Anonymous: CF_OPERATION_PARAMETERS_0 { - AckDelete: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_7 { - Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_ACK_DELETE_FLAGS(0), - CompletionStatus: status, - }, - }, - }; - // SAFETY: op_info and op_params are valid. - let _ = unsafe { CfExecute(&op_info, &mut op_params) }; -} -/// Refreshes a folder's placeholders from the phone (`storage.changed` or initial connect). -async fn refresh_folder(root: Arc, rel_folder: &str) { - let Some(node) = core_host::node() else { return }; - let Ok(entries) = node.storage_list(root.device, rel_folder).await else { return }; + core_host::spawn(async move { + if rel_path.is_empty() { + ack_delete(connection_key, transfer_key, request_key, STATUS_ACCESS_DENIED); + return; + } + let Some(node) = core_host::node() else { + ack_delete(connection_key, transfer_key, request_key, STATUS_CLOUD_NETWORK_UNAVAILABLE); + return; + }; + let status = match node.storage_delete(root.device, &rel_path, true).await { + Ok(()) => STATUS_SUCCESS, + Err(nectarlink_core::Error::Offline) => STATUS_CLOUD_NETWORK_UNAVAILABLE, + Err(nectarlink_core::Error::Denied) => STATUS_ACCESS_DENIED, + Err(nectarlink_core::Error::Storage(msg)) if msg.contains("not_found") => STATUS_SUCCESS, + Err(_) => STATUS_CLOUD_UNSUCCESSFUL, + }; + ack_delete(connection_key, transfer_key, request_key, status); + }); + } - let local_dir = if rel_folder.is_empty() { - root.root_path.clone() - } else { - root.root_path.join(rel_folder.replace('/', "\\")) - }; - if !local_dir.exists() { - return; + fn ack_delete(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64, status: NTSTATUS) { + let op_info = make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_ACK_DELETE); + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + AckDelete: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_7 { + Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_ACK_DELETE_FLAGS(0), + CompletionStatus: status, + }, + }, + }; + // SAFETY: op_info and op_params are valid. + let _ = unsafe { CfExecute(&op_info, &mut op_params) }; } - lock(&root.populated_dirs).insert(rel_folder.to_owned()); + /// Refreshes a folder's placeholders from the phone (`storage.changed` or initial connect). + async fn refresh_folder(root: Arc, rel_folder: &str) { + let Some(node) = core_host::node() else { return }; + let Ok(entries) = node.storage_list(root.device, rel_folder).await else { return }; - let mut missing: Vec = Vec::new(); - for entry in entries { - let child_path = local_dir.join(&entry.name); - if !child_path.exists() { - missing.push(entry); + let local_dir = if rel_folder.is_empty() { + root.root_path.clone() + } else { + root.root_path.join(rel_folder.replace('/', "\\")) + }; + if !local_dir.exists() { + return; } - } - if missing.is_empty() { - return; - } - let mut wide_names: Vec> = Vec::with_capacity(missing.len()); - let mut identities: Vec> = Vec::with_capacity(missing.len()); - for entry in &missing { - wide_names.push(to_wide_null(&entry.name)); - let rel = - if rel_folder.is_empty() { entry.name.clone() } else { format!("{rel_folder}/{}", entry.name) }; - identities.push(rel.into_bytes()); - } - let mut create_infos: Vec = missing - .iter() - .enumerate() - .map(|(i, entry)| build_placeholder_create_info(entry, &wide_names[i], &identities[i])) - .collect(); - - let wide_dir = to_wide_null(&local_dir); - let mut processed = 0u32; - // SAFETY: wide_dir is null-terminated and create_infos stays alive for the call. - let _ = unsafe { - CfCreatePlaceholders( - PCWSTR(wide_dir.as_ptr()), - &mut create_infos, - windows::Win32::Storage::CloudFilters::CF_CREATE_FLAG_NONE, - Some(&mut processed), - ) - }; -} + lock(&root.populated_dirs).insert(rel_folder.to_owned()); -/// Watches the sync root directory for files or folders dropped in by the user -/// and uploads them to the phone via `storage.write` / `storage.mkdir`. -fn start_directory_watcher(root: Arc, stop: Arc) { - let _ = std::thread::Builder::new().name("storage-watch".into()).spawn(move || { - let wide_dir = to_wide_null(&root.root_path); - // SAFETY: wide_dir is a valid null-terminated path. - let dir_handle = unsafe { - CreateFileW( + let mut missing: Vec = Vec::new(); + for entry in entries { + let child_path = local_dir.join(&entry.name); + if !child_path.exists() { + missing.push(entry); + } + } + if missing.is_empty() { + return; + } + + let mut wide_names: Vec> = Vec::with_capacity(missing.len()); + let mut identities: Vec> = Vec::with_capacity(missing.len()); + for entry in &missing { + wide_names.push(to_wide_null(&entry.name)); + let rel = if rel_folder.is_empty() { + entry.name.clone() + } else { + format!("{rel_folder}/{}", entry.name) + }; + identities.push(rel.into_bytes()); + } + let mut create_infos: Vec = missing + .iter() + .enumerate() + .map(|(i, entry)| build_placeholder_create_info(entry, &wide_names[i], &identities[i])) + .collect(); + + let wide_dir = to_wide_null(&local_dir); + let mut processed = 0u32; + // SAFETY: wide_dir is null-terminated and create_infos stays alive for the call. + let _ = unsafe { + CfCreatePlaceholders( PCWSTR(wide_dir.as_ptr()), - FILE_LIST_DIRECTORY.0, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - None, - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS, - None, + &mut create_infos, + windows::Win32::Storage::CloudFilters::CF_CREATE_FLAG_NONE, + Some(&mut processed), ) }; - let Ok(dir_handle) = dir_handle else { return }; - if dir_handle == INVALID_HANDLE_VALUE { - return; - } + } - let mut buf = vec![0u64; 4096]; - while !stop.load(Ordering::Relaxed) { - let mut bytes_returned = 0u32; - // SAFETY: buf is 8-byte aligned and valid for ReadDirectoryChangesW. - let ok = unsafe { - ReadDirectoryChangesW( - dir_handle, - buf.as_mut_ptr().cast(), - (buf.len() * std::mem::size_of::()) as u32, - true, - FILE_NOTIFY_CHANGE_FILE_NAME - | FILE_NOTIFY_CHANGE_DIR_NAME - | FILE_NOTIFY_CHANGE_LAST_WRITE - | FILE_NOTIFY_CHANGE_SIZE, - Some(&mut bytes_returned), + /// Watches the sync root directory for files or folders dropped in by the user + /// and uploads them to the phone via `storage.write` / `storage.mkdir`. + fn start_directory_watcher(root: Arc, stop: Arc) { + let _ = std::thread::Builder::new().name("storage-watch".into()).spawn(move || { + let wide_dir = to_wide_null(&root.root_path); + // SAFETY: wide_dir is a valid null-terminated path. + let dir_handle = unsafe { + CreateFileW( + PCWSTR(wide_dir.as_ptr()), + FILE_LIST_DIRECTORY.0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, None, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, None, ) + }; + let Ok(dir_handle) = dir_handle else { return }; + if dir_handle == INVALID_HANDLE_VALUE { + return; } - .is_ok(); - - if !ok || stop.load(Ordering::Relaxed) || bytes_returned == 0 { - break; - } - - let changed_paths = parse_notify_buffer( - // SAFETY: bytes_returned bytes were initialized by ReadDirectoryChangesW. - unsafe { std::slice::from_raw_parts(buf.as_ptr().cast::(), bytes_returned as usize) }, - ); - for rel_win in changed_paths { - let rel_slash = rel_win.replace('\\', "/"); - if rel_slash.is_empty() - || rel_slash.split('/').any(|seg| seg.starts_with('.') || seg.starts_with("~$")) - { - continue; + let mut buf = vec![0u64; 4096]; + while !stop.load(Ordering::Relaxed) { + let mut bytes_returned = 0u32; + // SAFETY: buf is 8-byte aligned and valid for ReadDirectoryChangesW. + let ok = unsafe { + ReadDirectoryChangesW( + dir_handle, + buf.as_mut_ptr().cast(), + (buf.len() * std::mem::size_of::()) as u32, + true, + FILE_NOTIFY_CHANGE_FILE_NAME + | FILE_NOTIFY_CHANGE_DIR_NAME + | FILE_NOTIFY_CHANGE_LAST_WRITE + | FILE_NOTIFY_CHANGE_SIZE, + Some(&mut bytes_returned), + None, + None, + ) } - let full_path = root.root_path.join(&rel_win); - if !needs_upload(&full_path) { - continue; + .is_ok(); + + if !ok || stop.load(Ordering::Relaxed) || bytes_returned == 0 { + break; } - { - let mut uploading = lock(&root.uploading); - if !uploading.insert(rel_slash.clone()) { + + let changed_paths = parse_notify_buffer( + // SAFETY: bytes_returned bytes were initialized by ReadDirectoryChangesW. + unsafe { std::slice::from_raw_parts(buf.as_ptr().cast::(), bytes_returned as usize) }, + ); + + for rel_win in changed_paths { + let rel_slash = rel_win.replace('\\', "/"); + if rel_slash.is_empty() + || rel_slash.split('/').any(|seg| seg.starts_with('.') || seg.starts_with("~$")) + { continue; } + let full_path = root.root_path.join(&rel_win); + if !needs_upload(&full_path) { + continue; + } + { + let mut uploading = lock(&root.uploading); + if !uploading.insert(rel_slash.clone()) { + continue; + } + } + let root_clone = root.clone(); + core_host::spawn(async move { + // Brief debounce so Explorer finishes writing the dropped file. + tokio::time::sleep(Duration::from_millis(350)).await; + upload_local_item(&root_clone, &rel_slash, &full_path).await; + lock(&root_clone.uploading).remove(&rel_slash); + }); } - let root_clone = root.clone(); - core_host::spawn(async move { - // Brief debounce so Explorer finishes writing the dropped file. - tokio::time::sleep(Duration::from_millis(350)).await; - upload_local_item(&root_clone, &rel_slash, &full_path).await; - lock(&root_clone.uploading).remove(&rel_slash); - }); } - } - // SAFETY: dir_handle was opened with CreateFileW above. - let _ = unsafe { CloseHandle(dir_handle) }; - }); -} + // SAFETY: dir_handle was opened with CreateFileW above. + let _ = unsafe { CloseHandle(dir_handle) }; + }); + } -fn parse_notify_buffer(bytes: &[u8]) -> Vec { - let mut out = Vec::new(); - let mut offset = 0usize; - while offset + std::mem::size_of::() <= bytes.len() { - // SAFETY: offset is within bounds and 4-byte aligned in ReadDirectoryChangesW buffer. - let info = unsafe { &*(bytes.as_ptr().add(offset) as *const FILE_NOTIFY_INFORMATION) }; - let name_bytes = info.FileNameLength as usize; - let name_offset = offset + std::mem::offset_of!(FILE_NOTIFY_INFORMATION, FileName); - if name_offset + name_bytes <= bytes.len() - && matches!(info.Action, FILE_ACTION_ADDED | FILE_ACTION_MODIFIED | FILE_ACTION_RENAMED_NEW_NAME) - { - // SAFETY: FileName is a UTF-16 slice of `name_bytes / 2` u16 elements. - let wide = unsafe { - std::slice::from_raw_parts(bytes.as_ptr().add(name_offset) as *const u16, name_bytes / 2) - }; - let rel = std::ffi::OsString::from_wide(wide).to_string_lossy().into_owned(); - if !out.contains(&rel) { - out.push(rel); + fn parse_notify_buffer(bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut offset = 0usize; + while offset + std::mem::size_of::() <= bytes.len() { + // SAFETY: offset is within bounds and 4-byte aligned in ReadDirectoryChangesW buffer. + let info = unsafe { &*(bytes.as_ptr().add(offset) as *const FILE_NOTIFY_INFORMATION) }; + let name_bytes = info.FileNameLength as usize; + let name_offset = offset + std::mem::offset_of!(FILE_NOTIFY_INFORMATION, FileName); + if name_offset + name_bytes <= bytes.len() + && matches!( + info.Action, + FILE_ACTION_ADDED | FILE_ACTION_MODIFIED | FILE_ACTION_RENAMED_NEW_NAME + ) + { + // SAFETY: FileName is a UTF-16 slice of `name_bytes / 2` u16 elements. + let wide = unsafe { + std::slice::from_raw_parts(bytes.as_ptr().add(name_offset) as *const u16, name_bytes / 2) + }; + let rel = std::ffi::OsString::from_wide(wide).to_string_lossy().into_owned(); + if !out.contains(&rel) { + out.push(rel); + } } + if info.NextEntryOffset == 0 { + break; + } + offset = offset.saturating_add(info.NextEntryOffset as usize); } - if info.NextEntryOffset == 0 { - break; - } - offset = offset.saturating_add(info.NextEntryOffset as usize); + out } - out -} -/// Checks whether `full_path` is a newly dropped non-placeholder file/folder or -/// a modified placeholder that is no longer in sync. -fn needs_upload(full_path: &Path) -> bool { - let wide = to_wide_null(full_path); - let mut find_data = WIN32_FIND_DATAW::default(); - // SAFETY: wide is null-terminated and find_data is a valid struct. - let handle = unsafe { FindFirstFileW(PCWSTR(wide.as_ptr()), &mut find_data) }; - let Ok(handle) = handle else { return false }; - if handle == INVALID_HANDLE_VALUE { - return false; + /// Checks whether `full_path` is a newly dropped non-placeholder file/folder or + /// a modified placeholder that is no longer in sync. + fn needs_upload(full_path: &Path) -> bool { + let wide = to_wide_null(full_path); + let mut find_data = WIN32_FIND_DATAW::default(); + // SAFETY: wide is null-terminated and find_data is a valid struct. + let handle = unsafe { FindFirstFileW(PCWSTR(wide.as_ptr()), &mut find_data) }; + let Ok(handle) = handle else { return false }; + if handle == INVALID_HANDLE_VALUE { + return false; + } + // SAFETY: handle came from FindFirstFileW. + let _ = unsafe { FindClose(handle) }; + // SAFETY: dwFileAttributes and dwReserved0 (reparse tag) were populated by FindFirstFileW. + let state = unsafe { + CfGetPlaceholderStateFromAttributeTag(find_data.dwFileAttributes, find_data.dwReserved0) + }; + if (state.0 & CF_PLACEHOLDER_STATE_PLACEHOLDER.0) == 0 { + return true; + } + (state.0 & CF_PLACEHOLDER_STATE_IN_SYNC.0) == 0 } - // SAFETY: handle came from FindFirstFileW. - let _ = unsafe { FindClose(handle) }; - // SAFETY: dwFileAttributes and dwReserved0 (reparse tag) were populated by FindFirstFileW. - let state = - unsafe { CfGetPlaceholderStateFromAttributeTag(find_data.dwFileAttributes, find_data.dwReserved0) }; - if (state.0 & CF_PLACEHOLDER_STATE_PLACEHOLDER.0) == 0 { - return true; + + async fn upload_local_item(root: &Arc, rel_slash: &str, full_path: &Path) { + if !full_path.exists() || !needs_upload(full_path) { + return; + } + let Some(node) = core_host::node() else { return }; + if full_path.is_dir() { + if node.storage_mkdir(root.device, rel_slash).await.is_ok() { + mark_path_in_sync(full_path, rel_slash, true); + } + } else if full_path.is_file() && node.storage_write(root.device, rel_slash, full_path).await.is_ok() { + mark_path_in_sync(full_path, rel_slash, false); + } } - (state.0 & CF_PLACEHOLDER_STATE_IN_SYNC.0) == 0 -} -async fn upload_local_item(root: &Arc, rel_slash: &str, full_path: &Path) { - if !full_path.exists() || !needs_upload(full_path) { - return; + fn mark_path_in_sync(full_path: &Path, rel_slash: &str, is_dir: bool) { + let wide = to_wide_null(full_path); + let flags = if is_dir { FILE_FLAG_BACKUP_SEMANTICS } else { Default::default() }; + // SAFETY: wide is a valid null-terminated path. + let handle = unsafe { + CreateFileW( + PCWSTR(wide.as_ptr()), + GENERIC_READ.0 | GENERIC_WRITE.0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + None, + OPEN_EXISTING, + flags, + None, + ) + }; + let Ok(handle): Result = handle else { return }; + if handle == INVALID_HANDLE_VALUE { + return; + } + let identity = rel_slash.as_bytes(); + // SAFETY: handle is open with read/write access; identity is valid for the call. + let converted = unsafe { + CfConvertToPlaceholder( + handle, + Some(identity.as_ptr().cast()), + identity.len() as u32, + CF_CONVERT_FLAG_MARK_IN_SYNC, + None, + None, + ) + }; + if converted.is_err() { + // Already a placeholder: mark it in sync. + // SAFETY: handle is valid. + let _ = + unsafe { CfSetInSyncState(handle, CF_IN_SYNC_STATE_IN_SYNC, CF_SET_IN_SYNC_FLAG_NONE, None) }; + } + // SAFETY: handle was opened with CreateFileW above. + let _ = unsafe { CloseHandle(handle) }; } - let Some(node) = core_host::node() else { return }; - if full_path.is_dir() { - if node.storage_mkdir(root.device, rel_slash).await.is_ok() { - mark_path_in_sync(full_path, rel_slash, true); + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn converts_unix_ms_to_filetime_and_back() { + let unix_ms = 1_710_000_000_123i64; + let ticks = unix_ms_to_filetime_ticks(unix_ms); + assert_eq!(ticks, EPOCH_DIFF_100NS + unix_ms * 10_000); + assert_eq!(filetime_ticks_to_unix_ms(ticks), unix_ms); + assert_eq!(unix_ms_to_filetime_ticks(-500), EPOCH_DIFF_100NS); + } + + #[test] + fn builds_placeholder_metadata_for_files_and_folders() { + let file_entry = StorageEntry { + name: "photo.jpg".into(), + size: 42_000, + modified: 1_700_000_000_000, + is_dir: false, + }; + let wide_file = to_wide_null(&file_entry.name); + let id_file = b"DCIM/photo.jpg"; + let info_file = build_placeholder_create_info(&file_entry, &wide_file, id_file); + assert_eq!(info_file.FsMetadata.FileSize, 42_000); + assert_eq!(info_file.FsMetadata.BasicInfo.FileAttributes, FILE_ATTRIBUTE_NORMAL.0); + assert_eq!( + info_file.FsMetadata.BasicInfo.LastWriteTime, + unix_ms_to_filetime_ticks(1_700_000_000_000) + ); + assert_eq!(info_file.FileIdentityLength, id_file.len() as u32); + + let dir_entry = + StorageEntry { name: "Camera".into(), size: 999, modified: 1_700_000_000_000, is_dir: true }; + let wide_dir = to_wide_null(&dir_entry.name); + let id_dir = b"DCIM/Camera"; + let info_dir = build_placeholder_create_info(&dir_entry, &wide_dir, id_dir); + assert_eq!(info_dir.FsMetadata.FileSize, 0); + assert_eq!(info_dir.FsMetadata.BasicInfo.FileAttributes, FILE_ATTRIBUTE_DIRECTORY.0); + } + + #[test] + fn normalizes_relative_paths_and_sanitizes_folder_names() { + let root = Path::new(r"C:\Users\alice\Nectarlink\Pixel 9"); + assert_eq!(relative_path_under_root(root, root), Some(String::new())); + assert_eq!( + relative_path_under_root( + root, + Path::new(r"C:\Users\alice\Nectarlink\Pixel 9\DCIM\Camera\a.jpg") + ), + Some("DCIM/Camera/a.jpg".into()) + ); + assert_eq!( + relative_path_under_root(root, Path::new(r"C:\Users\alice\Nectarlink\Pixel 9 Pro\DCIM")), + None + ); + + assert_eq!(sanitize_folder_name("Pixel 9 (test)"), "Pixel 9 (test)"); + assert_eq!(sanitize_folder_name("My:Phone*1?"), "My Phone 1"); + assert_eq!(sanitize_folder_name("CON"), "CON phone"); } - } else if full_path.is_file() && node.storage_write(root.device, rel_slash, full_path).await.is_ok() { - mark_path_in_sync(full_path, rel_slash, false); } } -fn mark_path_in_sync(full_path: &Path, rel_slash: &str, is_dir: bool) { - let wide = to_wide_null(full_path); - let flags = if is_dir { FILE_FLAG_BACKUP_SEMANTICS } else { Default::default() }; - // SAFETY: wide is a valid null-terminated path. - let handle = unsafe { - CreateFileW( - PCWSTR(wide.as_ptr()), - GENERIC_READ.0 | GENERIC_WRITE.0, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - None, - OPEN_EXISTING, - flags, - None, - ) - }; - let Ok(handle): Result = handle else { return }; - if handle == INVALID_HANDLE_VALUE { - return; +#[cfg(not(windows))] +mod imp { + #![allow(dead_code)] + + use nectarlink_core::{DeviceId, NodeEvent}; + use std::path::{Path, PathBuf}; + + pub fn start() {} + + pub fn sync() {} + + pub fn on_event(_event: &NodeEvent) {} + + pub fn shutdown() {} + + pub fn unregister_all() {} + + pub fn sync_root_path_for(_device: DeviceId) -> Option { + None } - let identity = rel_slash.as_bytes(); - // SAFETY: handle is open with read/write access; identity is valid for the call. - let converted = unsafe { - CfConvertToPlaceholder( - handle, - Some(identity.as_ptr().cast()), - identity.len() as u32, - CF_CONVERT_FLAG_MARK_IN_SYNC, - None, - None, - ) - }; - if converted.is_err() { - // Already a placeholder: mark it in sync. - // SAFETY: handle is valid. - let _ = unsafe { CfSetInSyncState(handle, CF_IN_SYNC_STATE_IN_SYNC, CF_SET_IN_SYNC_FLAG_NONE, None) }; + + pub fn open_in_explorer(_device: DeviceId) -> Result<(), String> { + Err("phone storage exploration is not supported on this platform".into()) } - // SAFETY: handle was opened with CreateFileW above. - let _ = unsafe { CloseHandle(handle) }; -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn converts_unix_ms_to_filetime_and_back() { - let unix_ms = 1_710_000_000_123i64; - let ticks = unix_ms_to_filetime_ticks(unix_ms); - assert_eq!(ticks, EPOCH_DIFF_100NS + unix_ms * 10_000); - assert_eq!(filetime_ticks_to_unix_ms(ticks), unix_ms); - assert_eq!(unix_ms_to_filetime_ticks(-500), EPOCH_DIFF_100NS); + pub fn sanitize_folder_name(device_name: &str) -> String { + device_name.chars().filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')).collect() } - #[test] - fn builds_placeholder_metadata_for_files_and_folders() { - let file_entry = StorageEntry { - name: "photo.jpg".into(), - size: 42_000, - modified: 1_700_000_000_000, - is_dir: false, - }; - let wide_file = to_wide_null(&file_entry.name); - let id_file = b"DCIM/photo.jpg"; - let info_file = build_placeholder_create_info(&file_entry, &wide_file, id_file); - assert_eq!(info_file.FsMetadata.FileSize, 42_000); - assert_eq!(info_file.FsMetadata.BasicInfo.FileAttributes, FILE_ATTRIBUTE_NORMAL.0); - assert_eq!( - info_file.FsMetadata.BasicInfo.LastWriteTime, - unix_ms_to_filetime_ticks(1_700_000_000_000) - ); - assert_eq!(info_file.FileIdentityLength, id_file.len() as u32); - - let dir_entry = - StorageEntry { name: "Camera".into(), size: 999, modified: 1_700_000_000_000, is_dir: true }; - let wide_dir = to_wide_null(&dir_entry.name); - let id_dir = b"DCIM/Camera"; - let info_dir = build_placeholder_create_info(&dir_entry, &wide_dir, id_dir); - assert_eq!(info_dir.FsMetadata.FileSize, 0); - assert_eq!(info_dir.FsMetadata.BasicInfo.FileAttributes, FILE_ATTRIBUTE_DIRECTORY.0); + pub fn unix_ms_to_filetime_ticks(unix_ms: i64) -> i64 { + unix_ms * 10_000 + 116_444_736_000_000_000 } - #[test] - fn normalizes_relative_paths_and_sanitizes_folder_names() { - let root = Path::new(r"C:\Users\alice\Nectarlink\Pixel 9"); - assert_eq!(relative_path_under_root(root, root), Some(String::new())); - assert_eq!( - relative_path_under_root(root, Path::new(r"C:\Users\alice\Nectarlink\Pixel 9\DCIM\Camera\a.jpg")), - Some("DCIM/Camera/a.jpg".into()) - ); - assert_eq!( - relative_path_under_root(root, Path::new(r"C:\Users\alice\Nectarlink\Pixel 9 Pro\DCIM")), - None - ); + pub fn filetime_ticks_to_unix_ms(ticks: i64) -> i64 { + (ticks - 116_444_736_000_000_000) / 10_000 + } - assert_eq!(sanitize_folder_name("Pixel 9 (test)"), "Pixel 9 (test)"); - assert_eq!(sanitize_folder_name("My:Phone*1?"), "My Phone 1"); - assert_eq!(sanitize_folder_name("CON"), "CON phone"); + pub fn relative_path_under_root(root_path: &Path, full_path: &Path) -> Option { + full_path.strip_prefix(root_path).ok().and_then(|p| p.to_str()).map(|s| s.replace('\\', "/")) } } + +pub use imp::*; diff --git a/desktop/app/src/updater.rs b/desktop/app/src/updater.rs index f85ca043..c8941685 100644 --- a/desktop/app/src/updater.rs +++ b/desktop/app/src/updater.rs @@ -25,8 +25,9 @@ //! update didn't go through. use crate::i18n::tr; +#[cfg(windows)] +use std::os::windows::process::CommandExt; use std::{ - os::windows::process::CommandExt, path::{Path, PathBuf}, process::ExitCode, sync::{ @@ -39,10 +40,12 @@ use std::{ use serde::Deserialize; use sha2::{Digest, Sha256}; +#[cfg(windows)] +use crate::win::autostart; use crate::{ core_host, state::Changes, - win::{autostart, http, toast}, + win::{http, toast}, }; const LATEST: &str = "https://api.github.com/repos/thtbee/nectarlink/releases/latest"; @@ -64,6 +67,7 @@ pub struct Update { } /// The scheduled task the installer registers (see the module docs). +#[cfg(windows)] pub const TASK: &str = "Nectarlink Update"; /// What the task runs: `nectarlink-desktop.exe --update-service`. pub const SERVICE_ARG: &str = "--update-service"; @@ -71,7 +75,9 @@ pub const SERVICE_ARG: &str = "--update-service"; const QUIET_FOR: Duration = Duration::from_secs(10 * 60); /// While an update waits for a quiet moment, how often to look for one. const LOOK_FOR_QUIET: Duration = Duration::from_secs(5 * 60); +#[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; +#[cfg(windows)] const DETACHED_PROCESS: u32 = 0x0000_0008; static AUTO: AtomicBool = AtomicBool::new(true); @@ -100,6 +106,10 @@ pub fn set_auto_install(on: bool) { /// Whether this copy updates itself (an installed one, not a build run /// from its folder). pub fn can_update() -> bool { + #[cfg(not(windows))] + return false; + + #[cfg(windows)] std::env::current_exe().is_ok_and(|exe| autostart::is_installed(&exe)) } @@ -217,6 +227,9 @@ const INSTALLER_SUFFIX: &str = /// The release as an update, if it's a newer published one with an /// installer for this architecture and its sums. fn newer_release(release: Release, current: &str) -> Option { + if !cfg!(windows) { + return None; + } newer_release_for(release, current, INSTALLER_SUFFIX) } @@ -305,13 +318,20 @@ fn quiet() -> bool { /// Whether the installer registered [`TASK`] on this PC. fn task_registered() -> bool { - std::process::Command::new("schtasks.exe") - .args(["/Query", "/TN", TASK]) - .creation_flags(CREATE_NO_WINDOW) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()) + #[cfg(windows)] + { + std::process::Command::new("schtasks.exe") + .args(["/Query", "/TN", TASK]) + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + } + #[cfg(not(windows))] + { + false + } } /// Installs the update found: downloads it (reporting `progress(done, @@ -320,20 +340,28 @@ fn task_registered() -> bool { /// Blocking. Returns what went wrong, in words for the user. On success the /// app is about to quit for the installer. pub fn install(progress: impl FnMut(u64, u64)) -> Result<(), String> { - let update = available().ok_or("There's no update to install.")?; - let staged = stage(&update, progress).map_err(|e| { - tracing::warn!(error = %e, "update download failed"); - e.for_user() - })?; - if task_registered() { - match install_with_task(true) { - Ok(()) => return Ok(()), - Err(e) => { - tracing::warn!(error = e, "the update task didn't start; asking for permission instead") + #[cfg(not(windows))] + { + let _ = progress; + Err("Updates are not supported on this platform.".into()) + } + #[cfg(windows)] + { + let update = available().ok_or("There's no update to install.")?; + let staged = stage(&update, progress).map_err(|e| { + tracing::warn!(error = %e, "update download failed"); + e.for_user() + })?; + if task_registered() { + match install_with_task(true) { + Ok(()) => return Ok(()), + Err(e) => { + tracing::warn!(error = e, "the update task didn't start; asking for permission instead") + } } } + install_with_prompt(&update, &staged) } - install_with_prompt(&update, &staged) } /// Why a download didn't produce a checked installer. @@ -445,50 +473,68 @@ pub fn stage(update: &Update, progress: impl FnMut(u64, u64)) -> Result