183 Commits
Author SHA1 Message Date
Leon Böttger 20d019e109 Bump build number 2026-07-03 12:19:15 +02:00
Leon Böttger 606446a1b9 Register latest share extension during development 2026-07-03 12:10:33 +02:00
Leon Böttger f6588c3c43 Fix error message on share extension disappearing too quickly 2026-07-03 12:10:12 +02:00
Leon Böttger 28178a7e6a Bump version 2026-07-02 19:53:28 +02:00
Leon Böttger e9b6a477fc Always make alerts non-blocking 2026-06-30 18:32:22 +02:00
Leon Böttger 7c35cc7b35 Improve fix instructions if other device is already running QuickDrop 2026-06-30 18:12:33 +02:00
Leon Böttger 664db36267 Fix alert enables app icon in dock 2026-06-19 13:52:08 +02:00
Leon Böttger 167542f0fa Fix macOS 27 share extension 2026-06-15 21:59:44 +02:00
Leon Böttger cff6b8633f Revert OneUI 8.5 Strings 2026-06-15 19:47:23 +02:00
Leon Böttger 295b97278c Package updates 2026-06-14 21:34:40 +02:00
Leon Böttger 7d82520c2f Add AirDrop detection for iOS 2026-06-14 21:01:59 +02:00
Leon Böttger d98be9c23c Add AirDrop detection 2026-06-14 15:55:18 +02:00
Leon Böttger 9de9ebb4ba Fix capitilization of toast view subtitle 2026-06-03 19:07:01 +02:00
Leon Böttger b5de24d926 Improve user manual 2026-06-02 21:30:44 +02:00
Leon Böttger 28e0ede409 Dont gray out the menu bar status label 2026-06-02 19:36:05 +02:00
Leon Böttger dd9462d9f5 Bump build number 2026-05-23 14:46:52 +02:00
Leon Böttger 238769656a Avoid link local IPv6 addresses 2026-05-20 19:38:23 +02:00
Leon Böttger fd29254e38 Extract NearbyBonjourServiceResolver 2026-05-20 19:21:54 +02:00
Leon Böttger 6b0e14de3e More robust outgoing connection 2026-05-19 21:48:26 +02:00
Leon Böttger db70d40e43 Fix clipboard notification occasionally not appearing 2026-05-19 21:29:18 +02:00
Leon Böttger 4394b78242 Update version 2026-05-18 21:18:56 +02:00
Leon Böttger 488f1a14b2 Only poll clipboard if clipboard sync was enabled 2026-05-16 14:51:07 +02:00
Leon Böttger 72bfd88161 Add clipboard sync pairing 2026-05-16 14:46:22 +02:00
Leon Böttger 2f6b0ff5ba Add clipboard sync notification 2026-05-14 15:49:50 +02:00
Leon Böttger 5fdc5f8a1b Add Clipboard Listener 2026-05-14 14:44:17 +02:00
Leon Böttger ee091f60cd Update Screenshots 2026-05-14 10:54:45 +02:00
Leon Böttger 6c00c0f321 Package update 2026-05-14 10:54:32 +02:00
Leon Böttger 65a0ab3226 Improve iOS UI 2026-05-02 19:31:49 +02:00
Leon Böttger f5ca60a0ad Bump version 2026-05-01 10:43:40 +02:00
Leon Böttger ce16bca415 Fix zipping large file freezes app 2026-04-27 21:03:04 +02:00
Leon Böttger e6d1c7b1a3 Add device name lookup for unnamed devices 2026-04-27 20:29:35 +02:00
Leon Böttger 84d2150893 Always try to send logs on contact 2026-04-17 21:02:16 +02:00
Leon Böttger 007784c483 More robust connection 2026-04-17 19:34:11 +02:00
Leon Böttger 78cf2309f9 Package updates 2026-04-12 15:14:06 +02:00
Leon Böttger 976fc280db NearbyConnection: Fix missing disconnect handler 2026-04-10 19:34:18 +02:00
Leon Böttger 02ab04c69e Fix toast view liquid glass not updating background 2026-04-05 11:24:08 +02:00
Leon Böttger fcbe99dd85 Formatting 2026-04-05 11:05:55 +02:00
Leon Böttger feaf557ca2 Fix outgoing transfer flakiness 2026-04-03 19:19:22 +02:00
Leon Böttger 9a488a2ae2 Refactoring 2026-04-01 19:00:24 +02:00
Leon Böttger 80b81ae228 Fix ToastView progress bar animation 2026-03-31 21:30:17 +02:00
Leon Böttger 0112e512c0 Add notification sync documentation 2026-03-31 21:19:09 +02:00
Leon Böttger 0d0b720114 Bump version number 2026-03-25 08:44:45 +01:00
Leon Böttger 13f3073077 Update Mac background colors 2026-03-25 07:19:17 +01:00
Leon Böttger ff39ba4c7d Bump version 2026-03-22 15:38:39 +01:00
Leon Böttger 245358ae96 Send keepalive ack during handshake 2026-03-21 21:51:19 +01:00
Leon Böttger 429069f372 Don't send keepalive too early 2026-03-21 21:16:26 +01:00
Leon Böttger 04e056da02 Ignore keepalive during handshake 2026-03-21 21:09:54 +01:00
Leon Böttger 50d4d4ee92 Change toast view close button position 2026-03-21 13:47:20 +01:00
Leon Böttger 86b7e4ca24 Safer dataTransferInactivityTimer handling 2026-03-21 11:56:05 +01:00
Leon Böttger d3a95c068f Improve logging 2026-03-21 11:32:41 +01:00
Leon Böttger e3cbc19f11 Re-add openLog 2026-03-21 11:15:15 +01:00
Leon Böttger c46f6eedbc Extract pairing proto 2026-03-18 20:05:07 +01:00
Leon Böttger ea213906d6 Fix cannot share screenshots 2026-03-17 21:05:01 +01:00
Leon Böttger 4700a9c56e Package updates 2026-03-16 21:27:25 +01:00
Leon Böttger 4fe30da52b Improve Strings 2026-03-16 21:27:02 +01:00
Leon Böttger bd93961c59 Ensure P256 derived field length 2026-03-16 21:26:20 +01:00
Leon Böttger f244572508 Improve Strings 2026-03-16 19:10:20 +01:00
Leon Böttger d4ea143d51 UI Improvements 2026-03-16 07:22:35 +01:00
Leon Böttger 2dbc46f792 Fix init order 2026-03-15 21:18:33 +01:00
Leon Böttger f61be69507 Add QR code based pairing 2026-03-15 21:00:12 +01:00
Leon Böttger 82f40d780f PIN check for pairing 2026-03-15 15:57:56 +01:00
Leon Böttger 606082d76d Improve toast view UI 2026-03-15 14:56:53 +01:00
Leon Böttger 2233699ece Toast View adjustment 2026-03-15 13:59:08 +01:00
Leon Böttger 130935c01d Use ToastView for notification setup 2026-03-14 21:20:45 +01:00
Leon Böttger 74226d2fc3 Order TrustedDevicesView by date 2026-03-14 19:35:53 +01:00
Leon Böttger e895e7a68e Better device name fallback 2026-03-14 11:36:44 +01:00
Leon Böttger 843ed9fe4f Use Keychain for trusted devices 2026-03-14 11:33:27 +01:00
Leon Böttger 2ef3bc3b44 Add auth for incoming transfers 2026-03-14 11:15:15 +01:00
Leon Böttger 216e2cf851 Add nync to protobuf 2026-03-14 08:26:00 +01:00
Leon Böttger d63665136b Remove liquid glass force refresh 2026-03-13 21:20:50 +01:00
Leon Böttger 42c101605f Show unknown devicrd 2026-03-13 19:48:36 +01:00
Leon Böttger fd1dfb4f4a Always allow incoming notifications 2026-03-13 19:09:03 +01:00
Leon Böttger 767a5c5706 Bump build number 2026-03-11 21:25:28 +01:00
Leon Böttger 8e78f58b5e Fix log sending from share extension 2026-03-11 21:08:56 +01:00
Leon Böttger 935bdc5a65 Fix liquid glass background refresh 2026-03-11 21:05:31 +01:00
Leon Böttger f559a6ac9d Improve logging 2026-03-11 20:48:19 +01:00
Leon Böttger e853ba2b36 Improve logging 2026-03-11 20:23:13 +01:00
Leon Böttger 4621395a87 Package updates 2026-03-11 19:58:26 +01:00
Leon Böttger 55e2a72a90 Fix notification sync sematics 2026-03-11 19:05:38 +01:00
Leon Böttger 159e83179b Improve Strings 2026-03-11 19:00:57 +01:00
Leon Böttger 6a77e4aba6 Bump version 2026-03-11 18:46:15 +01:00
Leon Böttger ffe3ac7a4a Bump version 2026-03-08 16:17:50 +01:00
Leon Böttger 2dbe945f3e Better out-of-frame detection 2026-03-08 16:15:21 +01:00
Leon Böttger e828edd6ae Better error handling 2026-03-08 15:44:25 +01:00
Leon Böttger 7fbe254118 Ignore duplicate sequence numbers 2026-03-08 15:42:12 +01:00
Leon Böttger cffaabc076 Fail fast on outbound rejection 2026-03-08 15:39:17 +01:00
Leon Böttger 3736605509 Better frame duplication logic 2026-03-08 15:30:22 +01:00
Leon Böttger e0407d9398 Safer connection handling 2026-03-08 15:18:07 +01:00
Leon Böttger 3ed93e6079 Add liquid glass for toast view 2026-03-08 15:12:27 +01:00
Leon Böttger e1924e1919 Improve ACK logic 2026-03-08 14:40:16 +01:00
Leon Böttger c10facbedd Disconnect only after sending last frame 2026-03-08 14:32:07 +01:00
Leon Böttger a1b4a075a6 Support body / EOF frame separately 2026-03-08 14:27:31 +01:00
Leon Böttger bf7ad37d15 Recover files on early disconnect 2026-03-08 14:13:39 +01:00
Leon Böttger e68167788a Process partial frame on end of connection 2026-03-08 14:07:48 +01:00
Leon Böttger 48eb5be4a5 Add notification sync support 2026-03-08 13:49:07 +01:00
Leon Böttger a352e9ed1c Fix 2026-03-07 10:32:07 +01:00
Leon Böttger 0f8c9d254e Remove unused code 2026-03-01 10:54:03 +01:00
Leon Böttger be4d342894 Fix toast view appearance 2026-03-01 10:38:30 +01:00
Leon Böttger 811b991fcd Fix startup sequence 2026-02-26 20:51:01 +01:00
Leon Böttger d46b62d3de Refactoring 2026-02-23 19:15:14 +01:00
Leon Böttger 093390084f Fix toast view stutter 2026-02-22 19:37:10 +01:00
Leon Böttger b16556dd1b Fix toast view slide in animation 2026-02-22 19:18:36 +01:00
Leon Böttger f5d895ed56 Fix macOS 12 not visible on first start 2026-02-22 18:53:20 +01:00
Leon Böttger c489ef018f Fix toast view disappears if new connection comes right after 2026-02-22 16:12:02 +01:00
Leon Böttger 7e0a2e2830 Toast view cancel all connections with xmark 2026-02-19 21:35:14 +01:00
Leon Böttger 0b94a888b5 Fix cancel not hiding toast view 2026-02-19 21:29:36 +01:00
Leon Böttger 15677b7843 Fix toast view race condition 2026-02-19 21:23:18 +01:00
Leon Böttger 6401c1969c Allow multiple incoming connections 2026-02-19 20:53:48 +01:00
Leon Böttger bb1f28af0b Don't create multiple ReceiveModels in AppDelegate 2026-02-16 19:43:42 +01:00
Leon Böttger d367e0ff34 Fix accept button on macOS 12 2026-02-12 21:24:17 +01:00
Leon Böttger ec093cf302 Formatting 2026-02-12 18:59:57 +01:00
Leon Böttger edc09b048c Fix rename alert 2026-02-11 21:13:23 +01:00
Leon Böttger 9f15ee85f6 Improve toast view 2026-02-11 21:04:07 +01:00
Leon Böttger 10ef0c5a21 Always use fade as hide animation for toast view 2026-02-11 19:18:50 +01:00
Leon Böttger 0aa4fd6894 Improve UI 2026-02-11 19:14:19 +01:00
Leon Böttger 71bddf6541 Fix glitch on macOS 12 2026-02-11 19:05:39 +01:00
Leon Böttger 3ac2280dc6 UI improvements 2026-02-08 11:14:45 +01:00
Leon Böttger 8b965a7eb2 Fix toast could appear twice 2026-02-08 10:11:10 +01:00
Leon Böttger f11943475c Slide in for tast view 2026-02-08 08:41:59 +01:00
Leon Böttger a5781e6e96 Fix toast view dark mode 2026-02-07 21:29:27 +01:00
Leon Böttger ed20c9155e Replace alert with toast view 2026-02-07 15:15:26 +01:00
Leon Böttger b55d6ff07b UI improvements 2026-02-06 18:42:39 +01:00
Leon Böttger c86ed97bc2 Improve support link & logging 2026-02-05 20:54:59 +01:00
Leon Böttger eb32603c5f Fix issue that prevented sharing files 2026-02-02 20:47:34 +01:00
Leon Böttger 044753efb1 Present introduction as sheet window 2026-02-01 15:12:29 +01:00
Leon Böttger e8fdd0e94a Improve introduction view 2026-02-01 14:54:41 +01:00
Leon Böttger 09ef75d249 Refactoring 2026-01-31 21:21:32 +01:00
Leon Böttger 3551c1a006 Fix: Cannot save photos without creation date 2026-01-25 21:00:53 +01:00
Leon Böttger d748167f0e Refactoring 2026-01-25 20:31:34 +01:00
Leon Böttger 02e8af22ab Cleanup TrustedDevicesView 2026-01-25 14:52:52 +01:00
Leon Böttger 0e1fab8faa Improve strings 2026-01-25 13:41:31 +01:00
Leon Böttger 0396c56a31 Add support for WiFi password transfer & add FAQ 2026-01-25 13:30:04 +01:00
Leon Böttger ffdf667cc6 Refactoring 2026-01-23 21:31:25 +01:00
Leon Böttger cce84e9460 Prefer fixed port if possible 2026-01-23 20:58:45 +01:00
Leon Böttger b546370e87 Clean up after closing app 2026-01-18 18:50:22 +01:00
Leon Böttger fbd14c2280 Fix toast view cutoff 2026-01-18 11:31:37 +01:00
Leon Böttger bf1a8164d9 Update metadata 2026-01-18 11:16:58 +01:00
Leon Böttger 0a53ad79cb Fix toast view offset when transfer done 2026-01-15 21:32:03 +01:00
Leon Böttger 800fd23be3 Package updates 2026-01-15 21:17:14 +01:00
Leon Böttger 487a27a019 Simplify introduction 2026-01-08 21:00:43 +01:00
Leon Böttger c74858ea19 Add removeData URL scheme for debugging 2026-01-06 18:57:45 +01:00
Leon Böttger 40169334ff Fix connection drop for larger files 2026-01-01 12:03:50 +01:00
Leon Böttger ed213b4d3c Stop advertising when screen off or other user logged in 2025-12-26 19:08:05 +01:00
Leon Böttger 0697b94547 UI improvements 2025-12-26 18:18:12 +01:00
Leon Böttger e8fc6f719e Performance improvements 2025-12-26 17:50:08 +01:00
Leon Böttger 9dfe864677 Fix outgoing transfer to Windows 2025-12-26 15:41:22 +01:00
Leon Böttger a9e6ed1ddf Fix max device name chars 2025-12-26 13:42:46 +01:00
Leon Böttger 728385ff95 Add button to enable share extension 2025-12-26 13:18:54 +01:00
Leon Böttger 46cdec35d5 Improve strings 2025-12-26 11:43:47 +01:00
Leon Böttger daa1ab77df Fix connection to Windows 2025-12-26 11:36:24 +01:00
Leon Böttger 6766ef69d9 Add synced StoreKit file 2025-12-26 11:35:39 +01:00
Leon Böttger 7a964e35ca Improve strings 2025-12-26 11:35:27 +01:00
Leon Böttger 733ed8bec8 Automatically delete temporary files after file transfer 2025-12-21 13:48:31 +01:00
Leon Böttger 24a0202ae9 Improve strings for introduction 2025-12-21 11:23:16 +01:00
Leon Böttger 945fdcaf6e Show IntroductionView in separate window 2025-12-21 11:16:51 +01:00
Leon Böttger 7cdfdcd474 Fix edit device name alert for macOS 12 2025-12-20 21:05:01 +01:00
Leon Böttger ad1ab30b94 Add GitHub release target 2025-12-20 20:20:18 +01:00
Leon Böttger 4a8ba26c8c Update README 2025-12-20 15:50:02 +01:00
Leon Böttger c887ccb637 Improve macOS introduction 2025-12-20 15:48:39 +01:00
Leon Böttger fb13405247 Add Local Network Checker 2025-12-20 15:24:57 +01:00
Leon Böttger b61aecf315 Add local network capability for macOS 2025-12-20 14:19:42 +01:00
Leon Böttger 8a9bb0bdfe Fix: Delay local network permission prompt to introduction 2025-12-20 14:00:51 +01:00
Leon Böttger 1bd15acaef Improve macOS introduction 2025-12-20 13:36:30 +01:00
Leon Böttger 3b99eda41c macOS: Add new introduction 2025-12-20 12:12:12 +01:00
Leon Böttger 8393a7495f Fix ZIP creation for certain file types, improve UX for outgoing transfer 2025-12-18 21:31:36 +01:00
Leon Böttger a5e1030c1f Add ability to set custom device name 2025-12-18 20:37:44 +01:00
Leon Böttger 8aa3341595 Update version number 2025-12-16 18:47:32 +01:00
Leon Böttger f5e3c88daf Add StoreKit 2 2025-12-02 19:34:38 +01:00
Leon Böttger 488f898d7b FIx: Cannot save images to photos if custom directory chosen 2025-11-29 18:39:52 +01:00
Leon Böttger 9e7278d327 Fix: iOS share extension not detecting new devices after a while #2 2025-11-22 18:52:02 +01:00
Leon Böttger fdc19b6f62 Fix: iOS share extension not detecting new devices after a while 2025-11-22 16:08:11 +01:00
Leon Böttger 734cf766d5 Add missing App Uses Non-Exempt Encryption flag 2025-11-18 20:42:30 +01:00
Leon Böttger f948c30027 Fix macOS toast view autohide 2025-11-18 20:39:13 +01:00
Leon Böttger 0c440b7511 macOS: Request review only after toast was dismissed 2025-11-18 20:25:18 +01:00
Leon Böttger bf5724ece1 Bump version number 2025-11-18 20:10:02 +01:00
Leon Böttger 658a2ff28e macOS: Add ability to save images/videos directly to photos app 2025-11-18 19:16:07 +01:00
Leon Böttger 4ddf39817c Feat: Remove files that were already imported in photos app 2025-11-16 15:13:47 +01:00
Leon Böttger 3fa1d8621e Improve strings 2025-11-16 15:05:11 +01:00
Leon Böttger 394aaa8093 iOS: Add ability to save photos to gallery 2025-11-16 14:31:17 +01:00
Leon Böttger 5e62f3e107 Improve logging 2025-11-16 10:55:21 +01:00
Leon Böttger 3f65ace2ae Bump version 2025-11-15 15:19:35 +01:00
Leon Böttger 41b6eb8e3a Improve iOS introduction 2025-11-15 15:06:14 +01:00
Leon Böttger 1256b91d4f Refactoring 2025-11-15 14:11:20 +01:00
129 changed files with 39366 additions and 7631 deletions

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
DerivedData/ DerivedData/
.DerivedData/
.DS_Store .DS_Store
xcuserdata/ xcuserdata/
/research/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 KiB

@@ -0,0 +1,220 @@
{
"forceMultiLine" : true,
"screenshots" : [
{
"baseText" : "Quick Transfer to Android & more.",
"color" : {
"alpha" : 1,
"blue" : 0.19851559400558472,
"green" : 0.19851559400558472,
"red" : 0.19851559400558472
},
"fileName" : "1.png",
"fontSize" : 107,
"key" : "1",
"localizations" : {
"ar-SA" : "نقل سريع إلى أندرويد والمزيد.",
"ca" : "Transferència ràpida a Android i més.",
"cs" : "Rychlý přenos na Android i jinam.",
"da" : "Hurtig filoverførsel til Android m.m.",
"de-DE" : "Schneller Transfer zu Android & mehr.",
"el" : "Γρήγορη μεταφορά σε Android και άλλα.",
"en-AU" : "Quick Transfer to Android & more.",
"en-CA" : "Quick Transfer to Android & more.",
"en-GB" : "Quick Transfer to Android & more.",
"es-ES" : "Transferencia rápida a Android y más.",
"es-MX" : "Transferencia rápida a Android y más.",
"fi" : "Nopea tiedostonsiirto Androidille ym.",
"fr-CA" : "Transfert rapide vers Android+.",
"fr-FR" : "Transfert rapide vers Android+.",
"he" : "העברת קבצים מהירה לאנדרואיד ועוד.",
"hi" : "एंड्रॉइड आदि पर तेज़ फ़ाइल ट्रांसफर।",
"hr" : "Brz prijenos na Android i više.",
"hu" : "Gyors fájlátvitel Androidra és másra.",
"id" : "Transfer cepat ke Android & lainnya.",
"it" : "Trasferisci file su Android e altro.",
"ja" : "Android などへのファイルを素早く転送。",
"ko" : "Android 등으로 빠른 파일 전송.",
"ms" : "Pindahan pantas ke Android+.",
"nl-NL" : "Snel bestanden naar Android en meer.",
"no" : "Rask overføring til Android og mer.",
"pl" : "Szybki transfer na Androida i więcej.",
"pt-BR" : "Transfer rápido para Android e mais.",
"pt-PT" : "Transfer rápido para Android e mais.",
"ro" : "Transfer rapid la Android și altele.",
"ru" : "Передача на Android и не только.",
"sk" : "Rýchly prenos na Android aj inde.",
"sv" : "Snabb överföring till Android m.m.",
"th" : "ถ่ายโอนไฟล์เร็วไปยัง Android และอื่นๆ",
"tr" : "Android'e hızlı aktarım ve fazlası.",
"uk" : "Передача на Android і не тільки.",
"vi" : "Chuyển nhanh sang Android và hơn nữa.",
"zh-Hans" : "快速文件传输到安卓及更多设备。",
"zh-Hant" : "快速檔案傳輸到 Android 及更多裝置。"
},
"yOffset" : 253
},
{
"baseText" : "Browse & manage received files.",
"color" : {
"alpha" : 1,
"blue" : 0.19851559400558472,
"green" : 0.19851559400558472,
"red" : 0.19851559400558472
},
"fileName" : "2.png",
"fontSize" : 107,
"key" : "2",
"localizations" : {
"ar-SA" : "تصفح وأدر الملفات المستلمة.",
"ca" : "Navega i gestiona els fitxers rebuts.",
"cs" : "Přijaté soubory.",
"da" : "Se og administrer modtagne filer.",
"de-DE" : "Empfangene Dateien ansehen & ordnen.",
"el" : "Δες και διαχειρίσου ληφθέντα αρχεία.",
"en-AU" : "Browse & manage received files.",
"en-CA" : "Browse & manage received files.",
"en-GB" : "Browse & manage received files.",
"es-ES" : "Archivos recibidos: ver y gestionar.",
"es-MX" : "Archivos recibidos: ver y gestionar.",
"fi" : "Selaa ja hallitse saatuja tiedostoja.",
"fr-CA" : "Parcourez et gérez vos fichiers.",
"fr-FR" : "Parcourez et gérez vos fichiers.",
"he" : "עיין ונהל קבצים שהתקבלו.",
"hi" : "प्राप्त फ़ाइलें देखें व संभालें।",
"hr" : "Primljene datoteke.",
"hu" : "Fogadott fájlok.",
"id" : "Jelajahi & kelola file yang diterima.",
"it" : "Sfoglia e gestisci i file ricevuti.",
"ja" : "受信ファイルを閲覧&管理",
"ko" : "받은 파일을 찾아보고 관리하세요.",
"ms" : "Semak & urus fail diterima.",
"nl-NL" : "Beheer ontvangen bestanden.",
"no" : "Se og administrer mottatte filer.",
"pl" : "Odebrane pliki: przegląd i obsługa.",
"pt-BR" : "Veja e gerencie arquivos recebidos.",
"pt-PT" : "Gere ficheiros recebidos.",
"ro" : "Fișiere primite: vezi și gestionează.",
"ru" : "Полученные файлы: обзор и управление.",
"sk" : "Prehliadaj a spravuj prijaté súbory.",
"sv" : "Bläddra och hantera mottagna filer.",
"th" : "เรียกดูและจัดการไฟล์ที่ได้รับ",
"tr" : "Alınan dosyalara göz atın ve yönetin.",
"uk" : "Отримані файли.",
"vi" : "Duyệt và quản lý các tệp đã nhận.",
"zh-Hans" : "浏览和管理收到的文件。",
"zh-Hant" : "瀏覽並管理已接收的檔案。"
},
"yOffset" : 253
},
{
"baseText" : "Share files right from your apps.",
"color" : {
"alpha" : 1,
"blue" : 0.19851559400558472,
"green" : 0.19851559400558472,
"red" : 0.19851559400558472
},
"fileName" : "3.png",
"fontSize" : 107,
"key" : "3",
"localizations" : {
"ar-SA" : "شارك الملفات مباشرة من تطبيقاتك.",
"ca" : "Comparteix fitxers des de les apps.",
"cs" : "Sdílejte soubory přímo z aplikací.",
"da" : "Del filer direkte fra dine apps.",
"de-DE" : "Teile Dateien direkt aus deinen Apps.",
"el" : "Μοιράσου αρχεία από τις εφαρμογές.",
"en-AU" : "Share files right from your apps.",
"en-CA" : "Share files right from your apps.",
"en-GB" : "Share files right from your apps.",
"es-ES" : "Comparte archivos desde tus apps.",
"es-MX" : "Comparte archivos desde tus apps.",
"fi" : "Jaa tiedostoja sovelluksistasi.",
"fr-CA" : "Partagez des fichiers via vos apps.",
"fr-FR" : "Partagez des fichiers via vos apps.",
"he" : "שתף קבצים ישירות מהאפליקציות שלך.",
"hi" : "अपने ऐप्स से सीधे फ़ाइलें साझा करें।",
"hr" : "Dijeli datoteke iz svojih aplikacija.",
"hu" : "Ossz meg fájlokat az appjaidból.",
"id" : "Bagikan file dari aplikasimu.",
"it" : "Condividi file dalle tue app.",
"ja" : "アプリから直接ファイルを共有できます。",
"ko" : "앱에서 바로 파일을 공유하세요.",
"ms" : "Kongsi fail terus dari aplikasi anda.",
"nl-NL" : "Deel bestanden direct vanuit je apps.",
"no" : "Del filer direkte fra appene dine.",
"pl" : "Udostępniaj pliki z aplikacji.",
"pt-BR" : "Compartilhe arquivos direto dos apps.",
"pt-PT" : "Partilha ficheiros das tuas apps.",
"ro" : "Partajează fișiere din aplicații.",
"ru" : "Делитесь файлами из приложений.",
"sk" : "Zdieľaj súbory zo svojich aplikácií.",
"sv" : "Dela filer direkt från dina appar.",
"th" : "แชร์ไฟล์ได้โดยตรงจากแอปของคุณ",
"tr" : "Uygulamalardan paylaş.",
"uk" : "Діліться файлами прямо з додатків.",
"vi" : "Chia sẻ tệp ngay từ ứng dụng của bạn.",
"zh-Hans" : "直接从你的应用中分享文件。",
"zh-Hant" : "直接從你的應用程式分享檔案。"
},
"yOffset" : 253
},
{
"baseText" : "Ultra-fast transfer over Wi-Fi.",
"color" : {
"alpha" : 1,
"blue" : 0.19851559400558472,
"green" : 0.19851559400558472,
"red" : 0.19851559400558472
},
"fileName" : "4.png",
"fontSize" : 107,
"key" : "4",
"localizations" : {
"ar-SA" : "نقل فائق السرعة عبر الواي فاي.",
"ca" : "Transferència Wi-Fi ultraràpida.",
"cs" : "Ultra-rychlý přenos přes Wi-Fi.",
"da" : "Ultrahurtig overførsel via Wi-Fi.",
"de-DE" : "Ultraschneller Transfer über WLAN.",
"el" : "Ταχύτατη μεταφορά Wi-Fi.",
"en-AU" : "Ultra-fast transfer over Wi-Fi.",
"en-CA" : "Ultra-fast transfer over Wi-Fi.",
"en-GB" : "Ultra-fast transfer over Wi-Fi.",
"es-ES" : "Transfiere rápido por Wi-Fi.",
"es-MX" : "Transfiere rápido por Wi-Fi.",
"fi" : "Huippunopea siirto Wi-Fillä.",
"fr-CA" : "Transfert Wi-Fi ultra-rapide.",
"fr-FR" : "Transfert Wi-Fi ultra-rapide.",
"he" : "העברה מהירה במיוחד דרך Wi-Fi.",
"hi" : "Wi-Fi पर अल्ट्रा-फास्ट ट्रांसफर।",
"hr" : "Ultra-brz prijenos putem Wi-Fi-ja.",
"hu" : "Ultragyors átvitel Wi-Fin.",
"id" : "Transfer ultra-cepat melalui Wi-Fi.",
"it" : "Transfer rapido via Wi-Fi.",
"ja" : "Wi-Fi経由で超高速転送。",
"ko" : "Wi-Fi를 통한 초고속 전송.",
"ms" : "Pindahan ultra-pantas melalui Wi-Fi.",
"nl-NL" : "Ultrasnelle overdracht via Wi-Fi.",
"no" : "Ultrarask overføring over Wi-Fi.",
"pl" : "Błyskawiczny transfer przez Wi-Fi.",
"pt-BR" : "Transfira muito rápido via Wi-Fi.",
"pt-PT" : "Transfere muito rápido por Wi-Fi.",
"ro" : "Transfer ultra-rapid prin Wi-Fi.",
"ru" : "Быстрая передача Wi-Fi.",
"sk" : "Ultra-rýchly prenos cez Wi-Fi.",
"sv" : "Ultrasnabb överföring via Wi-Fi.",
"th" : "ถ่ายโอนด้วยความเร็วสูงผ่าน Wi-Fi",
"tr" : "Wi-Fi ile ultra hızlı transfer.",
"uk" : "Надшвидка передача через Wi-Fi.",
"vi" : "Chuyển siêu nhanh qua Wi-Fi.",
"zh-Hans" : "超快的 Wi-Fi 传输。",
"zh-Hant" : "超快速 Wi-Fi 傳輸。"
},
"yOffset" : 253
}
],
"sourceLocale" : "en-US",
"syncSettings" : true,
"version" : 2
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.
+126
View File
@@ -0,0 +1,126 @@
{
"appPolicies" : {
"eula" : "",
"policies" : [
{
"locale" : "en_US",
"policyText" : "",
"policyURL" : ""
}
]
},
"identifier" : "07114EE9",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "4.99",
"familyShareable" : true,
"internalID" : "6742512912",
"localizations" : [
{
"description" : "Unlocks unlimited file transfer, forever.",
"displayName" : "QuickDrop+",
"locale" : "en_US"
}
],
"productID" : "plusversion",
"referenceName" : "QuickDrop+ Lifetime",
"type" : "NonConsumable"
}
],
"settings" : {
"_applicationInternalID" : "6740147178",
"_askToBuyEnabled" : false,
"_billingGracePeriodEnabled" : false,
"_billingIssuesEnabled" : false,
"_developerTeamID" : "92SDAWLN76",
"_disableDialogs" : false,
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 788014826.59455097,
"_locale" : "en_US",
"_renewalBillingIssuesEnabled" : false,
"_storefront" : "USA",
"_storeKitErrors" : [
],
"_timeRate" : 0
},
"subscriptionGroups" : [
{
"id" : "21845627",
"localizations" : [
],
"name" : "QuickDrop+ Subscription",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "1.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "6756841799",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Unlocks unlimited file transfer.",
"displayName" : "QuickDrop+ (1 Month)",
"locale" : "en_US"
}
],
"productID" : "quickdrop.monthly",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "QuickDrop+ (1 Month)",
"subscriptionGroupID" : "21845627",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
},
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "3.99",
"familyShareable" : true,
"groupNumber" : 1,
"internalID" : "6755912812",
"introductoryOffer" : {
"internalID" : "D30B2549",
"numberOfPeriods" : 1,
"paymentMode" : "free",
"subscriptionPeriod" : "P3D"
},
"localizations" : [
{
"description" : "Unlocks unlimited file transfer.",
"displayName" : "QuickDrop+ (1 Year)",
"locale" : "en_US"
}
],
"productID" : "quickdrop.yearly",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "QuickDrop+ (1 Year)",
"subscriptionGroupID" : "21845627",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
}
],
"version" : {
"major" : 4,
"minor" : 0
}
}
@@ -12,6 +12,8 @@ import Network
import System import System
import BigInt import BigInt
import SwiftECC import SwiftECC
import ASN1
import LUI
#if os(macOS) #if os(macOS)
import AppKit import AppKit
@@ -28,8 +30,14 @@ class InboundNearbyConnection: NearbyConnection {
private var textPayloadID: Int64 = 0 private var textPayloadID: Int64 = 0
private var bytesToBeTransferred: Int64 = 0 private var bytesToBeTransferred: Int64 = 0
private var isAuthenticated = false private var isAuthenticated = false
private var receiverAuthenticationAccepted = false
private var peerCertificate: Sharing_Nearby_PublicCertificate? = nil private var peerCertificate: Sharing_Nearby_PublicCertificate? = nil
private var mirroredNotificationMetadata: Sharing_Nearby_MirroredNotificationMetadata?
private var pairingMetadata: Sharing_Nearby_PairingMetadata?
private var isPairingSetupRequest = false
var isPlainTextTransfer = false var isPlainTextTransfer = false
var isClipboardSyncTransfer = false
var isControlTransfer = false
var wasUserRejected = false var wasUserRejected = false
var delegate: InboundNearbyConnectionDelegate? var delegate: InboundNearbyConnectionDelegate?
@@ -45,24 +53,22 @@ class InboundNearbyConnection: NearbyConnection {
override func disconnect() { override func disconnect() {
let recoveredAllPendingFiles = recoverCompletedFilesOnDisconnect()
if recoveredAllPendingFiles {
if case let .protocolError(message) = self.lastError as? NearbyError,
message == "Error.ClosedByPeer".localized() {
log("[InboundNearbyConnection \(self.id)] Recovering completed transfer after connection closed by peer.")
self.lastError = nil
NearbyConnectionManager.shared.updatedTransferProgress(connection: self, progress: 1)
}
}
super.disconnect() super.disconnect()
currentState = .disconnected currentState = .disconnected
deletePartiallyReceivedFiles() deletePartiallyReceivedFiles()
DispatchQueue.main.async { DispatchQueue.main.async {
self.delegate?.connectionWasTerminated(connection: self, error: self.lastError) self.delegate?.connectionWasTerminated(connection: self, savedFiles: self.downloadedFiles, error: self.lastError)
if !self.downloadedFiles.isEmpty {
#if os(macOS)
if Settings.shared.openFinderAfterReceiving {
log("[SaveFilesManager] Opening \(self.downloadedFiles.count) file(s) in Finder.")
NSWorkspace.shared.activateFileViewerSelecting(self.downloadedFiles)
}
#else
FileManager.default.openDocumentFolder()
#endif
}
} }
} }
@@ -73,6 +79,19 @@ class InboundNearbyConnection: NearbyConnection {
log("[InboundNearbyConnection \(self.id)] Received frame in state \(currentState)...") log("[InboundNearbyConnection \(self.id)] Received frame in state \(currentState)...")
} }
if !encryptionDone,
currentState != .initial,
let frame = try? Location_Nearby_Connections_OfflineFrame(serializedBytes: frameData),
frame.hasV1,
frame.v1.hasType,
case .keepAlive = frame.v1.type {
log("[InboundNearbyConnection \(self.id)] Received keep-alive during handshake in state \(currentState)")
if !frame.v1.hasKeepAlive || !frame.v1.keepAlive.ack {
sendKeepAlive(ack: true)
}
return
}
do { do {
switch currentState { switch currentState {
case .initial: case .initial:
@@ -122,11 +141,64 @@ class InboundNearbyConnection: NearbyConnection {
} }
switch currentState { switch currentState {
case .sentConnectionResponse: case .sentConnectionResponse:
try processPairedKeyEncryptionFrame(frame) if frame.hasV1, frame.v1.hasPairedKeyEncryption {
try processPairedKeyEncryptionFrame(frame)
return
}
if frame.hasV1, frame.v1.hasIntroduction {
log("[InboundNearbyConnection \(self.id)] Received introduction before paired-key exchange finished; continuing unauthenticated flow.")
try sendPairedKeyResult(status: .unable)
try processIntroductionFrame(frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .pairedKeyResult || frame.v1.type == .progressUpdate {
log("[InboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for pairedKeyEncryption.")
return
}
if frame.v1.type == .response, case .accept = frame.v1.connectionResponse.status {
log("[InboundNearbyConnection \(self.id)] Ignoring accept response while waiting for pairedKeyEncryption.")
return
}
}
throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.pairedKeyEncryption")
case .sentPairedKeyResult: case .sentPairedKeyResult:
try processPairedKeyResultFrame(frame) if frame.hasV1, frame.v1.hasPairedKeyResult {
try processPairedKeyResultFrame(frame)
return
}
if frame.hasV1, frame.v1.hasIntroduction {
log("[InboundNearbyConnection \(self.id)] Received introduction without pairedKeyResult; continuing for legacy compatibility.")
try processIntroductionFrame(frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .pairedKeyEncryption || frame.v1.type == .progressUpdate {
log("[InboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for pairedKeyResult.")
return
}
if frame.v1.type == .response, case .accept = frame.v1.connectionResponse.status {
log("[InboundNearbyConnection \(self.id)] Ignoring accept response while waiting for pairedKeyResult.")
return
}
}
throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.pairedKeyResult")
case .receivedPairedKeyResult: case .receivedPairedKeyResult:
try processIntroductionFrame(frame) if frame.hasV1, frame.v1.hasIntroduction {
try processIntroductionFrame(frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .pairedKeyResult || frame.v1.type == .pairedKeyEncryption || frame.v1.type == .progressUpdate {
log("[InboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for introduction.")
return
}
if frame.v1.type == .response, case .accept = frame.v1.connectionResponse.status {
log("[InboundNearbyConnection \(self.id)] Ignoring accept response while waiting for introduction.")
return
}
}
throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.introduction")
default: default:
if frame.hasV1, frame.v1.hasType, frame.v1.type == .progressUpdate { if frame.hasV1, frame.v1.hasType, frame.v1.type == .progressUpdate {
// ignore progress updates // ignore progress updates
@@ -151,7 +223,7 @@ class InboundNearbyConnection: NearbyConnection {
let id = frame.payloadHeader.id let id = frame.payloadHeader.id
guard let fileInfo = filesToBeReceived[id] else { throw NearbyError.protocolError("File payload ID \(id) is not known") } guard var fileInfo = filesToBeReceived[id] else { throw NearbyError.protocolError("File payload ID \(id) is not known") }
let currentOffset = fileInfo.bytesTransferred let currentOffset = fileInfo.bytesTransferred
@@ -159,11 +231,15 @@ class InboundNearbyConnection: NearbyConnection {
guard currentOffset + Int64(frame.payloadChunk.body.count) <= fileInfo.meta.size else { throw NearbyError.protocolError("Transferred file size exceeds previously specified value") } guard currentOffset + Int64(frame.payloadChunk.body.count) <= fileInfo.meta.size else { throw NearbyError.protocolError("Transferred file size exceeds previously specified value") }
if !frame.payloadChunk.body.isEmpty { let hasBody = !frame.payloadChunk.body.isEmpty
let isLastChunk = (frame.payloadChunk.flags & 1) == 1
if hasBody {
do { do {
try fileInfo.fileHandle?.write(contentsOf: frame.payloadChunk.body) try fileInfo.fileHandle?.write(contentsOf: frame.payloadChunk.body)
filesToBeReceived[id]!.bytesTransferred += Int64(frame.payloadChunk.body.count) fileInfo.bytesTransferred += Int64(frame.payloadChunk.body.count)
fileInfo.progress?.completedUnitCount = filesToBeReceived[id]!.bytesTransferred fileInfo.progress?.completedUnitCount = fileInfo.bytesTransferred
filesToBeReceived[id] = fileInfo
self.bytesTransferred += Int64(frame.payloadChunk.body.count) self.bytesTransferred += Int64(frame.payloadChunk.body.count)
NearbyConnectionManager.shared.updatedTransferProgress(connection: self, progress: Double(self.bytesTransferred) / Double(self.bytesToBeTransferred)) NearbyConnectionManager.shared.updatedTransferProgress(connection: self, progress: Double(self.bytesTransferred) / Double(self.bytesToBeTransferred))
@@ -173,15 +249,19 @@ class InboundNearbyConnection: NearbyConnection {
throw NearbyError.protocolError(error.localizedDescription) throw NearbyError.protocolError(error.localizedDescription)
} }
} }
else if (frame.payloadChunk.flags & 1) == 1 {
if isLastChunk {
guard fileInfo.bytesTransferred == fileInfo.meta.size else {
throw NearbyError.protocolError("Received EOF before file was fully transferred (\(fileInfo.bytesTransferred)/\(fileInfo.meta.size) bytes)")
}
try fileInfo.fileHandle?.close() try fileInfo.fileHandle?.close()
filesToBeReceived[id]!.fileHandle = nil fileInfo.fileHandle = nil
#if os(macOS) #if os(macOS)
fileInfo.progress?.unpublish() fileInfo.progress?.unpublish()
#endif #endif
downloadedFiles.append(fileInfo.destinationURL) downloadedFiles.append(fileInfo.destinationURL)
EXIFUtils.applyEXIFTimestamps(at: fileInfo.destinationURL) EXIFUtils.applyTimestamps(at: fileInfo.destinationURL)
filesToBeReceived[id] = fileInfo
filesToBeReceived.removeValue(forKey: id) filesToBeReceived.removeValue(forKey: id)
if filesToBeReceived.isEmpty { if filesToBeReceived.isEmpty {
@@ -190,7 +270,7 @@ class InboundNearbyConnection: NearbyConnection {
try sendDisconnectionAndDisconnect() try sendDisconnectionAndDisconnect()
} }
} }
else { else if !hasBody {
log("[InboundNearbyConnection \(self.id)] Received file chunk with no body and no flags, ignoring it.") log("[InboundNearbyConnection \(self.id)] Received file chunk with no body and no flags, ignoring it.")
} }
} }
@@ -201,11 +281,22 @@ class InboundNearbyConnection: NearbyConnection {
if let urlStr = String(data: payload, encoding: .utf8) { if let urlStr = String(data: payload, encoding: .utf8) {
if isPlainTextTransfer { if isPlainTextTransfer {
#if os(macOS) && !EXTENSION
ClipboardSyncCoordinator.shared.suppressNextClipboardSync(for: urlStr)
#endif
#if os(macOS) #if os(macOS)
// macOS clipboard // macOS clipboard
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
pasteboard.clearContents() pasteboard.clearContents()
pasteboard.setString(urlStr, forType: .string) pasteboard.setString(urlStr, forType: .string)
#if !EXTENSION
if isClipboardSyncTransfer {
ClipboardSyncNotificationPresenter.shared.present(
clipboardText: urlStr,
senderDeviceName: remoteDeviceInfo?.name
)
}
#endif
#elseif os(iOS) #elseif os(iOS)
// iOS clipboard // iOS clipboard
UIPasteboard.general.string = urlStr UIPasteboard.general.string = urlStr
@@ -234,7 +325,7 @@ class InboundNearbyConnection: NearbyConnection {
#endif #endif
filesToBeReceived.removeValue(forKey: id) filesToBeReceived.removeValue(forKey: id)
downloadedFiles.append(fileInfo.destinationURL) downloadedFiles.append(fileInfo.destinationURL)
EXIFUtils.applyEXIFTimestamps(at: fileInfo.destinationURL) EXIFUtils.applyTimestamps(at: fileInfo.destinationURL)
if filesToBeReceived.isEmpty { if filesToBeReceived.isEmpty {
log("[InboundNearbyConnection \(self.id)] Received file payload. Disconnecting...") log("[InboundNearbyConnection \(self.id)] Received file payload. Disconnecting...")
@@ -253,19 +344,12 @@ class InboundNearbyConnection: NearbyConnection {
guard case .connectionRequest = frame.v1.type else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") } guard case .connectionRequest = frame.v1.type else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") }
let endpointInfo = frame.v1.connectionRequest.endpointInfo let endpointInfo = EndpointInfo(data: frame.v1.connectionRequest.endpointInfo)
let fallbackDeviceName = "AndroidDevice".localized()
let resolvedDeviceName = endpointInfo?.name?.trimmingCharacters(in: .whitespacesAndNewlines)
let deviceName = (resolvedDeviceName?.isEmpty == false) ? resolvedDeviceName : fallbackDeviceName
guard endpointInfo.count > 17 else { throw NearbyError.protocolError("Endpoint info too short") } remoteDeviceInfo = RemoteDeviceInfo(name: deviceName, type: endpointInfo?.deviceType)
let deviceNameLength = Int(endpointInfo[17])
guard endpointInfo.count >= deviceNameLength + 18 else { throw NearbyError.protocolError("Endpoint info too short to contain the device name") }
guard let deviceName = String(data: endpointInfo[18 ..< (18 + deviceNameLength)], encoding: .utf8) else { throw NearbyError.protocolError("Device name is not valid UTF-8") }
let rawDeviceType = Int(endpointInfo[0] & 7) >> 1
remoteDeviceInfo = RemoteDeviceInfo(name: deviceName, type: RemoteDeviceInfo.DeviceType.fromRawValue(value: rawDeviceType))
currentState = .receivedConnectionRequest currentState = .receivedConnectionRequest
} }
@@ -386,9 +470,28 @@ class InboundNearbyConnection: NearbyConnection {
pairedEncryption.v1.type = .pairedKeyEncryption pairedEncryption.v1.type = .pairedKeyEncryption
pairedEncryption.v1.pairedKeyEncryption = Sharing_Nearby_PairedKeyEncryptionFrame() pairedEncryption.v1.pairedKeyEncryption = Sharing_Nearby_PairedKeyEncryptionFrame()
// For inbound connections, we do not use provide secretIDHash and signedData if let signingPrivateKey = IdentityManager.shared.getPrivateKey(),
pairedEncryption.v1.pairedKeyEncryption.secretIDHash = Data.randomData(length: 6) let publicKey = IdentityManager.shared.getPublicKey()?.toGenericPublicKey(),
pairedEncryption.v1.pairedKeyEncryption.signedData = Data.randomData(length: 72) let publicKeyData = IdentityManager.shared.getPublicKey()?.toGenericPublicKeyData(),
let publicKeyID = publicKey.id(),
let authKeyData = self.authKey?.data() {
var cert = Sharing_Nearby_PublicCertificate()
cert.secretID = publicKeyID
cert.publicKey = publicKeyData
let signatureTuple = signingPrivateKey.sign(msg: authKeyData)
pairedEncryption.v1.certificateInfo.publicCertificate.append(cert)
pairedEncryption.v1.pairedKeyEncryption.secretIDHash = cert.secretID
pairedEncryption.v1.pairedKeyEncryption.signedData = Data(signatureTuple.asn1.encode())
pairedEncryption.v1.pairedKeyResult.status = .success
} else {
log("[InboundNearbyConnection \(self.id)] No private key available for receiver authentication.")
pairedEncryption.v1.pairedKeyEncryption.secretIDHash = Data.randomData(length: 6)
pairedEncryption.v1.pairedKeyEncryption.signedData = Data.randomData(length: 72)
}
try sendTransferSetupFrame(pairedEncryption) try sendTransferSetupFrame(pairedEncryption)
currentState = .sentConnectionResponse currentState = .sentConnectionResponse
@@ -435,6 +538,7 @@ class InboundNearbyConnection: NearbyConnection {
if peerPublicKey.verify(signature: signature, msg: authKeyData) { if peerPublicKey.verify(signature: signature, msg: authKeyData) {
log("[InboundNearbyConnection \(self.id)] Paired key authentication successful.") log("[InboundNearbyConnection \(self.id)] Paired key authentication successful.")
self.peerCertificate = trustedCert
try sendPairedKeyResult(status: .success) try sendPairedKeyResult(status: .success)
isAuthenticated = true isAuthenticated = true
} else { } else {
@@ -476,6 +580,7 @@ class InboundNearbyConnection: NearbyConnection {
private func processPairedKeyResultFrame(_ frame: Sharing_Nearby_Frame) throws { private func processPairedKeyResultFrame(_ frame: Sharing_Nearby_Frame) throws {
guard frame.hasV1, frame.v1.hasPairedKeyResult else { throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.pairedKeyResult") } guard frame.hasV1, frame.v1.hasPairedKeyResult else { throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.pairedKeyResult") }
receiverAuthenticationAccepted = frame.v1.pairedKeyResult.status == .success
currentState = .receivedPairedKeyResult currentState = .receivedPairedKeyResult
} }
@@ -525,26 +630,176 @@ class InboundNearbyConnection: NearbyConnection {
} }
let metadata = TransferMetadata(files: filesToBeReceived.map { $0.value.meta }, id: id, pinCode: pinCode, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil) let metadata = TransferMetadata(files: filesToBeReceived.map { $0.value.meta }, id: id, pinCode: pinCode, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil)
checkIfCanProceed(metadata: metadata) checkIfCanProceed(metadata: metadata)
return
} }
else if let textMetadata = frame.v1.introduction.textMetadata.first {
let isURL = textMetadata.type == .url
let metadata = TransferMetadata(files: [], id: id, pinCode: pinCode, textDescription: textMetadata.textTitle, transferType: isURL ? .url : .text, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil) #if os(macOS)
if frame.v1.introduction.hasMirroredNotificationMetadata || frame.v1.introduction.hasPairingMetadata {
isControlTransfer = true
mirroredNotificationMetadata = frame.v1.introduction.hasMirroredNotificationMetadata
? frame.v1.introduction.mirroredNotificationMetadata
: nil
pairingMetadata = frame.v1.introduction.hasPairingMetadata
? frame.v1.introduction.pairingMetadata
: nil
isPairingSetupRequest = pairingMetadata?.setupRequest == true
let hasPairingConfirmation = pairingMetadata?.hasSetupPinHash == true
let pairingUseCase = pairingMetadata.flatMap { PairingUseCase(protoValue: $0.useCase) }
guard let device = self.remoteDeviceInfo else {
self.rejectTransfer(with: .reject)
return
}
if pairingMetadata != nil && pairingUseCase == nil {
self.rejectTransfer(with: .reject)
return
}
if pairingMetadata != nil,
mirroredNotificationMetadata == nil,
!isPairingSetupRequest,
!hasPairingConfirmation {
self.rejectTransfer(with: .reject)
return
}
if !isAuthenticated && !isPairingSetupRequest && !hasPairingConfirmation {
self.lastError = NearbyError.notificationSyncNotTrusted
self.rejectTransfer(with: .reject, markAsUserRejected: false)
return
}
if hasPairingConfirmation {
guard let peerCertificate = peerCertificate,
let pairingMetadata,
let pairingUseCase else {
self.rejectTransfer(with: .reject)
return
}
// Existing trust only proves this is the same device key we trusted before.
// It does not by itself grant a new feature, such as clipboard sync.
let trustedData = TrustStore.shared.findTrustedKey(for: peerCertificate.secretID)
if let trustedData {
guard let currentData = try? peerCertificate.serializedData(),
currentData == trustedData else {
self.rejectTransfer(with: .reject)
return
}
}
// The QR token proves the user accepted this specific pairing use case.
// Require it even for already-trusted devices before adding a new grant.
let matches = TrustStore.shared.confirmPendingPairingTrust(
secretIdHex: peerCertificate.secretID.hex,
useCase: pairingUseCase,
pinHash: pairingMetadata.setupPinHash
)
guard matches else {
self.rejectTransfer(with: .reject)
return
}
// Preserve the existing trusted certificate when present; otherwise store
// the newly paired device and attach the confirmed use-case grant.
if trustedData != nil {
TrustStore.shared.grantUseCase(pairingUseCase, for: peerCertificate.secretID.hex)
} else {
TrustStore.shared.addTrusted(
certificate: peerCertificate,
device: device,
grantedUseCases: [pairingUseCase]
)
}
DispatchQueue.main.async {
self.delegate?.pairingSetupConfirmed(connection: self)
}
submitUserConsent(accepted: true, trustDevice: false)
return
}
if !isAuthenticated || isPairingSetupRequest {
guard let transferType = pairingTransferType(for: pairingUseCase) else {
self.rejectTransfer(with: .reject)
return
}
let metadata = TransferMetadata(
files: [],
id: id,
pinCode: pinCode,
transferType: transferType,
allowsToBeAddedAsTrustedDevice: true
)
DispatchQueue.main.async {
self.delegate?.obtainUserConsent(transfer: metadata, device: device, connection: self)
}
return
}
submitUserConsent(accepted: true, trustDevice: false)
return
}
#endif
if let textMetadata = frame.v1.introduction.textMetadata.first {
let isURL = textMetadata.type == .url
textPayloadID = textMetadata.payloadID textPayloadID = textMetadata.payloadID
let isRemoteCopy = frame.v1.introduction.hasUseCase
&& frame.v1.introduction.useCase == .remoteCopy
if isRemoteCopy && !isURL {
let peerFingerprint = peerCertificate?.secretID.hex
let isClipboardSyncAllowed = isAuthenticated
&& receiverAuthenticationAccepted
&& TrustStore.shared.hasUseCaseGrant(.clipboardSync, for: peerFingerprint)
guard isClipboardSyncAllowed else {
log("[InboundNearbyConnection \(self.id)] Rejecting clipboard sync transfer because the sender is not trusted for clipboard sync.")
self.rejectTransfer(with: .reject, markAsUserRejected: false)
return
}
isClipboardSyncTransfer = true
} else {
isClipboardSyncTransfer = false
}
let metadata = TransferMetadata(
files: [],
id: id,
pinCode: pinCode,
textDescription: textMetadata.textTitle,
transferType: isURL ? .url : .text,
allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil,
isClipboardSync: isClipboardSyncTransfer
)
if !isURL { if !isURL {
isPlainTextTransfer = true isPlainTextTransfer = true
} }
checkIfCanProceed(metadata: metadata) checkIfCanProceed(metadata: metadata)
} else { }
rejectDueToUnsupportedFileType(frame) else if let wifiMetadata = frame.v1.introduction.wifiCredentialsMetadata.first {
let metadata = TransferMetadata(files: [], id: id, pinCode: pinCode, textDescription: wifiMetadata.ssid, transferType: .wifiPassword, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil)
textPayloadID = wifiMetadata.payloadID
isPlainTextTransfer = true
checkIfCanProceed(metadata: metadata)
}
else {
log("[InboundNearbyConnection \(self.id)] Rejecting transfer due to unsupported file type. Frame is \(frame.debugDescription)")
lastError = NearbyError.canceled(reason: .unsupportedType)
rejectTransfer(with: .unsupportedAttachmentType)
} }
func checkIfCanProceed(metadata: TransferMetadata) { func checkIfCanProceed(metadata: TransferMetadata) {
let acceptAutomatically = isAuthenticated || Settings.shared.automaticallyAcceptFiles let acceptAutomatically = isAuthenticated || Settings.sharedInstance.automaticallyAcceptFiles
DispatchQueue.main.async { DispatchQueue.main.async {
if acceptAutomatically { if acceptAutomatically {
@@ -559,21 +814,33 @@ class InboundNearbyConnection: NearbyConnection {
} }
func rejectDueToUnsupportedFileType(_ frame: Sharing_Nearby_Frame) { func submitUserConsent(accepted: Bool, trustDevice: Bool, pairingToken: String? = nil) {
if isPairingSetupRequest,
let peerCertificate = peerCertificate,
let pairingMetadata,
let pairingUseCase = PairingUseCase(protoValue: pairingMetadata.useCase) {
let secretIdHex = peerCertificate.secretID.hex
if accepted {
if let token = pairingToken {
TrustStore.shared.registerPendingPairingTrust(
secretIdHex: secretIdHex,
pinCode: token,
useCase: pairingUseCase
)
} else {
log("[InboundNearbyConnection \(self.id)] Missing pairing token; pending trust not registered.")
TrustStore.shared.clearPendingPairingTrust(secretIdHex: secretIdHex)
}
} else {
TrustStore.shared.clearPendingPairingTrust(secretIdHex: secretIdHex)
}
}
log("[InboundNearbyConnection \(self.id)] Rejecting transfer due to unsupported file type. Frame is \(frame.debugDescription)")
lastError = NearbyError.canceled(reason: .unsupportedType)
rejectTransfer(with: .unsupportedAttachmentType)
}
func submitUserConsent(accepted: Bool, trustDevice: Bool) {
if trustDevice, let peerCertificate = peerCertificate, let remoteDeviceInfo = remoteDeviceInfo { if trustDevice, let peerCertificate = peerCertificate, let remoteDeviceInfo = remoteDeviceInfo {
TrustStore.shared.addTrusted(certificate: peerCertificate, device: remoteDeviceInfo) TrustStore.shared.addTrusted(certificate: peerCertificate, device: remoteDeviceInfo)
} }
DispatchQueue.global(qos: .utility).async { NearbyConnection.dispatchQueue.async {
if accepted { if accepted {
self.acceptTransfer() self.acceptTransfer()
} else { } else {
@@ -589,7 +856,7 @@ class InboundNearbyConnection: NearbyConnection {
return return
} }
if isFileTransferRestricted() { if !isControlTransfer && isFileTransferRestricted() {
log("[InboundNearbyConnection \(self.id)] File transfer restrictions detected.") log("[InboundNearbyConnection \(self.id)] File transfer restrictions detected.")
delegate?.showPlusScreen() delegate?.showPlusScreen()
rejectTransfer() rejectTransfer()
@@ -633,17 +900,36 @@ class InboundNearbyConnection: NearbyConnection {
currentState = .receivingFiles currentState = .receivingFiles
isTransferring = true isTransferring = true
try sendTransferSetupFrame(frame) try sendTransferSetupFrame(frame)
if let mirroredNotificationMetadata,
!isPairingSetupRequest {
MirroredNotificationPresenter.shared.present(metadata: mirroredNotificationMetadata, senderDeviceName: remoteDeviceInfo?.name)
}
if isControlTransfer {
try sendDisconnectionAndDisconnect()
}
} catch { } catch {
lastError = error lastError = error
protocolError() protocolError()
} }
} }
private func pairingTransferType(for useCase: PairingUseCase?) -> TransferMetadata.TransferType? {
switch useCase {
case .notificationSync:
return .notificationSync
case .clipboardSync:
return .clipboardSync
case .none:
return nil
}
}
private func rejectTransfer(with reason: Sharing_Nearby_ConnectionResponseFrame.Status = .reject) {
private func rejectTransfer(with reason: Sharing_Nearby_ConnectionResponseFrame.Status = .reject, markAsUserRejected: Bool = true) {
// rejected by user // rejected by user
if reason == .reject { if reason == .reject && markAsUserRejected {
self.wasUserRejected = true self.wasUserRejected = true
} }
@@ -676,4 +962,41 @@ class InboundNearbyConnection: NearbyConnection {
try? FileManager.default.removeItem(at: file.destinationURL) try? FileManager.default.removeItem(at: file.destinationURL)
} }
} }
private func recoverCompletedFilesOnDisconnect() -> Bool {
guard !filesToBeReceived.isEmpty else { return false }
var recoveredCount = 0
let pendingFileIDs = Array(filesToBeReceived.keys)
for fileID in pendingFileIDs {
guard let file = filesToBeReceived[fileID] else { continue }
guard file.created, file.bytesTransferred == file.meta.size else { continue }
do {
try file.fileHandle?.close()
} catch {
log("[InboundNearbyConnection \(self.id)] Failed to close recovered file handle for \(file.destinationURL.lastPathComponent): \(error.localizedDescription)")
}
#if os(macOS)
file.progress?.unpublish()
#endif
if !downloadedFiles.contains(file.destinationURL) {
downloadedFiles.append(file.destinationURL)
}
EXIFUtils.applyTimestamps(at: file.destinationURL)
filesToBeReceived.removeValue(forKey: fileID)
recoveredCount += 1
}
if recoveredCount > 0 {
log("[InboundNearbyConnection \(self.id)] Recovered \(recoveredCount) completed file(s) during disconnect.")
}
return recoveredCount > 0 && filesToBeReceived.isEmpty
}
} }
@@ -0,0 +1,134 @@
//
// MirroredNotificationSupport.swift
// QuickDrop
//
import Foundation
import CryptoKit
import UserNotifications
import LUI
final class MirroredNotificationPresenter {
static let shared = MirroredNotificationPresenter()
private let center = UNUserNotificationCenter.current()
private let queue = DispatchQueue(label: "MirroredNotificationPresenter")
private var pendingNotifications: [(Sharing_Nearby_MirroredNotificationMetadata, String?)] = []
private var authorizationRequestInFlight = false
private init() {}
func present(metadata: Sharing_Nearby_MirroredNotificationMetadata, senderDeviceName: String?) {
queue.async {
self.postNotificationIfAuthorized(metadata: metadata, senderDeviceName: senderDeviceName)
}
}
private func postNotificationIfAuthorized(metadata: Sharing_Nearby_MirroredNotificationMetadata, senderDeviceName: String?) {
center.getNotificationSettings { settings in
self.queue.async {
self.handleAuthorizationStatus(
settings.authorizationStatus,
metadata: metadata,
senderDeviceName: senderDeviceName
)
}
}
}
private func handleAuthorizationStatus(
_ status: UNAuthorizationStatus,
metadata: Sharing_Nearby_MirroredNotificationMetadata,
senderDeviceName: String?
) {
switch status {
case .authorized, .provisional, .ephemeral:
post(metadata: metadata, senderDeviceName: senderDeviceName)
case .notDetermined:
pendingNotifications.append((metadata, senderDeviceName))
requestAuthorizationIfNeeded()
case .denied:
log("[MirroredNotificationPresenter] Notifications are denied in system settings; cannot mirror.")
@unknown default:
log("[MirroredNotificationPresenter] Unknown notification authorization status; skipping mirrored notification.")
}
}
private func requestAuthorizationIfNeeded() {
if authorizationRequestInFlight {
return
}
authorizationRequestInFlight = true
DispatchQueue.main.async {
self.center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
self.queue.async {
self.authorizationRequestInFlight = false
if let error = error {
log("[MirroredNotificationPresenter] Authorization request failed: \(error.localizedDescription)")
}
guard granted else {
log("[MirroredNotificationPresenter] Authorization request not granted; dropping queued mirrored notifications.")
self.pendingNotifications.removeAll()
return
}
let queued = self.pendingNotifications
self.pendingNotifications.removeAll()
for (queuedMetadata, queuedSender) in queued {
self.post(metadata: queuedMetadata, senderDeviceName: queuedSender)
}
}
}
}
}
private func post(metadata: Sharing_Nearby_MirroredNotificationMetadata, senderDeviceName: String?) {
let senderName = senderDeviceName?.trimmingCharacters(in: .whitespacesAndNewlines)
let hasSenderName = senderName?.isEmpty == false
let displaySender = hasSenderName ? senderName! : "NotificationSyncFallbackSenderAndroid".localized()
let identifierSender = hasSenderName ? senderName! : "android"
let cleanAppName = metadata.appName.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanTitle = metadata.title.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanBody = metadata.body.trimmingCharacters(in: .whitespacesAndNewlines)
let notificationKey = metadata.notificationKey.trimmingCharacters(in: .whitespacesAndNewlines)
let content = UNMutableNotificationContent()
content.title = cleanTitle.isEmpty ? (cleanAppName.isEmpty ? "NotificationSyncFallbackTitle".localized() : cleanAppName) : cleanTitle
content.body = cleanBody
content.subtitle = cleanAppName.isEmpty ? displaySender : "\(cleanAppName) · \(displaySender)"
content.sound = .default
content.threadIdentifier = "quickdrop-mirror-\(sanitizeIdentifierComponent(identifierSender))"
let identifier = makeIdentifier(sender: identifierSender, notificationKey: notificationKey)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
center.add(request) { error in
if let error = error {
log("[MirroredNotificationPresenter] Could not post notification: \(error.localizedDescription)")
} else {
DispatchQueue.main.async {
Settings.sharedInstance.incomingNotificationCount += 1
let count = Settings.sharedInstance.incomingNotificationCount
log("[MirroredNotificationPresenter] Posted mirrored notification (\(identifier)). Current count: \(count)")
}
}
}
}
private func makeIdentifier(sender: String, notificationKey: String) -> String {
let raw = "\(sender)|\(notificationKey)"
let digest = SHA256.hash(data: Data(raw.utf8))
let prefix = digest.prefix(12).map { String(format: "%02x", $0) }.joined()
return "quickdrop-mirror-\(prefix)"
}
private func sanitizeIdentifierComponent(_ value: String) -> String {
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
let scalarView = value.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
let sanitized = String(scalarView)
return sanitized.isEmpty ? "android" : sanitized
}
}
@@ -0,0 +1,237 @@
//
// NearbyBonjourServiceResolver.swift
// QuickDrop
//
// Created by Leon Böttger on 19.05.26.
//
import Darwin
import Foundation
import Network
struct NearbyResolvedBonjourEndpoint {
let endpoint: NWEndpoint
/// Numeric host string; IPv6 link-local hosts may include a `%scopeID` suffix for NWEndpoint routing.
let host: String
let port: UInt16
let interface: NWInterface?
let description: String
let isIPv4: Bool
let isLinkLocal: Bool
}
final class NearbyBonjourServiceResolver: NSObject, NetServiceDelegate {
private static let defaultWatchdogGracePeriod: TimeInterval = 0.5
private let service: NetService
private let resolveTimeout: TimeInterval
private let watchdogGracePeriod: TimeInterval
private let interface: NWInterface?
private var timeoutWorkItem: DispatchWorkItem?
private var completion: (([NearbyResolvedBonjourEndpoint]) -> Void)?
private var finished = false
init?(
result: NWBrowser.Result,
resolveTimeout: TimeInterval,
watchdogGracePeriod: TimeInterval = defaultWatchdogGracePeriod,
completion: @escaping ([NearbyResolvedBonjourEndpoint]) -> Void
) {
guard let components = Self.serviceEndpointComponents(from: result) else {
return nil
}
self.service = NetService(
domain: Self.normalizedServiceDomain(components.domain),
type: Self.normalizedServiceType(components.type),
name: components.name
)
self.resolveTimeout = resolveTimeout
self.watchdogGracePeriod = watchdogGracePeriod
self.interface = components.interface
self.completion = completion
super.init()
}
private static func serviceEndpointComponents(
from result: NWBrowser.Result
) -> (name: String, type: String, domain: String, interface: NWInterface?)? {
guard case let NWEndpoint.service(name: name, type: type, domain: domain, interface: interface) = result.endpoint else {
return nil
}
return (name: name, type: type, domain: domain, interface: interface)
}
func start() {
service.delegate = self
service.resolve(withTimeout: resolveTimeout)
let timeoutWorkItem = DispatchWorkItem { [weak self] in
self?.finish(endpoints: [])
}
self.timeoutWorkItem = timeoutWorkItem
DispatchQueue.main.asyncAfter(deadline: .now() + resolveTimeout + watchdogGracePeriod, execute: timeoutWorkItem)
}
func cancel() {
finish(endpoints: [], notify: false)
}
func netServiceDidResolveAddress(_ sender: NetService) {
finish(endpoints: Self.resolvedEndpoints(from: sender, interface: interface))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
finish(endpoints: [])
}
private func finish(endpoints: [NearbyResolvedBonjourEndpoint], notify: Bool = true) {
guard !finished else { return }
finished = true
timeoutWorkItem?.cancel()
service.stop()
service.delegate = nil
let completion = completion
self.completion = nil
if notify {
DispatchQueue.main.async {
completion?(endpoints)
}
}
}
private static func resolvedEndpoints(
from service: NetService,
interface: NWInterface?
) -> [NearbyResolvedBonjourEndpoint] {
let endpoints = (service.addresses ?? [])
.compactMap { resolvedEndpoint(from: $0, interface: interface) }
var seenDescriptions = Set<String>()
let uniqueEndpoints = endpoints.filter { endpoint in
seenDescriptions.insert(endpoint.description).inserted
}
return uniqueEndpoints.sorted { lhs, rhs in
if lhs.isIPv4 != rhs.isIPv4 {
return lhs.isIPv4
}
if lhs.isLinkLocal != rhs.isLinkLocal {
return !lhs.isLinkLocal
}
return lhs.description < rhs.description
}
}
private static func resolvedEndpoint(
from addressData: Data,
interface: NWInterface?
) -> NearbyResolvedBonjourEndpoint? {
addressData.withUnsafeBytes { rawBuffer in
guard let baseAddress = rawBuffer.baseAddress else { return nil }
let sockaddrPointer = baseAddress.assumingMemoryBound(to: sockaddr.self)
let addressFamily = Int32(sockaddrPointer.pointee.sa_family)
guard addressFamily == AF_INET || addressFamily == AF_INET6 else {
return nil
}
var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
var portBuffer = [CChar](repeating: 0, count: Int(NI_MAXSERV))
let result = getnameinfo(
sockaddrPointer,
socklen_t(addressData.count),
&hostBuffer,
socklen_t(hostBuffer.count),
&portBuffer,
socklen_t(portBuffer.count),
NI_NUMERICHOST | NI_NUMERICSERV
)
guard result == 0 else { return nil }
var host = String(cString: hostBuffer)
if addressFamily == AF_INET6 {
host = scopedIPv6Host(host, addressData: addressData)
}
guard let portRawValue = UInt16(String(cString: portBuffer)),
let port = NWEndpoint.Port(rawValue: portRawValue) else {
return nil
}
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: port)
let description: String
if addressFamily == AF_INET6 {
description = "[\(host)]:\(portRawValue)"
} else {
description = "\(host):\(portRawValue)"
}
let isIPv6LinkLocal = addressFamily == AF_INET6 && host.lowercased().hasPrefix("fe80:")
return NearbyResolvedBonjourEndpoint(
endpoint: endpoint,
host: host,
port: portRawValue,
interface: isIPv6LinkLocal ? interface : nil,
description: description,
isIPv4: addressFamily == AF_INET,
isLinkLocal: isIPv6LinkLocal
)
}
}
private static func scopedIPv6Host(
_ host: String,
addressData: Data
) -> String {
guard !host.contains("%"),
host.lowercased().hasPrefix("fe80:") else {
return host
}
guard let scopeID = ipv6ScopeID(from: addressData), scopeID != 0 else {
return host
}
return "\(host)%\(scopeID)"
}
private static func ipv6ScopeID(from addressData: Data) -> UInt32? {
addressData.withUnsafeBytes { rawBuffer in
guard let baseAddress = rawBuffer.baseAddress else { return nil }
let sockaddrPointer = baseAddress.assumingMemoryBound(to: sockaddr.self)
guard Int32(sockaddrPointer.pointee.sa_family) == AF_INET6 else { return nil }
return baseAddress.assumingMemoryBound(to: sockaddr_in6.self).pointee.sin6_scope_id
}
}
private static func normalizedServiceDomain(_ domain: String) -> String {
let trimmedDomain = domain.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedDomain.isEmpty else { return "local." }
return trimmedDomain.hasSuffix(".") ? trimmedDomain : "\(trimmedDomain)."
}
private static func normalizedServiceType(_ type: String) -> String {
let trimmedType = type.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedType.hasSuffix(".") ? trimmedType : "\(trimmedType)."
}
}
+134 -43
View File
@@ -12,11 +12,12 @@ import Network
import System import System
import BigInt import BigInt
import SwiftECC import SwiftECC
import LUI
class NearbyConnection { class NearbyConnection {
private let maxFrameLength = 5 * 1024 * 1024 private let maxFrameLength = 5 * 1024 * 1024
private static let dispatchQueue = DispatchQueue(label: "com.leonboettger.quickdrop.queue", qos: .utility) static let dispatchQueue = DispatchQueue(label: "com.leonboettger.quickdrop.queue", qos: .utility)
let id: String let id: String
let connection: NWConnection let connection: NWConnection
@@ -28,8 +29,13 @@ class NearbyConnection {
var cancelled: Bool = false var cancelled: Bool = false
var isTransferring: Bool = false { var isTransferring: Bool = false {
didSet { didSet {
log("[NearbyConnection \(self.id)] Now transferring. Setting up data transfer inactivity timer.") dataTransferInactivityTimer?.cancel()
startDataTransferTimer(previousBytesTransferred: bytesTransferred) dataTransferInactivityTimer = nil
if isTransferring {
log("[NearbyConnection \(self.id)] Now transferring. Setting up data transfer inactivity timer.")
startDataTransferTimer(previousBytesTransferred: bytesTransferred)
}
} }
} }
@@ -37,7 +43,11 @@ class NearbyConnection {
private var connectionClosed: Bool = false private var connectionClosed: Bool = false
private var inactivityTimer: DispatchSourceTimer? private var inactivityTimer: DispatchSourceTimer?
private let timeoutInterval: TimeInterval = 30 private var dataTransferInactivityTimer: DispatchSourceTimer?
private let ackInterval: TimeInterval = 5
private let ackTimeoutInterval: TimeInterval = 60
private var lastKeepAliveFrameSent = Date()
// UKEY2-related state // UKEY2-related state
var privateKey: ECPrivateKey? var privateKey: ECPrivateKey?
@@ -117,7 +127,7 @@ class NearbyConnection {
func connectionClosedByPeer(onError: @escaping () -> Void) { func connectionClosedByPeer(onError: @escaping () -> Void) {
NearbyConnection.dispatchQueue.asyncAfter(deadline: .now() + 0.5) { NearbyConnection.dispatchQueue.asyncAfter(deadline: .now() + 1) {
// If already closed, ignore the error, as file transfer has been processed // If already closed, ignore the error, as file transfer has been processed
if !self.connectionClosed { if !self.connectionClosed {
@@ -185,7 +195,14 @@ class NearbyConnection {
} }
guard let content = content else { guard let content = content else {
log("[NearbyConnection \(self.id)] Received nil content during receiveFrameAsync(). IsComplete: \(isComplete)") log("[NearbyConnection \(self.id)] Received nil content during receiveFrameAsync(). IsComplete: \(isComplete)")
assertionFailure() self.lastError = NearbyError.protocolError("Received empty frame header")
self.protocolError()
return
}
guard content.count == 4 else {
log("[NearbyConnection \(self.id)] Received invalid frame header size \(content.count), expected 4")
self.lastError = NearbyError.protocolError("Unexpected frame header length")
self.protocolError()
return return
} }
let frameLength = UInt32(content[0]) << 24 | UInt32(content[1]) << 16 | UInt32(content[2]) << 8 | UInt32(content[3]) let frameLength = UInt32(content[0]) << 24 | UInt32(content[1]) << 16 | UInt32(content[2]) << 8 | UInt32(content[3])
@@ -202,7 +219,7 @@ class NearbyConnection {
private func receiveFrameAsync(length: UInt32) { private func receiveFrameAsync(length: UInt32) {
connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { [self] content, _, isComplete, _ in connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { [self] content, _, isComplete, error in
if self.connectionClosed { if self.connectionClosed {
log("[NearbyConnection \(self.id)] Received FIN from peer, connection closed.") log("[NearbyConnection \(self.id)] Received FIN from peer, connection closed.")
connection.cancel() connection.cancel()
@@ -213,6 +230,13 @@ class NearbyConnection {
if let content = content, !content.isEmpty { if let content = content, !content.isEmpty {
log("[NearbyConnection \(self.id)] Connection closed by peer during receiveFrameAsync(length:), and frame not empty. Frame length: \(content.count)") log("[NearbyConnection \(self.id)] Connection closed by peer during receiveFrameAsync(length:), and frame not empty. Frame length: \(content.count)")
if content.count == Int(length) {
self.processReceivedFrame(frameData: content)
}
else {
log("[NearbyConnection \(self.id)] Dropping trailing partial frame during connection close. Expected \(length) bytes, got \(content.count)")
}
} }
else { else {
log("[NearbyConnection \(self.id)] Connection closed by peer during receiveFrameAsync(length:), but no content received anymore (\(String(describing: content))).") log("[NearbyConnection \(self.id)] Connection closed by peer during receiveFrameAsync(length:), but no content received anymore (\(String(describing: content))).")
@@ -226,11 +250,23 @@ class NearbyConnection {
return return
} }
if let error = error {
log("[NearbyConnection \(self.id)] Error during receiveFrameAsync(length:): \(error)")
self.lastError = error
self.protocolError()
return
}
guard let content = content else { guard let content = content else {
log("[NearbyConnection \(self.id)] Received nil content during receiveFrameAsync(length:). IsComplete: \(isComplete)") log("[NearbyConnection \(self.id)] Received nil content during receiveFrameAsync(length:). IsComplete: \(isComplete)")
self.protocolError() self.protocolError()
return return
} }
guard content.count == Int(length) else {
log("[NearbyConnection \(self.id)] Dropping partial frame body. Expected \(length) bytes, got \(content.count)")
self.lastError = NearbyError.protocolError("Unexpected frame body length")
self.protocolError()
return
}
self.startAndResetHeartbeatTimer() self.startAndResetHeartbeatTimer()
self.processReceivedFrame(frameData: content) self.processReceivedFrame(frameData: content)
@@ -252,7 +288,10 @@ class NearbyConnection {
UInt8(truncatingIfNeeded: length), UInt8(truncatingIfNeeded: length),
]) ])
lengthPrefixedData.append(frame) lengthPrefixedData.append(frame)
connection.send(content: lengthPrefixedData, completion: .contentProcessed { _ in connection.send(content: lengthPrefixedData, completion: .contentProcessed { error in
if let error = error {
log("[NearbyConnection \(self.id)] Error while sending frame: \(error)")
}
if let completion = completion { if let completion = completion {
completion() completion()
} }
@@ -363,9 +402,17 @@ class NearbyConnection {
let d2dMsg = try Securegcm_DeviceToDeviceMessage(serializedBytes: decryptedData) let d2dMsg = try Securegcm_DeviceToDeviceMessage(serializedBytes: decryptedData)
guard d2dMsg.hasMessage, d2dMsg.hasSequenceNumber else { throw NearbyError.requiredFieldMissing("d2dMessage.message|sequenceNumber") } guard d2dMsg.hasMessage, d2dMsg.hasSequenceNumber else { throw NearbyError.requiredFieldMissing("d2dMessage.message|sequenceNumber") }
clientSeq += 1 let receivedSeq = d2dMsg.sequenceNumber
let expectedSeq = clientSeq + 1
guard d2dMsg.sequenceNumber == clientSeq else { throw NearbyError.protocolError("Wrong sequence number. Expected \(clientSeq), got \(d2dMsg.sequenceNumber)") } if receivedSeq == expectedSeq {
clientSeq = receivedSeq
} else if receivedSeq <= clientSeq {
log("[NearbyConnection \(self.id)] Ignoring duplicate/stale frame sequence \(receivedSeq). Current sequence is \(clientSeq)")
return
} else {
throw NearbyError.protocolError("Wrong sequence number. Expected \(expectedSeq), got \(receivedSeq)")
}
let offlineFrame = try Location_Nearby_Connections_OfflineFrame(serializedBytes: d2dMsg.message) let offlineFrame = try Location_Nearby_Connections_OfflineFrame(serializedBytes: d2dMsg.message)
if offlineFrame.hasV1, offlineFrame.v1.hasType, case .payloadTransfer = offlineFrame.v1.type { if offlineFrame.hasV1, offlineFrame.v1.hasType, case .payloadTransfer = offlineFrame.v1.type {
@@ -377,14 +424,29 @@ class NearbyConnection {
let chunk = payloadTransfer.payloadChunk let chunk = payloadTransfer.payloadChunk
guard header.hasType, header.hasID else { throw NearbyError.requiredFieldMissing("payloadHeader.type|id") } guard header.hasType, header.hasID else { throw NearbyError.requiredFieldMissing("payloadHeader.type|id") }
guard payloadTransfer.hasPayloadChunk, chunk.hasOffset, chunk.hasFlags else {
if payloadTransfer.controlMessage.event == .payloadCanceled { if payloadTransfer.hasPayloadChunk {
guard chunk.hasOffset, chunk.hasFlags else {
log("[NearbyConnection \(self.id)] Payload transfer chunk is missing offset or flags. Frame is \(payloadTransfer.debugDescription)")
throw NearbyError.requiredFieldMissing("payloadChunk.offset|flags")
}
} else if payloadTransfer.hasControlMessage || payloadTransfer.packetType == .control {
switch payloadTransfer.controlMessage.event {
case .payloadCanceled:
log("[NearbyConnection \(self.id)] Cancel control frame received.") log("[NearbyConnection \(self.id)] Cancel control frame received.")
throw NearbyError.canceled(reason: .userCanceled) throw NearbyError.canceled(reason: .userCanceled)
case .payloadReceivedAck:
// Legacy peers can send ACKs as control events instead of packetType=.payloadAck.
return
case .payloadError:
throw NearbyError.protocolError("Peer reported payload error")
case .unknownEventType:
return
} }
} else if payloadTransfer.packetType == .payloadAck {
log("[NearbyConnection \(self.id)] Payload transfer chunk is missing offset or flags. Frame is \(payloadTransfer.debugDescription)") return
} else {
log("[NearbyConnection \(self.id)] Payload transfer frame missing supported payload content. Frame is \(payloadTransfer.debugDescription)")
throw NearbyError.requiredFieldMissing("payloadChunk.offset|flags") throw NearbyError.requiredFieldMissing("payloadChunk.offset|flags")
} }
@@ -428,12 +490,18 @@ class NearbyConnection {
} }
} }
else if offlineFrame.hasV1, offlineFrame.v1.hasType, case .keepAlive = offlineFrame.v1.type { else if offlineFrame.hasV1, offlineFrame.v1.hasType, case .keepAlive = offlineFrame.v1.type {
if offlineFrame.v1.hasKeepAlive {
let bytesTransferred = self.bytesTransferred let keepAlive = offlineFrame.v1.keepAlive
let gigabytesTransferred = Double(bytesTransferred) / 1_000_000_000 if !keepAlive.hasAck || !keepAlive.ack {
sendKeepAlive(ack: true)
log("[NearbyConnection \(self.id)] Sent keep-alive, \(self.bytesTransferred) bytes (\(gigabytesTransferred) GB) sent") }
sendKeepAlive(ack: true) } else {
// Older peers can omit the keepAlive payload; default to ACK reply.
sendKeepAlive(ack: true)
}
} else if offlineFrame.hasV1, offlineFrame.v1.hasType, case .disconnection = offlineFrame.v1.type {
log("[NearbyConnection \(self.id)] Received disconnection frame.")
disconnect()
} else { } else {
if offlineFrame.hasV1, offlineFrame.v1.hasType, offlineFrame.v1.type == .bandwidthUpgradeRetry { if offlineFrame.hasV1, offlineFrame.v1.hasType, offlineFrame.v1.type == .bandwidthUpgradeRetry {
@@ -467,17 +535,12 @@ class NearbyConnection {
guard peerKey.hasEcP256PublicKey else { throw NearbyError.requiredFieldMissing("peerKey.ecP256PublicKey") } guard peerKey.hasEcP256PublicKey else { throw NearbyError.requiredFieldMissing("peerKey.ecP256PublicKey") }
let domain = Domain.instance(curve: .EC256r1) let domain = Domain.instance(curve: .EC256r1)
var clientX = peerKey.ecP256PublicKey.x let clientX = peerKey.ecP256PublicKey.x.ensureLength(length: 32)
var clientY = peerKey.ecP256PublicKey.y let clientY = peerKey.ecP256PublicKey.y.ensureLength(length: 32)
if clientX.count > 32 {
clientX = clientX.suffix(32)
}
if clientY.count > 32 {
clientY = clientY.suffix(32)
}
let key = try ECPublicKey(domain: domain, w: Point(BInt(magnitude: [UInt8](clientX)), BInt(magnitude: [UInt8](clientY)))) let key = try ECPublicKey(domain: domain, w: Point(BInt(magnitude: [UInt8](clientX)), BInt(magnitude: [UInt8](clientY))))
let dhs = try (privateKey?.domain.multiplyPoint(key.w, privateKey!.s).x.asMagnitudeBytes())! let dhsRaw = try (privateKey?.domain.multiplyPoint(key.w, privateKey!.s).x.asMagnitudeBytes())!
let dhs = Data(dhsRaw).ensureLength(length: 32)
var sha = SHA256() var sha = SHA256()
sha.update(data: dhs) sha.update(data: dhs)
let derivedSecretKey = Data(sha.finalize()) let derivedSecretKey = Data(sha.finalize())
@@ -540,6 +603,8 @@ class NearbyConnection {
connection.stateUpdateHandler = nil connection.stateUpdateHandler = nil
inactivityTimer?.cancel() inactivityTimer?.cancel()
inactivityTimer = nil inactivityTimer = nil
dataTransferInactivityTimer?.cancel()
dataTransferInactivityTimer = nil
connectionClosed = true connectionClosed = true
connection.send(content: nil, isComplete: true, completion: .contentProcessed { _ in }) connection.send(content: nil, isComplete: true, completion: .contentProcessed { _ in })
} }
@@ -551,14 +616,22 @@ class NearbyConnection {
offlineFrame.v1.type = .disconnection offlineFrame.v1.type = .disconnection
offlineFrame.v1.disconnection = Location_Nearby_Connections_DisconnectionFrame() offlineFrame.v1.disconnection = Location_Nearby_Connections_DisconnectionFrame()
if encryptionDone { if connectionClosed {
try encryptAndSendOfflineFrame(offlineFrame) disconnect()
} else { return
try sendFrameAsync(offlineFrame.serializedData())
} }
log("[NearbyConnection \(self.id)] Sent disconnection frame during sendDisconnectionAndDisconnect") let finishDisconnect: () -> Void = {
disconnect() log("[NearbyConnection \(self.id)] Sent disconnection frame during sendDisconnectionAndDisconnect")
self.disconnect()
}
if encryptionDone {
try encryptAndSendOfflineFrame(offlineFrame, completion: finishDisconnect)
} else {
let data = try offlineFrame.serializedData()
sendFrameAsync(data, completion: finishDisconnect)
}
} }
@@ -587,25 +660,36 @@ class NearbyConnection {
} else { } else {
try sendFrameAsync(offlineFrame.serializedData()) try sendFrameAsync(offlineFrame.serializedData())
} }
self.lastKeepAliveFrameSent = Date()
} catch { } catch {
log("[NearbyConnection \(self.id)] Error sending KEEP_ALIVE: \(error)") log("[NearbyConnection \(self.id)] Error sending KEEP_ALIVE: \(error)")
} }
log("[NearbyConnection \(self.id)] Sent keep-alive \(ack ? "ack" : "no ack"), \(self.bytesTransferred) bytes (\(Double(bytesTransferred) / 1_000_000_000) GB) sent")
} }
func startAndResetHeartbeatTimer() { func startAndResetHeartbeatTimer() {
if connectionClosed {
return
}
if encryptionDone && lastKeepAliveFrameSent.isOlderThan(seconds: ackInterval) {
sendKeepAlive(ack: false)
}
// Cancel previous timer if any // Cancel previous timer if any
inactivityTimer?.cancel() inactivityTimer?.cancel()
inactivityTimer = DispatchSource.makeTimerSource(queue: NearbyConnection.dispatchQueue) inactivityTimer = DispatchSource.makeTimerSource(queue: NearbyConnection.dispatchQueue)
inactivityTimer?.schedule(deadline: .now() + timeoutInterval) inactivityTimer?.schedule(deadline: .now() + ackTimeoutInterval)
inactivityTimer?.setEventHandler { [weak self] in inactivityTimer?.setEventHandler { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if !self.connectionClosed { if !self.connectionClosed {
log("[NearbyConnection \(self.id)] Connection timeout: No message received in \(self.timeoutInterval) seconds") log("[NearbyConnection \(self.id)] Connection timeout: No message received in \(self.ackTimeoutInterval) seconds")
self.lastError = NearbyError.canceled(reason: .timedOut) self.lastError = NearbyError.canceled(reason: .timedOut)
self.disconnect() self.disconnect()
} }
@@ -616,7 +700,14 @@ class NearbyConnection {
func startDataTransferTimer(previousBytesTransferred: Int64) { func startDataTransferTimer(previousBytesTransferred: Int64) {
NearbyConnection.dispatchQueue.asyncAfter(deadline: .now() + timeoutInterval) { dataTransferInactivityTimer?.cancel()
dataTransferInactivityTimer = DispatchSource.makeTimerSource(queue: NearbyConnection.dispatchQueue)
dataTransferInactivityTimer?.schedule(deadline: .now() + ackTimeoutInterval)
dataTransferInactivityTimer?.setEventHandler { [weak self] in
guard let self = self else { return }
guard !self.connectionClosed, self.isTransferring else { return }
if previousBytesTransferred < self.bytesTransferred { if previousBytesTransferred < self.bytesTransferred {
// everything good, transferred more than last check, schedule next check // everything good, transferred more than last check, schedule next check
@@ -624,13 +715,13 @@ class NearbyConnection {
} }
else { else {
// connection stale, need to abort // connection stale, need to abort
if !self.connectionClosed { log("[NearbyConnection \(self.id)] Connection timeout: No more data received in \(self.ackTimeoutInterval) seconds")
log("[NearbyConnection \(self.id)] Connection timeout: No more data received in \(self.timeoutInterval) seconds") self.lastError = NearbyError.canceled(reason: .timedOut)
self.lastError = NearbyError.canceled(reason: .timedOut) self.disconnect()
self.disconnect()
}
} }
} }
dataTransferInactivityTimer?.resume()
} }
@@ -17,9 +17,12 @@ import SwiftECC
import CryptoKit import CryptoKit
import SwiftUI import SwiftUI
import QRCode import QRCode
import LUI
public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate, ObservableObject { public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate, ObservableObject {
// MARK: Private Properties
#if !EXTENSION #if !EXTENSION
private let sleepManager = SleepManager.shared private let sleepManager = SleepManager.shared
#endif #endif
@@ -30,9 +33,14 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
private var outboundAppDelegates: [OutboundAppDelegate] = [] private var outboundAppDelegates: [OutboundAppDelegate] = []
private var inboundAppDelegates: [InboundAppDelegate] = [] private var inboundAppDelegates: [InboundAppDelegate] = []
private var outgoingTransfers: [String: OutgoingTransferInfo] = [:] private var outgoingTransfers: [String: OutgoingTransferInfo] = [:]
private var pendingOutgoingTransferPreparations: [String: UUID] = [:]
private var outgoingServiceResolvers: [String: NearbyBonjourServiceResolver] = [:]
private let pendingNotificationSyncTrustQueue = DispatchQueue(label: "NearbyConnectionManager.pendingNotificationSyncTrust")
private var pendingNotificationSyncTrust: [String: PendingReceiverTrust] = [:]
private var startedDeviceDiscovery = false private var startedDeviceDiscovery = false
private var startedAdvertising = false private var startedAdvertising = false { didSet { informAboutStatus() }}
private var browsers: [NWBrowser] = [] private var browsers: [NWBrowser] = []
private let deviceNameResolver = NearbyDeviceNameResolver()
private let serviceTypes = ["_FC9F5ED42C8A._tcp."] private let serviceTypes = ["_FC9F5ED42C8A._tcp."]
private var qrCodePrivateKey: ECPrivateKey? private var qrCodePrivateKey: ECPrivateKey?
private var qrCodeAdvertisingToken: Data? private var qrCodeAdvertisingToken: Data?
@@ -40,33 +48,40 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
private let hasConnectionMonitor = NWPathMonitor() private let hasConnectionMonitor = NWPathMonitor()
private let connectionMonitorQueue = DispatchQueue(label: "NetworkConnectionMonitorQueue") private let connectionMonitorQueue = DispatchQueue(label: "NetworkConnectionMonitorQueue")
private var securityScopeUrl: URL? private var securityScopeUrl: URL?
private static let customDeviceNameKey = "com.leonboettger.quickdrop.deviceName"
private var defaultPort: NWEndpoint.Port = 50362
private let txtKeyEndpointInfo = "n"
private let txtQuickDropCapabilities = "qd_caps"
private let txtQuickDropDeviceKeyFingerprint = "qd_kid"
private let notificationSyncCapabilityToken = "nsync1"
private let clipboardReceiveCapabilityToken = "cliprecv1"
private let unnamedAndroidFallbackDeviceName = "AndroidDevice".localized()
public let deviceInfo: EndpointInfo
public let endpointID: [UInt8] = generateEndpointID() // MARK: Shared Instance
public static let shared = NearbyConnectionManager() public static let shared = NearbyConnectionManager()
// MARK: Published State
@Published var attachments: AttachmentDetails? = nil @Published var attachments: AttachmentDetails? = nil
@Published var hasLocalNetworkPermission = true @Published var hasLocalNetworkPermission = true
@Published var isConnectedToLocalNetwork = true @Published var isConnectedToLocalNetwork = true { didSet { informAboutStatus() }}
@Published var deviceInfo: EndpointInfo
public var connectionUpdateCallback: (Bool) -> Void = { _ in } {
didSet { // MARK: Public Properties
connectionUpdateCallback(isConnectedToLocalNetwork)
} public var endpointID: [UInt8] = getEndpointID(forceRegeneration: false)
} public var connectionUpdateCallback: (Bool) -> Void = { _ in } { didSet { informAboutStatus() }}
public var changedDeviceNameCallback: () -> Void = { }
// MARK: Initializers
override init() { override init() {
self.deviceInfo = Self.getEndpointInfo()
#if os(macOS)
self.deviceInfo = EndpointInfo(name: Host.current().localizedName ?? "Mac", deviceType: .computer)
#else
let marketingName = Device.current.description.withoutBracketedContent
let isiPad = UIDevice.current.model == "iPad"
self.deviceInfo = EndpointInfo(name: marketingName, deviceType: isiPad ? .tablet : .phone)
#endif
tcpListener = try! NWListener(using: NWParameters(tls: .none)) tcpListener = try! NWListener(using: NWParameters(tls: .none))
super.init() super.init()
@@ -88,7 +103,6 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
log("[NearbyConnectionManager] Local network lost.") log("[NearbyConnectionManager] Local network lost.")
} }
self.isConnectedToLocalNetwork = isConnected self.isConnectedToLocalNetwork = isConnected
self.connectionUpdateCallback(isConnected)
} }
} }
@@ -118,22 +132,123 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
public func becomeVisible() { // MARK: Deinitializer
deinit {
self.stopAccessingSaveDirectory()
}
public static func getCustomDeviceName() -> String? {
let storedName = AppGroup.appGroupUD.string(forKey: Self.customDeviceNameKey)
guard let name = storedName, !name.isEmpty else {
return nil
}
return name
}
public func setCustomDeviceName(to newName: String) {
AppGroup.appGroupUD.setValue(newName, forKey: Self.customDeviceNameKey)
self.deviceInfo = Self.getEndpointInfo()
changedDeviceNameCallback()
if startedAdvertising {
self.becomeInvisible()
runAfter(seconds: 0.5) {
self.becomeVisible()
}
}
}
private static func getEndpointInfo() -> EndpointInfo {
let deviceType = isMac() ? RemoteDeviceInfo.DeviceType.computer : (isiPadOrMac() ? .tablet : .phone)
if let customName = getCustomDeviceName() {
return EndpointInfo(name: String(customName), deviceType: deviceType)
}
#if os(macOS)
return EndpointInfo(name: String((Host.current().localizedName ?? "Mac")), deviceType: deviceType)
#else
return EndpointInfo(name: String(Device.current.description.withoutBracketedContent), deviceType: deviceType)
#endif
}
public func becomeVisible(randomPort: Bool = false) {
if startedAdvertising { if startedAdvertising {
log("[NearbyConnectionManager] Already advertising, skipping") log("[NearbyConnectionManager] Already advertising, skipping")
return return
} }
log("[NearbyConnectionManager] Becoming visible") log("[NearbyConnectionManager] Becoming visible")
let parameters = NWParameters(tls: .none)
tcpListener = try! NWListener(using: NWParameters(tls: .none)) do {
startedAdvertising = true if randomPort {
self.tcpListener = try NWListener(using: parameters)
tcpListener.stateUpdateHandler = { (state: NWListener.State) in } else {
if case .ready = state { self.tcpListener = try NWListener(using: parameters, on: defaultPort)
self.initMDNS()
} }
} }
catch {
log("[NearbyConnectionManager] Error starting TCP listener: \(error). Trying with random port.")
runAfter(seconds: 1) {
self.becomeVisible(randomPort: true)
}
return
}
startedAdvertising = true
tcpListener.stateUpdateHandler = { state in
switch state {
case .ready:
log("[NearbyConnectionManager] Listener ready")
self.initMDNS(forceIDRegeneration: randomPort)
if let port = self.tcpListener.port, port != self.defaultPort {
log("[NearbyConnectionManager] Updated default port to \(port)")
self.defaultPort = port
}
case .failed(let error):
log("[NearbyConnectionManager] Listener failed: \(error)")
self.becomeInvisible()
runAfter(seconds: 1) {
if case .posix(let posixError) = error,
posixError == .EADDRINUSE {
self.becomeVisible(randomPort: true)
}
else {
self.becomeVisible()
}
}
case .cancelled:
log("[NearbyConnectionManager] Listener cancelled")
case .setup:
log("[NearbyConnectionManager] Listener setup")
case .waiting(let state):
log("[NearbyConnectionManager] Listener waiting: \(state)")
@unknown default:
log("[NearbyConnectionManager] Listener unknown state")
}
}
tcpListener.newConnectionHandler = { (connection: NWConnection) in tcpListener.newConnectionHandler = { (connection: NWConnection) in
let id = UUID().uuidString let id = UUID().uuidString
let conn = InboundNearbyConnection(connection: connection, id: id) let conn = InboundNearbyConnection(connection: connection, id: id)
@@ -141,6 +256,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
conn.delegate = self conn.delegate = self
conn.start() conn.start()
} }
tcpListener.start(queue: .global(qos: .utility)) tcpListener.start(queue: .global(qos: .utility))
} }
@@ -159,11 +275,13 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
private static func generateEndpointID() -> [UInt8] { private static func getEndpointID(forceRegeneration: Bool) -> [UInt8] {
// Try to retrieve from UserDefaults // Try to retrieve from UserDefaults
if let savedString = Settings.shared.endpointID, if !forceRegeneration,
let savedString = Settings.sharedInstance.endpointID,
let savedData = savedString.data(using: .utf8) { let savedData = savedString.data(using: .utf8) {
log("[NearbyConnectionManager] Using cached endpoint ID: \(savedString)")
return [UInt8](savedData) return [UInt8](savedData)
} }
@@ -176,13 +294,17 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
// Save to UserDefaults as String // Save to UserDefaults as String
let idString = String(bytes: id, encoding: .utf8) ?? "" let idString = String(bytes: id, encoding: .utf8) ?? ""
Settings.shared.endpointID = idString Settings.sharedInstance.endpointID = idString
log("[NearbyConnectionManager] Storing new endpoint ID: \(idString) ======================================")
return id return id
} }
private func initMDNS() { private func initMDNS(forceIDRegeneration: Bool) {
self.endpointID = Self.getEndpointID(forceRegeneration: forceIDRegeneration)
let nameBytes: [UInt8] = [ let nameBytes: [UInt8] = [
0x23, // PCP 0x23, // PCP
endpointID[0], endpointID[1], endpointID[2], endpointID[3], endpointID[0], endpointID[1], endpointID[2], endpointID[3],
@@ -196,15 +318,34 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
mdnsServices = serviceTypes.map { serviceType in mdnsServices = serviceTypes.map { serviceType in
let service = NetService(domain: "", type: serviceType, name: name, port: port) let service = NetService(domain: "", type: serviceType, name: name, port: port)
service.delegate = self service.delegate = self
service.setTXTRecord(NetService.data(fromTXTRecord: [ var txtRecord: [String: Data] = [
"n": deviceInfo.serialize().urlSafeBase64EncodedString().data(using: .utf8)!, txtKeyEndpointInfo: deviceInfo.serialize().urlSafeBase64EncodedString().data(using: .utf8)!,
])) ]
if let keyFingerprint = notificationSyncKeyFingerprintHex() {
txtRecord[txtQuickDropDeviceKeyFingerprint] = Data(keyFingerprint.utf8)
}
#if os(macOS)
let capabilityTokens = [
notificationSyncCapabilityToken,
clipboardReceiveCapabilityToken,
]
txtRecord[txtQuickDropCapabilities] = Data(capabilityTokens.joined(separator: ",").utf8)
#endif
service.setTXTRecord(NetService.data(fromTXTRecord: txtRecord))
service.publish() service.publish()
return service return service
} }
} }
private func notificationSyncKeyFingerprintHex() -> String? {
guard let keyID = IdentityManager.shared.getPublicKey()?.toGenericPublicKey().id() else {
return nil
}
return keyID.hex.lowercased()
}
private func stopMDNS() { private func stopMDNS() {
// Gracefully stop each published service // Gracefully stop each published service
for service in mdnsServices { for service in mdnsServices {
@@ -228,21 +369,35 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
func connectionWasTerminated(connection: InboundNearbyConnection, error: Error?) { func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?) {
incomingConnections.removeValue(forKey: connection.id) incomingConnections.removeValue(forKey: connection.id)
// If there are no more incoming connections, we can stop accessing the save directory // Control transfers suppress normal success callbacks, but
if incomingConnections.isEmpty { // still forward failures so pending consent/error UI can be cleaned up.
self.stopAccessingSaveDirectory() if connection.isControlTransfer, error == nil {
return
} }
if !connection.wasUserRejected { if !connection.wasUserRejected {
inboundAppDelegates.forEach { delegate in inboundAppDelegates.forEach { delegate in
delegate.connectionWasTerminated(connectionID: connection.id, from: connection.remoteDeviceInfo ?? RemoteDeviceInfo(name: "UnknownDevice".localized(), type: .unknown), wasPlainTextTransfer: connection.isPlainTextTransfer, error: error) delegate.connectionWasTerminated(
connectionID: connection.id,
from: connection.remoteDeviceInfo,
savedFiles: savedFiles,
wasPlainTextTransfer: connection.isPlainTextTransfer,
wasClipboardSync: connection.isClipboardSyncTransfer,
error: error
)
} }
} }
} }
func pairingSetupConfirmed(connection _: InboundNearbyConnection) {
inboundAppDelegates.forEach { delegate in
delegate.pairingSetupConfirmed()
}
}
func showPlusScreen() { func showPlusScreen() {
inboundAppDelegates.forEach { delegate in inboundAppDelegates.forEach { delegate in
@@ -258,11 +413,16 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
public func submitUserConsent(transferID: String, accept: Bool, trustDevice: Bool) { public func submitUserConsent(
transferID: String,
accept: Bool,
trustDevice: Bool,
pairingToken: String? = nil
) {
guard let conn = incomingConnections[transferID] else { return } guard let conn = incomingConnections[transferID] else { return }
log("[NearbyConnectionManager] Submitting user consent for transfer \(transferID), accepted: \(accept)") log("[NearbyConnectionManager] Submitting user consent for transfer \(transferID), accepted: \(accept)")
conn.submitUserConsent(accepted: accept, trustDevice: trustDevice) conn.submitUserConsent(accepted: accept, trustDevice: trustDevice, pairingToken: pairingToken)
} }
@@ -286,6 +446,8 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
self.addFoundDevice(service: res) self.addFoundDevice(service: res)
case let .removed(res): case let .removed(res):
self.removeFoundDevice(service: res) self.removeFoundDevice(service: res)
case let .changed(_, new, _):
self.addFoundDevice(service: new)
default: default:
log("[NearbyConnectionManager] Ignoring change \(change)") log("[NearbyConnectionManager] Ignoring change \(change)")
} }
@@ -330,7 +492,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
browsers.removeAll() browsers.removeAll()
startedDeviceDiscovery = false startedDeviceDiscovery = false
deviceNameResolver.cancelAll()
for delegate in outboundAppDelegates { for delegate in outboundAppDelegates {
@@ -343,6 +505,16 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
var isDeviceDiscoveryActive: Bool {
startedDeviceDiscovery
}
func discoveredDevices() -> [RemoteDeviceInfo] {
foundServices.values.compactMap(\.device)
}
public func addOutboundAppDelegate(_ delegate: OutboundAppDelegate) { public func addOutboundAppDelegate(_ delegate: OutboundAppDelegate) {
outboundAppDelegates.append(delegate) outboundAppDelegates.append(delegate)
for service in foundServices.values { for service in foundServices.values {
@@ -367,15 +539,22 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
public func cancelTransfer(id: String) { public func cancelTransfer(id: String) {
if let transfer = incomingConnections[id] { if let transfer = incomingConnections[id] {
transfer.cancel() transfer.cancel()
} }
if let transfer = outgoingTransfers[id] { pendingOutgoingTransferPreparations.removeValue(forKey: id)
transfer.connection.cancel() outgoingServiceResolvers.removeValue(forKey: id)?.cancel()
if let info = outgoingTransfers[id] {
info.connection.cancel()
} }
} }
public func cancelAllIncomingConnections() {
let incomingTransfers = Array(incomingConnections.values)
for transfer in incomingTransfers {
transfer.cancel()
}
}
private func endpointID(for service: NWBrowser.Result) -> String? { private func endpointID(for service: NWBrowser.Result) -> String? {
guard case let NWEndpoint.service(name: serviceName, type: _, domain: _, interface: _) = service.endpoint else { return nil } guard case let NWEndpoint.service(name: serviceName, type: _, domain: _, interface: _) = service.endpoint else { return nil }
@@ -401,13 +580,58 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
var foundService = FoundServiceInfo(service: service) var foundService = FoundServiceInfo(service: service)
guard case let NWBrowser.Result.Metadata.bonjour(txtRecord) = service.metadata else { return } guard case let NWBrowser.Result.Metadata.bonjour(txtRecord) = service.metadata else { return }
guard let endpointInfoEncoded = txtRecord.dictionary["n"] else { return } guard let endpointInfoEncoded = txtRecord.dictionary[txtKeyEndpointInfo] else { return }
guard let endpointInfoData = Data.dataFromUrlSafeBase64(endpointInfoEncoded) else { return } guard let endpointInfoData = Data.dataFromUrlSafeBase64(endpointInfoEncoded) else { return }
guard var endpointInfo = EndpointInfo(data: endpointInfoData) else { return } let parsedEndpointInfo = EndpointInfo(data: endpointInfoData)
var deviceInfo: RemoteDeviceInfo? let keyFingerprint = normalizedKeyFingerprint(
txtRecord.dictionary[txtQuickDropDeviceKeyFingerprint]
)
let capabilityTokens = Set(
txtRecord.dictionary[txtQuickDropCapabilities]?
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } ?? []
)
let supportsNotificationSync = capabilityTokens.contains(notificationSyncCapabilityToken)
let supportsClipboardReceive = capabilityTokens.contains(clipboardReceiveCapabilityToken)
var endpointInfo = parsedEndpointInfo
?? EndpointInfo(name: unnamedAndroidFallbackDeviceName, deviceType: .unknown)
let isQuickDropPeer = keyFingerprint != nil
let needsDeviceNameLookup = deviceNameResolver.needsLookup(for: parsedEndpointInfo)
if needsDeviceNameLookup {
endpointInfo.name = unnamedAndroidFallbackDeviceName
}
let deviceInfo = addFoundDevice(
foundService: &foundService,
endpointInfo: endpointInfo,
endpointID: endpointID,
isQuickDropPeer: isQuickDropPeer,
keyFingerprint: keyFingerprint,
supportsNotificationSync: supportsNotificationSync,
supportsClipboardReceive: supportsClipboardReceive
)
if let _ = endpointInfo.name { if needsDeviceNameLookup {
deviceInfo = addFoundDevice(foundService: &foundService, endpointInfo: endpointInfo, endpointID: endpointID) deviceNameResolver.resolveName(for: service, endpointID: endpointID) { [weak self] deviceName in
guard let self else { return }
guard self.foundServices[endpointID]?.device?.name == self.unnamedAndroidFallbackDeviceName else { return }
var resolvedEndpointInfo = endpointInfo
resolvedEndpointInfo.name = deviceName
var resolvedFoundService = FoundServiceInfo(service: service)
_ = self.addFoundDevice(
foundService: &resolvedFoundService,
endpointInfo: resolvedEndpointInfo,
endpointID: endpointID,
isQuickDropPeer: isQuickDropPeer,
keyFingerprint: keyFingerprint,
supportsNotificationSync: supportsNotificationSync,
supportsClipboardReceive: supportsClipboardReceive
)
}
}
else {
deviceNameResolver.cancelLookup(for: endpointID)
} }
if let qrData = endpointInfo.qrCodeData, let qrCodeAdvertisingToken = qrCodeAdvertisingToken, let qrCodeNameEncryptionKey = qrCodeNameEncryptionKey { if let qrData = endpointInfo.qrCodeData, let qrCodeAdvertisingToken = qrCodeAdvertisingToken, let qrCodeNameEncryptionKey = qrCodeNameEncryptionKey {
@@ -415,10 +639,8 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
log("[NearbyConnectionManager] Device has QR data: \(qrData.base64EncodedString()), advertising token is \(qrCodeAdvertisingToken.base64EncodedString())") log("[NearbyConnectionManager] Device has QR data: \(qrData.base64EncodedString()), advertising token is \(qrCodeAdvertisingToken.base64EncodedString())")
if qrData == qrCodeAdvertisingToken { if qrData == qrCodeAdvertisingToken {
if let deviceInfo = deviceInfo { for delegate in outboundAppDelegates {
for delegate in outboundAppDelegates { delegate.startTransferWithQrCode(device: deviceInfo)
delegate.startTransferWithQrCode(device: deviceInfo)
}
} }
} }
else if qrData.count > 28 { else if qrData.count > 28 {
@@ -427,7 +649,15 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
let decryptedName = try AES.GCM.open(box, using: qrCodeNameEncryptionKey, authenticating: qrCodeAdvertisingToken) let decryptedName = try AES.GCM.open(box, using: qrCodeNameEncryptionKey, authenticating: qrCodeAdvertisingToken)
guard let name = String.init(data: decryptedName, encoding: .utf8) else { return } guard let name = String.init(data: decryptedName, encoding: .utf8) else { return }
endpointInfo.name = name endpointInfo.name = name
let deviceInfo = addFoundDevice(foundService: &foundService, endpointInfo: endpointInfo, endpointID: endpointID) let deviceInfo = addFoundDevice(
foundService: &foundService,
endpointInfo: endpointInfo,
endpointID: endpointID,
isQuickDropPeer: isQuickDropPeer,
keyFingerprint: keyFingerprint,
supportsNotificationSync: supportsNotificationSync,
supportsClipboardReceive: supportsClipboardReceive
)
for delegate in outboundAppDelegates { for delegate in outboundAppDelegates {
delegate.startTransferWithQrCode(device: deviceInfo) delegate.startTransferWithQrCode(device: deviceInfo)
} }
@@ -439,11 +669,33 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
private func addFoundDevice(foundService: inout FoundServiceInfo, endpointInfo: EndpointInfo, endpointID: String) -> RemoteDeviceInfo { private func addFoundDevice(
let deviceInfo = RemoteDeviceInfo(info: endpointInfo, id: endpointID) foundService: inout FoundServiceInfo,
endpointInfo: EndpointInfo,
endpointID: String,
isQuickDropPeer: Bool = false,
keyFingerprint: String? = nil,
supportsNotificationSync: Bool = false,
supportsClipboardReceive: Bool = false
) -> RemoteDeviceInfo {
let hadPreviousDevice = foundServices[endpointID]?.device != nil
let deviceInfo = RemoteDeviceInfo(
info: endpointInfo,
id: endpointID,
isQuickDropPeer: isQuickDropPeer,
keyFingerprint: keyFingerprint,
supportsNotificationSync: supportsNotificationSync,
supportsClipboardReceive: supportsClipboardReceive
)
foundService.device = deviceInfo foundService.device = deviceInfo
foundServices[endpointID] = foundService foundServices[endpointID] = foundService
if hadPreviousDevice {
for delegate in outboundAppDelegates {
delegate.removeDevice(id: endpointID)
}
}
for delegate in outboundAppDelegates { for delegate in outboundAppDelegates {
delegate.addDevice(device: deviceInfo) delegate.addDevice(device: deviceInfo)
} }
@@ -452,9 +704,28 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
public func resolveDiscoveredDeviceIdForKeyFingerprint(_ keyFingerprint: String) -> String? {
guard let normalizedFingerprint = normalizedKeyFingerprint(keyFingerprint) else {
return nil
}
return foundServices.values
.first(where: { $0.device?.keyFingerprint == normalizedFingerprint })?
.device?
.id
}
private func removeFoundDevice(service: NWBrowser.Result) { private func removeFoundDevice(service: NWBrowser.Result) {
guard let endpointID = endpointID(for: service) else { return } guard let endpointID = endpointID(for: service) else { return }
guard let _ = foundServices.removeValue(forKey: endpointID) else { return } deviceNameResolver.cancelLookup(for: endpointID)
guard let currentService = foundServices[endpointID] else { return }
// Ignore delayed removals for an older Bonjour result after a changed result
// has already replaced the service for this endpoint ID.
guard serviceIdentity(currentService.service) == serviceIdentity(service) else {
return
}
foundServices.removeValue(forKey: endpointID)
for delegate in outboundAppDelegates { for delegate in outboundAppDelegates {
delegate.removeDevice(id: endpointID) delegate.removeDevice(id: endpointID)
} }
@@ -467,19 +738,18 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
let domain = Domain.instance(curve: .EC256r1) let domain = Domain.instance(curve: .EC256r1)
let (pubKey, privKey) = domain.makeKeyPair() let (pubKey, privKey) = domain.makeKeyPair()
qrCodePrivateKey = privKey qrCodePrivateKey = privKey
var keyData = Data() var keyData = Data()
keyData.append(contentsOf: [0, 0, 2]) keyData.append(contentsOf: [0, 0, 2])
let keyBytes = Data(pubKey.w.x.asSignedBytes()) keyData.append(Data(pubKey.w.x.asSignedBytes()).ensureLength(length: 32))
// Sometimes, for some keys, there will be a leading zero byte. Strip that, Android really hates it (it breaks the endpoint info)
keyData.append(keyBytes.suffixOfAtMost(numBytes: 32))
let ikm = SymmetricKey(data: keyData) let ikm = SymmetricKey(data: keyData)
qrCodeAdvertisingToken = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "advertisingContext".data(using: .utf8)!, outputLength: 16).data() qrCodeAdvertisingToken = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "advertisingContext".data(using: .utf8)!, outputLength: 16).data()
qrCodeNameEncryptionKey = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "encryptionKey".data(using: .utf8)!, outputLength: 16) qrCodeNameEncryptionKey = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "encryptionKey".data(using: .utf8)!, outputLength: 16)
do { do {
let qrKey = keyData.urlSafeBase64EncodedString() let qrKey = keyData.urlSafeBase64EncodedString()
let qrCodeImage = try QRCode.build let qrCodeImage = try QRCode.build
@@ -502,29 +772,130 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
public func startOutgoingTransfer(deviceID: String, delegate: OutboundAppDelegate, urls: [URL], textToSend: String?) { @discardableResult
public func startOutgoingTransfer(
deviceID: String,
delegate: OutboundAppDelegate,
urls: [URL],
textToSend: String?,
preparationFinished: (() -> Void)? = nil,
isClipboardSyncTransfer: Bool = false,
receiverAuthenticationPolicy: ReceiverAuthenticationPolicy
) -> Bool {
log("Starting outgoing transfer to \(deviceID)") log("Starting outgoing transfer to \(deviceID)")
guard let info = foundServices[deviceID] else { return } guard foundServices[deviceID] != nil else {
let error = NearbyError.protocolError("TargetDeviceNoLongerNearby".localized())
delegate.connectionFailed(error: error)
return false
}
do { let preparationID = UUID()
let localUrls = try saveFilesToTemp(urls: urls) pendingOutgoingTransferPreparations[deviceID] = preparationID
let tcp = NWProtocolTCP.Options() DispatchQueue.global(qos: .userInitiated).async { [weak self, weak delegate] in
tcp.noDelay = true guard let self else { return }
let nwconn = NWConnection(to: info.service.endpoint, using: NWParameters(tls: .none, tcp: tcp))
let conn = OutboundNearbyConnection(connection: nwconn, id: deviceID, urlsToSend: localUrls, textToSend: textToSend) do {
conn.delegate = self let localUrls = try self.saveFilesToTemp(urls: urls)
conn.qrCodePrivateKey = qrCodePrivateKey
let transfer = OutgoingTransferInfo(service: info.service, device: info.device!, connection: conn, delegate: delegate) DispatchQueue.main.async {
outgoingTransfers[deviceID] = transfer guard let info = self.foundServices[deviceID],
conn.start() self.pendingOutgoingTransferPreparations[deviceID] == preparationID else {
} catch { return
log("[NearbyConnectionManager] Error storing URLs: \(error)") }
outboundAppDelegates.forEach { delegate in
delegate.connectionFailed(error: error) guard let delegate else {
self.pendingOutgoingTransferPreparations.removeValue(forKey: deviceID)
return
}
preparationFinished?()
self.resolveOutgoingEndpoint(for: info.service, deviceID: deviceID) { endpoint, requiredInterface in
guard let info = self.foundServices[deviceID],
self.pendingOutgoingTransferPreparations[deviceID] == preparationID else {
return
}
self.pendingOutgoingTransferPreparations.removeValue(forKey: deviceID)
let tcp = NWProtocolTCP.Options()
tcp.noDelay = true
let parameters = NWParameters(tls: .none, tcp: tcp)
parameters.requiredInterface = requiredInterface
let nwconn = NWConnection(to: endpoint, using: parameters)
let conn = OutboundNearbyConnection(
connection: nwconn,
id: deviceID,
urlsToSend: localUrls,
textToSend: textToSend,
isClipboardSyncTransfer: isClipboardSyncTransfer,
receiverAuthenticationPolicy: receiverAuthenticationPolicy
)
conn.remoteDeviceInfo = info.device
conn.delegate = self
conn.qrCodePrivateKey = self.qrCodePrivateKey
let transfer = OutgoingTransferInfo(service: info.service, device: info.device!, connection: conn, delegate: delegate)
self.outgoingTransfers[deviceID] = transfer
conn.start()
}
}
} catch {
log("[NearbyConnectionManager] Error storing URLs: \(error)")
DispatchQueue.main.async {
guard self.pendingOutgoingTransferPreparations[deviceID] == preparationID else {
return
}
self.pendingOutgoingTransferPreparations.removeValue(forKey: deviceID)
delegate?.connectionFailed(error: error)
}
} }
} }
return true
}
private func resolveOutgoingEndpoint(
for service: NWBrowser.Result,
deviceID: String,
completion: @escaping (NWEndpoint, NWInterface?) -> Void
) {
guard let resolver = NearbyBonjourServiceResolver(result: service, resolveTimeout: 2.5, completion: { [weak self] endpoints in
guard let self else { return }
self.outgoingServiceResolvers.removeValue(forKey: deviceID)
if let endpoint = endpoints.first {
completion(endpoint.endpoint, endpoint.interface)
} else {
log("[NearbyConnectionManager] Could not resolve outgoing service for \(deviceID); falling back to Bonjour endpoint \(service.endpoint)")
completion(service.endpoint, nil)
}
}) else {
log("[NearbyConnectionManager] Cannot create resolver for outgoing service; falling back to Bonjour endpoint")
completion(service.endpoint, nil)
return
}
outgoingServiceResolvers.removeValue(forKey: deviceID)?.cancel()
outgoingServiceResolvers[deviceID] = resolver
resolver.start()
}
private func normalizedKeyFingerprint(_ keyFingerprint: String?) -> String? {
let normalized = keyFingerprint?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return normalized?.isEmpty == false ? normalized : nil
}
private func serviceIdentity(_ service: NWBrowser.Result) -> String {
String(describing: service.endpoint)
} }
@@ -570,6 +941,78 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
func receiverAuthenticationPending(
connection _: OutboundNearbyConnection,
certificateData: Data,
device: RemoteDeviceInfo,
fingerprint: String
) {
guard let normalizedFingerprint = normalizedKeyFingerprint(fingerprint) else { return }
log("[NearbyConnectionManager] Receiver auth pending: fingerprint=\(normalizedFingerprint) device=\(device.name ?? "unknown") (\(device.id ?? "no-id"))")
pendingNotificationSyncTrustQueue.sync {
pendingNotificationSyncTrust[normalizedFingerprint] = PendingReceiverTrust(
certificateData: certificateData,
device: device
)
}
}
public func commitNotificationSyncReceiverTrust(_ fingerprint: String) -> Bool {
guard let normalizedFingerprint = normalizedKeyFingerprint(fingerprint) else {
return false
}
let pending = pendingNotificationSyncTrustQueue.sync {
pendingNotificationSyncTrust.removeValue(forKey: normalizedFingerprint)
}
if pending == nil {
let alreadyTrusted = TrustStore.shared.trustedCertificates.keys.contains(normalizedFingerprint)
if alreadyTrusted {
log("[NearbyConnectionManager] Commit trust skipped: already trusted for \(normalizedFingerprint)")
return true
}
log("[NearbyConnectionManager] Commit trust failed: no pending entry for \(normalizedFingerprint)")
return false
}
guard let pending,
let certificate = try? Sharing_Nearby_PublicCertificate(serializedBytes: pending.certificateData) else {
log("[NearbyConnectionManager] Commit trust failed: could not parse certificate for \(normalizedFingerprint)")
return false
}
let derivedFingerprint = certificate.secretID.hex.lowercased()
guard derivedFingerprint == normalizedFingerprint else {
log("[NearbyConnectionManager] Commit trust failed: fingerprint mismatch pending=\(normalizedFingerprint) derived=\(derivedFingerprint)")
return false
}
log("[NearbyConnectionManager] Commit trust succeeded for fingerprint=\(normalizedFingerprint)")
TrustStore.shared.addTrusted(certificate: certificate, device: pending.device)
return true
}
public func clearPendingNotificationSyncReceiverTrust(_ fingerprint: String? = nil) {
if fingerprint == nil {
pendingNotificationSyncTrustQueue.sync {
pendingNotificationSyncTrust.removeAll()
}
log("[NearbyConnectionManager] Cleared all pending receiver trust entries")
return
}
guard let normalizedFingerprint = normalizedKeyFingerprint(fingerprint) else { return }
_ = pendingNotificationSyncTrustQueue.sync {
pendingNotificationSyncTrust.removeValue(forKey: normalizedFingerprint)
}
log("[NearbyConnectionManager] Cleared pending receiver trust for \(normalizedFingerprint)")
}
public func getActiveConnectionsCount() -> Int { public func getActiveConnectionsCount() -> Int {
return incomingConnections.count + outgoingTransfers.count return incomingConnections.count + outgoingTransfers.count
} }
@@ -611,7 +1054,6 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
if accessSuccess { if accessSuccess {
url.stopAccessingSecurityScopedResource() url.stopAccessingSecurityScopedResource()
} }
} }
else { else {
log("[NearbyConnectionManager] File URL \(url) does not point to a security scoped resource") log("[NearbyConnectionManager] File URL \(url) does not point to a security scoped resource")
@@ -634,7 +1076,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
return securityScopeUrl return securityScopeUrl
} }
if let bookmarkData = Settings.shared.saveFolderBookmark { if let bookmarkData = Settings.sharedInstance.saveFolderBookmark {
var isStale = false var isStale = false
do { do {
@@ -684,6 +1126,14 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
// MARK: Callbacks
func informAboutStatus() {
DispatchQueue.main.async {
self.connectionUpdateCallback(self.isConnectedToLocalNetwork && self.startedAdvertising)
}
}
// -- MARK: - Internal Data Model // -- MARK: - Internal Data Model
@@ -699,4 +1149,10 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
let connection: OutboundNearbyConnection let connection: OutboundNearbyConnection
let delegate: OutboundAppDelegate let delegate: OutboundAppDelegate
} }
private struct PendingReceiverTrust {
let certificateData: Data
let device: RemoteDeviceInfo
}
} }
@@ -0,0 +1,134 @@
//
// NearbyDeviceNameResolver.swift
// QuickDrop
//
// Created by Leon Böttger on 25.04.2026.
//
import Darwin
import Foundation
import Network
final class NearbyDeviceNameResolver {
private static let hostnameLookupQueue = DispatchQueue(
label: "com.leonboettger.quickdrop.deviceNameHostnameLookup",
qos: .utility
)
private var hostnameResolvers: [UUID: NearbyBonjourServiceResolver] = [:]
private var lookupTokens: [String: UUID] = [:]
func needsLookup(for endpointInfo: EndpointInfo?) -> Bool {
let cleanedName = endpointInfo?.name?.trimmingCharacters(in: .whitespacesAndNewlines)
return cleanedName?.isEmpty != false
}
func resolveName(for service: NWBrowser.Result, endpointID: String, completion: @escaping (String) -> Void) {
let lookupToken = UUID()
lookupTokens[endpointID] = lookupToken
resolveAdvertisedHostname(for: service) { [weak self] hostname in
guard let self else { return }
guard self.lookupTokens[endpointID] == lookupToken else { return }
self.lookupTokens.removeValue(forKey: endpointID)
guard let hostname else { return }
completion(hostname)
}
}
func cancelLookup(for endpointID: String) {
lookupTokens.removeValue(forKey: endpointID)
}
func cancelAll() {
lookupTokens.removeAll()
hostnameResolvers.values.forEach { $0.cancel() }
hostnameResolvers.removeAll()
}
private func resolveAdvertisedHostname(for service: NWBrowser.Result, completion: @escaping (String?) -> Void) {
let resolverID = UUID()
guard let resolver = NearbyBonjourServiceResolver(
result: service,
resolveTimeout: 0.5,
watchdogGracePeriod: 1.0,
completion: { [weak self] endpoints in
self?.hostnameResolvers.removeValue(forKey: resolverID)
guard let ipv4Address = endpoints.first(where: \.isIPv4)?.host else {
completion(nil)
return
}
Self.hostnameLookupQueue.async {
let hostname = NearbyHostLookup.hostname(forIPv4: ipv4Address)
DispatchQueue.main.async {
completion(hostname)
}
}
}
) else {
completion(nil)
return
}
hostnameResolvers[resolverID] = resolver
resolver.start()
}
}
private enum NearbyHostLookup {
static func hostname(forIPv4 address: String) -> String? {
reverseDNSName(forIPv4: address).flatMap(prettifyDisplayName)
}
private static func reverseDNSName(forIPv4 address: String) -> String? {
var socketAddress = sockaddr_in()
socketAddress.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
socketAddress.sin_family = sa_family_t(AF_INET)
guard inet_pton(AF_INET, address, &socketAddress.sin_addr) == 1 else {
return nil
}
var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = withUnsafePointer(to: &socketAddress) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in
getnameinfo(
sockaddrPointer,
socklen_t(MemoryLayout<sockaddr_in>.size),
&hostBuffer,
socklen_t(hostBuffer.count),
nil,
0,
NI_NAMEREQD
)
}
}
guard result == 0 else { return nil }
return String(cString: hostBuffer)
}
private static func prettifyDisplayName(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}
let withoutZone = value.split(separator: "%").first.map(String.init) ?? value
let withoutDomain = withoutZone.split(separator: ".").first.map(String.init) ?? withoutZone
let prettified = withoutDomain
.replacingOccurrences(of: "-", with: " ")
.replacingOccurrences(of: "_", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !prettified.isEmpty else { return nil }
return prettified.capitalized
}
}
@@ -14,18 +14,22 @@ import UniformTypeIdentifiers
import BigInt import BigInt
import SwiftECC import SwiftECC
import ASN1 import ASN1
import BigInt // required dependency of ASN1 import BigInt
import LUI
class OutboundNearbyConnection: NearbyConnection { class OutboundNearbyConnection: NearbyConnection {
private var currentState: State = .initial private var currentState: State = .initial
private let urlsToSend: [URL] private let urlsToSend: [URL]
private let textToSend: String? private let textToSend: String?
private let isClipboardSyncTransfer: Bool
private let receiverAuthenticationPolicy: ReceiverAuthenticationPolicy
private var ukeyClientFinishMsgData: Data? private var ukeyClientFinishMsgData: Data?
private var queue: [OutgoingFileTransfer] = [] private var queue: [OutgoingFileTransfer] = []
private var currentTransfer: OutgoingFileTransfer? private var currentTransfer: OutgoingFileTransfer?
private var totalBytesToSend: Int64 = 0 private var totalBytesToSend: Int64 = 0
private var textPayloadID: Int64 = 0 private var textPayloadID: Int64 = 0
private var receiverAuthenticationVerified = false
public var qrCodePrivateKey: ECPrivateKey? public var qrCodePrivateKey: ECPrivateKey?
public var delegate: OutboundNearbyConnectionDelegate? public var delegate: OutboundNearbyConnectionDelegate?
@@ -34,10 +38,19 @@ class OutboundNearbyConnection: NearbyConnection {
case initial, sentUkeyClientInit, sentUkeyClientFinish, sentPairedKeyEncryption, sentPairedKeyResult, sentIntroduction, sendingFiles case initial, sentUkeyClientInit, sentUkeyClientFinish, sentPairedKeyEncryption, sentPairedKeyResult, sentIntroduction, sendingFiles
} }
init(connection: NWConnection, id: String, urlsToSend: [URL], textToSend: String?) { init(
connection: NWConnection,
id: String,
urlsToSend: [URL],
textToSend: String?,
isClipboardSyncTransfer: Bool = false,
receiverAuthenticationPolicy: ReceiverAuthenticationPolicy
) {
self.urlsToSend = urlsToSend self.urlsToSend = urlsToSend
self.textToSend = textToSend self.textToSend = textToSend
self.isClipboardSyncTransfer = isClipboardSyncTransfer
self.receiverAuthenticationPolicy = receiverAuthenticationPolicy
super.init(connection: connection, id: id) super.init(connection: connection, id: id)
@@ -62,6 +75,18 @@ class OutboundNearbyConnection: NearbyConnection {
override func disconnect() { override func disconnect() {
super.disconnect() super.disconnect()
// delete all files
for url in urlsToSend {
do {
try FileManager.default.removeItem(at: url)
log("[OutboundNearbyConnection \(self.id)] Deleted file at \(url).")
}
catch {
log("[OutboundNearbyConnection \(self.id)] Failed to delete file at \(url): \(error)")
}
}
if let error = lastError { if let error = lastError {
DispatchQueue.main.async { DispatchQueue.main.async {
self.delegate?.failedWithError(connection: self, error: error) self.delegate?.failedWithError(connection: self, error: error)
@@ -134,15 +159,50 @@ class OutboundNearbyConnection: NearbyConnection {
switch currentState { switch currentState {
case .sentPairedKeyEncryption: case .sentPairedKeyEncryption:
try processPairedKeyEncryption(frame: frame) if frame.hasV1, frame.v1.hasPairedKeyEncryption {
try processPairedKeyEncryption(frame: frame)
return
}
if frame.hasV1, frame.v1.hasPairedKeyResult {
log("[OutboundNearbyConnection \(self.id)] Received pairedKeyResult before pairedKeyEncryption; continuing for legacy compatibility.")
try processPairedKeyResult(frame: frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .progressUpdate || frame.v1.type == .response {
log("[OutboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for pairedKeyEncryption.")
return
}
}
throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.pairedKeyEncryption")
case .sentPairedKeyResult: case .sentPairedKeyResult:
try processPairedKeyResult(frame: frame) if frame.hasV1, frame.v1.hasPairedKeyResult {
try processPairedKeyResult(frame: frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .pairedKeyEncryption || frame.v1.type == .progressUpdate || frame.v1.type == .response {
log("[OutboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for pairedKeyResult.")
return
}
}
throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.pairedKeyResult")
case .sentIntroduction: case .sentIntroduction:
try processConsent(frame: frame) if frame.version == .v1, frame.v1.type == .response {
try processConsent(frame: frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .pairedKeyEncryption || frame.v1.type == .pairedKeyResult || frame.v1.type == .progressUpdate {
log("[OutboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for consent.")
return
}
}
throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.type==response")
case .sendingFiles: case .sendingFiles:
break break
default: default:
assertionFailure("[OutboundNearbyConnection \(self.id)] Unexpected state \(currentState)") log("[OutboundNearbyConnection \(self.id)] Unexpected state \(currentState)")
} }
} }
@@ -162,8 +222,8 @@ class OutboundNearbyConnection: NearbyConnection {
frame.v1 = Location_Nearby_Connections_V1Frame() frame.v1 = Location_Nearby_Connections_V1Frame()
frame.v1.type = .connectionRequest frame.v1.type = .connectionRequest
frame.v1.connectionRequest = Location_Nearby_Connections_ConnectionRequestFrame() frame.v1.connectionRequest = Location_Nearby_Connections_ConnectionRequestFrame()
frame.v1.connectionRequest.endpointID = String(bytes: NearbyConnectionManager.shared.endpointID, encoding: .ascii)! frame.v1.connectionRequest.endpointID = Data(NearbyConnectionManager.shared.endpointID)
frame.v1.connectionRequest.endpointName = endpointInfo.name ?? "QuickDrop" frame.v1.connectionRequest.endpointName = Data((endpointInfo.name ?? "QuickDrop").utf8)
frame.v1.connectionRequest.endpointInfo = endpointInfo.serialize() frame.v1.connectionRequest.endpointInfo = endpointInfo.serialize()
frame.v1.connectionRequest.mediums = [.wifiLan] frame.v1.connectionRequest.mediums = [.wifiLan]
try sendFrameAsync(frame.serializedData()) try sendFrameAsync(frame.serializedData())
@@ -256,6 +316,12 @@ class OutboundNearbyConnection: NearbyConnection {
private func processConnectionResponse(frame: Location_Nearby_Connections_OfflineFrame) throws { private func processConnectionResponse(frame: Location_Nearby_Connections_OfflineFrame) throws {
guard frame.version == .v1 else { throw NearbyError.protocolError("Unexpected offline frame version \(frame.version)") } guard frame.version == .v1 else { throw NearbyError.protocolError("Unexpected offline frame version \(frame.version)") }
guard frame.hasV1, frame.v1.hasType else { throw NearbyError.requiredFieldMissing("offlineFrame.v1.type") }
if frame.v1.type == .keepAlive {
log("[OutboundNearbyConnection \(self.id)] Ignoring keep-alive while waiting for connection response")
return
}
guard frame.v1.type == .connectionResponse else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") } guard frame.v1.type == .connectionResponse else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") }
@@ -314,7 +380,12 @@ class OutboundNearbyConnection: NearbyConnection {
pairedResult.v1 = Sharing_Nearby_V1Frame() pairedResult.v1 = Sharing_Nearby_V1Frame()
pairedResult.v1.type = .pairedKeyResult pairedResult.v1.type = .pairedKeyResult
pairedResult.v1.pairedKeyResult = Sharing_Nearby_PairedKeyResultFrame() pairedResult.v1.pairedKeyResult = Sharing_Nearby_PairedKeyResultFrame()
pairedResult.v1.pairedKeyResult.status = .unable if receiverAuthenticationPolicy.requiresReceiverAuthentication {
try verifyReceiverAuthentication(frame: frame)
pairedResult.v1.pairedKeyResult.status = .success
} else {
pairedResult.v1.pairedKeyResult.status = .unable
}
try sendTransferSetupFrame(pairedResult) try sendTransferSetupFrame(pairedResult)
currentState = .sentPairedKeyResult currentState = .sentPairedKeyResult
@@ -323,10 +394,16 @@ class OutboundNearbyConnection: NearbyConnection {
private func processPairedKeyResult(frame: Sharing_Nearby_Frame) throws { private func processPairedKeyResult(frame: Sharing_Nearby_Frame) throws {
guard frame.hasV1, frame.v1.hasPairedKeyResult else { throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.pairedKeyResult") } guard frame.hasV1, frame.v1.hasPairedKeyResult else { throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.pairedKeyResult") }
if receiverAuthenticationPolicy.requiresReceiverAuthentication, !receiverAuthenticationVerified {
throw NearbyError.protocolError("Receiver authentication incomplete")
}
var introduction = Sharing_Nearby_Frame() var introduction = Sharing_Nearby_Frame()
introduction.version = .v1 introduction.version = .v1
introduction.v1.type = .introduction introduction.v1.type = .introduction
if isClipboardSyncTransfer {
introduction.v1.introduction.useCase = .remoteCopy
}
if let textToSend = textToSend { if let textToSend = textToSend {
var meta = Sharing_Nearby_TextMetadata() var meta = Sharing_Nearby_TextMetadata()
@@ -334,6 +411,7 @@ class OutboundNearbyConnection: NearbyConnection {
meta.textTitle = textToSend meta.textTitle = textToSend
meta.size = Int64(textToSend.utf8.count) meta.size = Int64(textToSend.utf8.count)
meta.payloadID = textPayloadID meta.payloadID = textPayloadID
meta.id = Int64.random(in: Int64.min ... Int64.max)
introduction.v1.introduction.textMetadata.append(meta) introduction.v1.introduction.textMetadata.append(meta)
} else if hasURL() { } else if hasURL() {
@@ -342,6 +420,7 @@ class OutboundNearbyConnection: NearbyConnection {
meta.textTitle = urlsToSend[0].host ?? "URL" meta.textTitle = urlsToSend[0].host ?? "URL"
meta.size = Int64(urlsToSend[0].absoluteString.utf8.count) meta.size = Int64(urlsToSend[0].absoluteString.utf8.count)
meta.payloadID = textPayloadID meta.payloadID = textPayloadID
meta.id = Int64.random(in: Int64.min ... Int64.max)
introduction.v1.introduction.textMetadata.append(meta) introduction.v1.introduction.textMetadata.append(meta)
} else { } else {
for url in urlsToSend { for url in urlsToSend {
@@ -369,12 +448,15 @@ class OutboundNearbyConnection: NearbyConnection {
} else { } else {
meta.type = .unknown meta.type = .unknown
} }
meta.payloadID = Int64.random(in: Int64.min ... Int64.max) meta.payloadID = Int64.random(in: Int64.min ... Int64.max)
try queue.append(OutgoingFileTransfer(url: url, payloadID: meta.payloadID, handle: FileHandle(forReadingFrom: url), totalBytes: meta.size, currentOffset: 0)) meta.id = Int64.random(in: Int64.min ... Int64.max)
try queue.append(OutgoingFileTransfer(url: url, fileName: meta.name, payloadID: meta.payloadID, handle: FileHandle(forReadingFrom: url), totalBytes: meta.size, currentOffset: 0))
introduction.v1.introduction.fileMetadata.append(meta) introduction.v1.introduction.fileMetadata.append(meta)
totalBytesToSend += meta.size totalBytesToSend += meta.size
log("[OutboundNearbyConnection \(self.id)] Sending file with \(meta.size) bytes") log("[OutboundNearbyConnection \(self.id)] Sending file with \(meta.size) bytes and mime type \(meta.mimeType) and type \(meta.type) and name \(meta.name)")
} }
} }
try sendTransferSetupFrame(introduction) try sendTransferSetupFrame(introduction)
@@ -414,6 +496,84 @@ class OutboundNearbyConnection: NearbyConnection {
} }
private func verifyReceiverAuthentication(frame: Sharing_Nearby_Frame) throws {
guard let expectedFingerprint = receiverAuthenticationPolicy.expectedReceiverKeyFingerprint, !expectedFingerprint.isEmpty else {
throw NearbyError.protocolError("Missing expected receiver fingerprint")
}
guard let certificate = frame.v1.certificateInfo.publicCertificate.first else {
throw NearbyError.protocolError("Missing receiver certificate")
}
let certificateData = try certificate.serializedData()
let secretID = certificate.secretID
guard !secretID.isEmpty else {
throw NearbyError.protocolError("Missing receiver key ID")
}
let peerGenericKey = try Securemessage_GenericPublicKey(serializedBytes: certificate.publicKey)
guard let derivedKeyID = peerGenericKey.id() else {
throw NearbyError.protocolError("Receiver key ID derivation failed")
}
guard secretID == derivedKeyID else {
throw NearbyError.protocolError("Receiver key ID mismatch")
}
let actualFingerprint = derivedKeyID.hex.lowercased()
guard actualFingerprint == expectedFingerprint else {
throw NearbyError.protocolError("Receiver fingerprint mismatch")
}
let pinnedCertificateData = TrustStore.shared.findTrustedKey(for: secretID)
if pinnedCertificateData == nil, !receiverAuthenticationPolicy.allowsReceiverTrustBootstrap {
throw NearbyError.protocolError("Receiver certificate is not trusted")
}
if let pinnedCertificateData, pinnedCertificateData != certificateData {
throw NearbyError.protocolError("Receiver certificate changed")
}
let pairedKeyEncryption = frame.v1.pairedKeyEncryption
guard pairedKeyEncryption.secretIDHash == secretID else {
throw NearbyError.protocolError("Receiver sent unexpected key ID")
}
guard let authKeyData = authKey?.data() else {
throw NearbyError.protocolError("Missing auth key for receiver verification")
}
let domain = Domain.instance(curve: .EC256r1)
let ecKey = peerGenericKey.ecP256PublicKey
let point = Point(
BInt(magnitude: [UInt8](ecKey.x.ensureLength(length: 32))),
BInt(magnitude: [UInt8](ecKey.y.ensureLength(length: 32)))
)
guard let peerPublicKey = try? ECPublicKey(domain: domain, w: point) else {
throw NearbyError.protocolError("Invalid receiver public key")
}
let signature = try ECSignature(asn1: .build(pairedKeyEncryption.signedData), domain: domain)
guard peerPublicKey.verify(signature: signature, msg: authKeyData) else {
throw NearbyError.protocolError("Receiver signature verification failed")
}
if pinnedCertificateData == nil, receiverAuthenticationPolicy.allowsReceiverTrustBootstrap {
guard let device = remoteDeviceInfo else {
throw NearbyError.protocolError("Missing remote device info for receiver trust")
}
delegate?.receiverAuthenticationPending(
connection: self,
certificateData: certificateData,
device: device,
fingerprint: actualFingerprint
)
}
receiverAuthenticationVerified = true
}
private func hasURL() -> Bool { private func hasURL() -> Bool {
urlsToSend.count == 1 && !urlsToSend[0].isFileURL urlsToSend.count == 1 && !urlsToSend[0].isFileURL
} }
@@ -441,8 +601,18 @@ class OutboundNearbyConnection: NearbyConnection {
} }
if queue.isEmpty { if queue.isEmpty {
log("[OutboundNearbyConnection \(self.id)] Disconnecting because all files have been transferred") log("[OutboundNearbyConnection \(self.id)] Disconnecting because all files have been transferred")
try sendDisconnectionAndDisconnect()
delegate?.transferFinished(connection: self) // Delay disconnection to ensure EOF frame is processed at peer
NearbyConnection.dispatchQueue.asyncAfter(deadline: .now() + 0.5) {
do {
try self.sendDisconnectionAndDisconnect()
self.delegate?.transferFinished(connection: self)
}
catch {
log("[OutboundNearbyConnection \(self.id)] Error while sending disconnection: \(error)")
}
}
return return
} }
currentTransfer = queue.removeFirst() currentTransfer = queue.removeFirst()
@@ -463,6 +633,7 @@ class OutboundNearbyConnection: NearbyConnection {
transfer.payloadHeader.type = .file transfer.payloadHeader.type = .file
transfer.payloadHeader.totalSize = Int64(currentTransfer!.totalBytes) transfer.payloadHeader.totalSize = Int64(currentTransfer!.totalBytes)
transfer.payloadHeader.isSensitive = false transfer.payloadHeader.isSensitive = false
transfer.payloadHeader.fileName = currentTransfer!.fileName
currentTransfer!.currentOffset += Int64(fileBuffer.count) currentTransfer!.currentOffset += Int64(fileBuffer.count)
var wrapper = Location_Nearby_Connections_OfflineFrame() var wrapper = Location_Nearby_Connections_OfflineFrame()
@@ -485,11 +656,12 @@ class OutboundNearbyConnection: NearbyConnection {
delegate?.updatedTransferProgress(connection: self, progress: Double(bytesTransferred) / Double(totalBytesToSend)) delegate?.updatedTransferProgress(connection: self, progress: Double(bytesTransferred) / Double(totalBytesToSend))
if currentTransfer!.currentOffset == currentTransfer!.totalBytes { if currentTransfer!.currentOffset == currentTransfer!.totalBytes {
// Signal end of file (yes, all this for one bit)
// Signal end of file
var transfer = Location_Nearby_Connections_PayloadTransferFrame() var transfer = Location_Nearby_Connections_PayloadTransferFrame()
transfer.packetType = .data transfer.packetType = .data
transfer.payloadChunk.offset = currentTransfer!.currentOffset transfer.payloadChunk.offset = currentTransfer!.currentOffset
transfer.payloadChunk.flags = 1 // <- this one here transfer.payloadChunk.flags = 1
transfer.payloadHeader.id = currentTransfer!.payloadID transfer.payloadHeader.id = currentTransfer!.payloadID
transfer.payloadHeader.type = .file transfer.payloadHeader.type = .file
transfer.payloadHeader.totalSize = Int64(currentTransfer!.totalBytes) transfer.payloadHeader.totalSize = Int64(currentTransfer!.totalBytes)
@@ -515,9 +687,39 @@ class OutboundNearbyConnection: NearbyConnection {
private struct OutgoingFileTransfer { private struct OutgoingFileTransfer {
let url: URL let url: URL
let fileName: String
let payloadID: Int64 let payloadID: Int64
let handle: FileHandle? let handle: FileHandle?
let totalBytes: Int64 let totalBytes: Int64
var currentOffset: Int64 var currentOffset: Int64
} }
} }
private extension ReceiverAuthenticationPolicy {
var expectedReceiverKeyFingerprint: String? {
switch self {
case .none:
return nil
case .trustedReceiver(let fingerprint), .bootstrapReceiverTrust(let fingerprint):
return Self.normalizedFingerprint(fingerprint)
}
}
var allowsReceiverTrustBootstrap: Bool {
if case .bootstrapReceiverTrust = self {
return true
}
return false
}
var requiresReceiverAuthentication: Bool {
if case .none = self {
return false
}
return true
}
static func normalizedFingerprint(_ fingerprint: String) -> String {
fingerprint.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}
+211 -20
View File
@@ -6,17 +6,20 @@
// //
import Foundation import Foundation
import UniformTypeIdentifiers
import Network import Network
import CryptoKit import CryptoKit
import SwiftECC import SwiftECC
import LUI
protocol InboundNearbyConnectionDelegate { protocol InboundNearbyConnectionDelegate {
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection) func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection)
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection) func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection)
func updatedTransferProgress(connection: InboundNearbyConnection, progress: Double) func updatedTransferProgress(connection: InboundNearbyConnection, progress: Double)
func connectionWasTerminated(connection: InboundNearbyConnection, error: Error?) func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?)
func showPlusScreen() func showPlusScreen()
func pairingSetupConfirmed(connection: InboundNearbyConnection)
} }
@@ -26,15 +29,33 @@ protocol OutboundNearbyConnectionDelegate {
func transferAccepted(connection: OutboundNearbyConnection) func transferAccepted(connection: OutboundNearbyConnection)
func failedWithError(connection: OutboundNearbyConnection, error: Error) func failedWithError(connection: OutboundNearbyConnection, error: Error)
func transferFinished(connection: OutboundNearbyConnection) func transferFinished(connection: OutboundNearbyConnection)
func receiverAuthenticationPending(
connection: OutboundNearbyConnection,
certificateData: Data,
device: RemoteDeviceInfo,
fingerprint: String
)
}
public enum ReceiverAuthenticationPolicy: Equatable {
case none
case trustedReceiver(fingerprint: String)
case bootstrapReceiverTrust(fingerprint: String)
} }
public protocol InboundAppDelegate: AnyObject { public protocol InboundAppDelegate: AnyObject {
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo) func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo)
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo) func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo)
func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo, wasPlainTextTransfer: Bool, error: Error?) func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, wasClipboardSync: Bool, error: Error?)
func transferProgress(connectionID: String, progress: Double) func transferProgress(connectionID: String, progress: Double)
func showPlusScreen() func showPlusScreen()
func pairingSetupConfirmed()
}
extension InboundAppDelegate {
func pairingSetupConfirmed() {}
} }
@@ -54,20 +75,87 @@ public struct RemoteDeviceInfo: Codable, Identifiable, Equatable {
public let name: String? public let name: String?
public let type: DeviceType public let type: DeviceType
public let qrCodeData: Data? public let qrCodeData: Data?
public let isQuickDropPeer: Bool
public var id: String? public var id: String?
public let keyFingerprint: String?
public let supportsNotificationSync: Bool
public let supportsClipboardReceive: Bool
init(name: String, type: DeviceType, id: String? = nil) { init(
name: String?,
type: DeviceType?,
id: String? = nil,
isQuickDropPeer: Bool = false,
keyFingerprint: String? = nil,
supportsNotificationSync: Bool = false,
supportsClipboardReceive: Bool = false
) {
self.name = name self.name = name
self.type = type self.type = type ?? .phone
self.id = id self.id = id
self.qrCodeData = nil self.qrCodeData = nil
self.isQuickDropPeer = isQuickDropPeer
self.keyFingerprint = Self.normalizedKeyFingerprint(keyFingerprint)
self.supportsNotificationSync = supportsNotificationSync
self.supportsClipboardReceive = supportsClipboardReceive
} }
init(info: EndpointInfo, id: String? = nil) { init(
info: EndpointInfo,
id: String? = nil,
isQuickDropPeer: Bool = false,
keyFingerprint: String? = nil,
supportsNotificationSync: Bool = false,
supportsClipboardReceive: Bool = false
) {
self.name = info.name self.name = info.name
self.type = info.deviceType self.type = info.deviceType
self.qrCodeData = info.qrCodeData self.qrCodeData = info.qrCodeData
self.id = id self.id = id
self.isQuickDropPeer = isQuickDropPeer
self.keyFingerprint = Self.normalizedKeyFingerprint(keyFingerprint)
self.supportsNotificationSync = supportsNotificationSync
self.supportsClipboardReceive = supportsClipboardReceive
}
private enum CodingKeys: String, CodingKey {
case name
case type
case qrCodeData
case isQuickDropPeer
case isVerifiedQuickDropPeer
case id
case keyFingerprint
case supportsNotificationSync
case supportsClipboardReceive
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
type = try container.decode(DeviceType.self, forKey: .type)
qrCodeData = try container.decodeIfPresent(Data.self, forKey: .qrCodeData)
isQuickDropPeer = try container.decodeIfPresent(Bool.self, forKey: .isQuickDropPeer)
?? container.decodeIfPresent(Bool.self, forKey: .isVerifiedQuickDropPeer)
?? false
id = try container.decodeIfPresent(String.self, forKey: .id)
keyFingerprint = Self.normalizedKeyFingerprint(
try container.decodeIfPresent(String.self, forKey: .keyFingerprint)
)
supportsNotificationSync = try container.decodeIfPresent(Bool.self, forKey: .supportsNotificationSync) ?? false
supportsClipboardReceive = try container.decodeIfPresent(Bool.self, forKey: .supportsClipboardReceive) ?? false
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(name, forKey: .name)
try container.encode(type, forKey: .type)
try container.encodeIfPresent(qrCodeData, forKey: .qrCodeData)
try container.encode(isQuickDropPeer, forKey: .isQuickDropPeer)
try container.encodeIfPresent(id, forKey: .id)
try container.encodeIfPresent(keyFingerprint, forKey: .keyFingerprint)
try container.encode(supportsNotificationSync, forKey: .supportsNotificationSync)
try container.encode(supportsClipboardReceive, forKey: .supportsClipboardReceive)
} }
@@ -112,6 +200,13 @@ public struct RemoteDeviceInfo: Codable, Identifiable, Equatable {
} }
return "iphone" return "iphone"
} }
private static func normalizedKeyFingerprint(_ keyFingerprint: String?) -> String? {
let normalized = keyFingerprint?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return normalized?.isEmpty == false ? normalized : nil
}
} }
@@ -122,45 +217,94 @@ public struct TransferMetadata {
public let textDescription: String? public let textDescription: String?
public let type: TransferType public let type: TransferType
public let allowsToBeAddedAsTrustedDevice: Bool public let allowsToBeAddedAsTrustedDevice: Bool
public let isClipboardSync: Bool
init(files: [FileMetadata], id: String, pinCode: String?, textDescription: String? = nil, transferType: TransferType = .file, allowsToBeAddedAsTrustedDevice: Bool) { init(files: [FileMetadata], id: String, pinCode: String?, textDescription: String? = nil, transferType: TransferType = .file, allowsToBeAddedAsTrustedDevice: Bool, isClipboardSync: Bool = false) {
self.files = files self.files = files
self.id = id self.id = id
self.pinCode = pinCode self.pinCode = pinCode
self.textDescription = textDescription self.textDescription = textDescription
self.type = transferType self.type = transferType
self.allowsToBeAddedAsTrustedDevice = allowsToBeAddedAsTrustedDevice self.allowsToBeAddedAsTrustedDevice = allowsToBeAddedAsTrustedDevice
self.isClipboardSync = isClipboardSync
} }
public enum TransferType { public enum TransferType {
case file case file
case text case text
case url case url
case wifiPassword
case notificationSync
case clipboardSync
} }
private func getSummary() -> String { private func getSummary() -> String {
if let textTitle = textDescription { if let textTitle = textDescription {
return textTitle return textTitle
} else if files.count == 1 { } else if files.count == 1 {
return files[0].name return fileTypeSummary(for: files[0])
} else { } else {
return String.localizedStringWithFormat("NFiles".localized(), files.count) return String.localizedStringWithFormat("NFiles".localized(), files.count)
} }
} }
public func getDescription(deviceName: String, alreadyAccepted: Bool) -> String { private func fileTypeSummary(for file: FileMetadata) -> String {
let mimeType = file.mimeType.lowercased()
let ext = URL(fileURLWithPath: file.name).pathExtension.lowercased()
let type = UTType(mimeType: mimeType) ?? UTType(filenameExtension: ext)
if let type, type.conforms(to: .pdf) {
return "PDF".localized()
}
if let type, type.conforms(to: .image) {
return "Image".localized()
}
if let type, type.conforms(to: .video) || type.conforms(to: .movie) {
return "Video".localized()
}
if let type, type.conforms(to: .audio) {
return "Audio".localized()
}
if let type, type.conforms(to: .text) {
return "Text".localized()
}
if let type, type.conforms(to: .archive) {
return "Archive".localized()
}
if let type, let description = type.localizedDescription {
return description
}
return "File".localized()
}
public func getDescription(deviceName: String) -> String {
let fileStr = getSummary() let fileStr = getSummary()
switch type { switch type {
case .file: case .file:
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingFiles" : "DeviceSendingFiles").localized(), arguments: [deviceName, fileStr]) return "DeviceSendingFiles".localized(with: fileStr, deviceName)
case .text: case .text:
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingText" : "DeviceSendingText").localized(), arguments: [deviceName, fileStr]) return "DeviceSendingText".localized(with: deviceName)
case .url: case .url:
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingUrl" : "DeviceSendingUrl").localized(), arguments: [deviceName, fileStr]) return "DeviceSendingUrl".localized(with: deviceName)
case .wifiPassword:
return "DeviceSendingWifiPassword".localized(with: deviceName)
case .notificationSync:
if let textDescription, !textDescription.isEmpty {
return textDescription
}
return "NotificationSyncConsentPromptFromDevice".localized(with: deviceName)
case .clipboardSync:
if let textDescription, !textDescription.isEmpty {
return textDescription
}
return "ClipboardSyncConsentPromptFromDevice".localized(with: deviceName)
} }
} }
@@ -169,6 +313,31 @@ public struct TransferMetadata {
} }
} }
enum PairingUseCase: String, Codable, Hashable {
case notificationSync = "notification_sync"
case clipboardSync = "clipboard_sync"
var protoValue: Sharing_Nearby_PairingMetadata.UseCase {
switch self {
case .notificationSync:
return .notificationSync
case .clipboardSync:
return .clipboardSync
}
}
init?(protoValue: Sharing_Nearby_PairingMetadata.UseCase) {
switch protoValue {
case .notificationSync:
self = .notificationSync
case .clipboardSync:
self = .clipboardSync
case .unknown:
return nil
}
}
}
public struct FileMetadata { public struct FileMetadata {
public let name: String public let name: String
@@ -182,16 +351,20 @@ public struct EndpointInfo {
let deviceType: RemoteDeviceInfo.DeviceType let deviceType: RemoteDeviceInfo.DeviceType
let qrCodeData: Data? let qrCodeData: Data?
static let maxNameChars = 32
init(name: String, deviceType: RemoteDeviceInfo.DeviceType) { init(name: String, deviceType: RemoteDeviceInfo.DeviceType) {
self.name = name self.name = String(decoding: Self.sanitizedString(name), as: UTF8.self)
self.deviceType = deviceType self.deviceType = deviceType
self.qrCodeData = nil self.qrCodeData = nil
} }
init?(data: Data) { init?(data: Data) {
guard data.count > 17 else { return nil } guard data.count > 17 else {
log("[EndpointInfo] Invalid endpoint info data received (too short)")
return nil
}
let hasName = (data[0] & 0x10) == 0 let hasName = (data[0] & 0x10) == 0
let deviceNameLength: Int let deviceNameLength: Int
@@ -199,8 +372,14 @@ public struct EndpointInfo {
if hasName { if hasName {
deviceNameLength = Int(data[17]) deviceNameLength = Int(data[17])
guard data.count >= deviceNameLength + 18 else { return nil } guard data.count >= deviceNameLength + 18 else {
guard let newDeviceName = String(data: data[18..<(18 + deviceNameLength)], encoding: .utf8) else { return nil } log("[EndpointInfo] Invalid endpoint info data received (name length mismatch)")
return nil
}
guard let newDeviceName = String(data: data[18..<(18 + deviceNameLength)], encoding: .utf8) else {
log("[EndpointInfo] Invalid endpoint info data received (name decoding failed)")
return nil
}
deviceName = newDeviceName deviceName = newDeviceName
} }
else { else {
@@ -244,19 +423,30 @@ public struct EndpointInfo {
var endpointInfo: [UInt8] = [UInt8(deviceType.rawValue << 1)] var endpointInfo: [UInt8] = [UInt8(deviceType.rawValue << 1)]
// 16 bytes: unknown random bytes // 16 bytes: unknown random bytes
for _ in 0 ... 15 { for _ in 0 ... 15 {
endpointInfo.append(UInt8.random(in: 0 ... 255)) endpointInfo.append(UInt8.random(in: 0...255))
} }
// Device name in UTF-8 prefixed with 1-byte length // Device name in UTF-8 prefixed with 1-byte length
var nameChars = [UInt8]((name ?? "").utf8) let nameChars = Self.sanitizedString(self.name ?? "")
if nameChars.count > 255 {
nameChars = [UInt8](nameChars[0 ..< 255])
}
endpointInfo.append(UInt8(nameChars.count)) endpointInfo.append(UInt8(nameChars.count))
for ch in nameChars { for ch in nameChars {
endpointInfo.append(UInt8(ch)) endpointInfo.append(UInt8(ch))
} }
return Data(endpointInfo) return Data(endpointInfo)
} }
private static func sanitizedString(_ string: String) -> [UInt8] {
var out: [UInt8] = []
out.reserveCapacity(min(Self.maxNameChars, string.utf8.count))
for scalar in string.unicodeScalars {
let bytes = Array(scalar.utf8)
if out.count + bytes.count > Self.maxNameChars { break }
out.append(contentsOf: bytes)
}
return out
}
} }
@@ -267,6 +457,7 @@ public enum NearbyError: Error {
case packetFilterError case packetFilterError
case firewallError case firewallError
case inputOutput case inputOutput
case notificationSyncNotTrusted
case canceled(reason: CancellationReason) case canceled(reason: CancellationReason)
public enum CancellationReason { public enum CancellationReason {
@@ -245,8 +245,8 @@ struct Location_Nearby_Connections_ConnectionRequestFrame: @unchecked Sendable {
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for // `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages. // methods supported on all messages.
var endpointID: String { var endpointID: Data {
get {return _storage._endpointID ?? String()} get {return _storage._endpointID ?? Data()}
set {_uniqueStorage()._endpointID = newValue} set {_uniqueStorage()._endpointID = newValue}
} }
/// Returns true if `endpointID` has been explicitly set. /// Returns true if `endpointID` has been explicitly set.
@@ -254,8 +254,8 @@ struct Location_Nearby_Connections_ConnectionRequestFrame: @unchecked Sendable {
/// Clears the value of `endpointID`. Subsequent reads from it will return its default value. /// Clears the value of `endpointID`. Subsequent reads from it will return its default value.
mutating func clearEndpointID() {_uniqueStorage()._endpointID = nil} mutating func clearEndpointID() {_uniqueStorage()._endpointID = nil}
var endpointName: String { var endpointName: Data {
get {return _storage._endpointName ?? String()} get {return _storage._endpointName ?? Data()}
set {_uniqueStorage()._endpointName = newValue} set {_uniqueStorage()._endpointName = newValue}
} }
/// Returns true if `endpointName` has been explicitly set. /// Returns true if `endpointName` has been explicitly set.
@@ -2550,8 +2550,8 @@ extension Location_Nearby_Connections_ConnectionRequestFrame: SwiftProtobuf.Mess
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}endpoint_id\0\u{3}endpoint_name\0\u{3}handshake_data\0\u{1}nonce\0\u{1}mediums\0\u{3}endpoint_info\0\u{3}medium_metadata\0\u{3}keep_alive_interval_millis\0\u{3}keep_alive_timeout_millis\0\u{3}device_type\0\u{3}device_info\0\u{3}connections_device\0\u{3}presence_device\0\u{3}connection_mode\0\u{3}location_hint\0") static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}endpoint_id\0\u{3}endpoint_name\0\u{3}handshake_data\0\u{1}nonce\0\u{1}mediums\0\u{3}endpoint_info\0\u{3}medium_metadata\0\u{3}keep_alive_interval_millis\0\u{3}keep_alive_timeout_millis\0\u{3}device_type\0\u{3}device_info\0\u{3}connections_device\0\u{3}presence_device\0\u{3}connection_mode\0\u{3}location_hint\0")
fileprivate class _StorageClass { fileprivate class _StorageClass {
var _endpointID: String? = nil var _endpointID: Data? = nil
var _endpointName: String? = nil var _endpointName: Data? = nil
var _handshakeData: Data? = nil var _handshakeData: Data? = nil
var _nonce: Int32? = nil var _nonce: Int32? = nil
var _mediums: [Location_Nearby_Connections_ConnectionRequestFrame.Medium] = [] var _mediums: [Location_Nearby_Connections_ConnectionRequestFrame.Medium] = []
@@ -2606,8 +2606,8 @@ extension Location_Nearby_Connections_ConnectionRequestFrame: SwiftProtobuf.Mess
// allocates stack space for every case branch when no optimizations are // allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034 // enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber { switch fieldNumber {
case 1: try { try decoder.decodeSingularStringField(value: &_storage._endpointID) }() case 1: try { try decoder.decodeSingularBytesField(value: &_storage._endpointID) }()
case 2: try { try decoder.decodeSingularStringField(value: &_storage._endpointName) }() case 2: try { try decoder.decodeSingularBytesField(value: &_storage._endpointName) }()
case 3: try { try decoder.decodeSingularBytesField(value: &_storage._handshakeData) }() case 3: try { try decoder.decodeSingularBytesField(value: &_storage._handshakeData) }()
case 4: try { try decoder.decodeSingularInt32Field(value: &_storage._nonce) }() case 4: try { try decoder.decodeSingularInt32Field(value: &_storage._nonce) }()
case 5: try { try decoder.decodeRepeatedEnumField(value: &_storage._mediums) }() case 5: try { try decoder.decodeRepeatedEnumField(value: &_storage._mediums) }()
@@ -2658,10 +2658,10 @@ extension Location_Nearby_Connections_ConnectionRequestFrame: SwiftProtobuf.Mess
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182 // https://github.com/apple/swift-protobuf/issues/1182
try { if let v = _storage._endpointID { try { if let v = _storage._endpointID {
try visitor.visitSingularStringField(value: v, fieldNumber: 1) try visitor.visitSingularBytesField(value: v, fieldNumber: 1)
} }() } }()
try { if let v = _storage._endpointName { try { if let v = _storage._endpointName {
try visitor.visitSingularStringField(value: v, fieldNumber: 2) try visitor.visitSingularBytesField(value: v, fieldNumber: 2)
} }() } }()
try { if let v = _storage._handshakeData { try { if let v = _storage._handshakeData {
try visitor.visitSingularBytesField(value: v, fieldNumber: 3) try visitor.visitSingularBytesField(value: v, fieldNumber: 3)
+506 -186
View File
@@ -334,6 +334,151 @@ struct Sharing_Nearby_WifiCredentialsMetadata: Sendable {
fileprivate var _id: Int64? = nil fileprivate var _id: Int64? = nil
} }
/// NEXT_ID=10
struct Sharing_Nearby_MirroredNotificationMetadata: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var version: Int32 {
get {return _version ?? 1}
set {_version = newValue}
}
/// Returns true if `version` has been explicitly set.
var hasVersion: Bool {return self._version != nil}
/// Clears the value of `version`. Subsequent reads from it will return its default value.
mutating func clearVersion() {self._version = nil}
var notificationKey: String {
get {return _notificationKey ?? String()}
set {_notificationKey = newValue}
}
/// Returns true if `notificationKey` has been explicitly set.
var hasNotificationKey: Bool {return self._notificationKey != nil}
/// Clears the value of `notificationKey`. Subsequent reads from it will return its default value.
mutating func clearNotificationKey() {self._notificationKey = nil}
var packageName: String {
get {return _packageName ?? String()}
set {_packageName = newValue}
}
/// Returns true if `packageName` has been explicitly set.
var hasPackageName: Bool {return self._packageName != nil}
/// Clears the value of `packageName`. Subsequent reads from it will return its default value.
mutating func clearPackageName() {self._packageName = nil}
var appName: String {
get {return _appName ?? String()}
set {_appName = newValue}
}
/// Returns true if `appName` has been explicitly set.
var hasAppName: Bool {return self._appName != nil}
/// Clears the value of `appName`. Subsequent reads from it will return its default value.
mutating func clearAppName() {self._appName = nil}
var title: String {
get {return _title ?? String()}
set {_title = newValue}
}
/// Returns true if `title` has been explicitly set.
var hasTitle: Bool {return self._title != nil}
/// Clears the value of `title`. Subsequent reads from it will return its default value.
mutating func clearTitle() {self._title = nil}
var body: String {
get {return _body ?? String()}
set {_body = newValue}
}
/// Returns true if `body` has been explicitly set.
var hasBody: Bool {return self._body != nil}
/// Clears the value of `body`. Subsequent reads from it will return its default value.
mutating func clearBody() {self._body = nil}
var postedAtEpochMs: Int64 {
get {return _postedAtEpochMs ?? 0}
set {_postedAtEpochMs = newValue}
}
/// Returns true if `postedAtEpochMs` has been explicitly set.
var hasPostedAtEpochMs: Bool {return self._postedAtEpochMs != nil}
/// Clears the value of `postedAtEpochMs`. Subsequent reads from it will return its default value.
mutating func clearPostedAtEpochMs() {self._postedAtEpochMs = nil}
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
fileprivate var _version: Int32? = nil
fileprivate var _notificationKey: String? = nil
fileprivate var _packageName: String? = nil
fileprivate var _appName: String? = nil
fileprivate var _title: String? = nil
fileprivate var _body: String? = nil
fileprivate var _postedAtEpochMs: Int64? = nil
}
/// NEXT_ID=5
struct Sharing_Nearby_PairingMetadata: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var version: Int32 {
get {return _version ?? 1}
set {_version = newValue}
}
/// Returns true if `version` has been explicitly set.
var hasVersion: Bool {return self._version != nil}
/// Clears the value of `version`. Subsequent reads from it will return its default value.
mutating func clearVersion() {self._version = nil}
var useCase: Sharing_Nearby_PairingMetadata.UseCase {
get {return _useCase ?? .unknown}
set {_useCase = newValue}
}
/// Returns true if `useCase` has been explicitly set.
var hasUseCase: Bool {return self._useCase != nil}
/// Clears the value of `useCase`. Subsequent reads from it will return its default value.
mutating func clearUseCase() {self._useCase = nil}
var setupRequest: Bool {
get {return _setupRequest ?? false}
set {_setupRequest = newValue}
}
/// Returns true if `setupRequest` has been explicitly set.
var hasSetupRequest: Bool {return self._setupRequest != nil}
/// Clears the value of `setupRequest`. Subsequent reads from it will return its default value.
mutating func clearSetupRequest() {self._setupRequest = nil}
var setupPinHash: Data {
get {return _setupPinHash ?? Data()}
set {_setupPinHash = newValue}
}
/// Returns true if `setupPinHash` has been explicitly set.
var hasSetupPinHash: Bool {return self._setupPinHash != nil}
/// Clears the value of `setupPinHash`. Subsequent reads from it will return its default value.
mutating func clearSetupPinHash() {self._setupPinHash = nil}
var unknownFields = SwiftProtobuf.UnknownStorage()
enum UseCase: Int, SwiftProtobuf.Enum, Swift.CaseIterable {
case unknown = 0
case notificationSync = 1
case clipboardSync = 2
init() {
self = .unknown
}
}
init() {}
fileprivate var _version: Int32? = nil
fileprivate var _useCase: Sharing_Nearby_PairingMetadata.UseCase? = nil
fileprivate var _setupRequest: Bool? = nil
fileprivate var _setupPinHash: Data? = nil
}
/// NEXT_ID=8 /// NEXT_ID=8
struct Sharing_Nearby_AppMetadata: Sendable { struct Sharing_Nearby_AppMetadata: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the // SwiftProtobuf.Message conformance is added in an extension below. See the
@@ -505,76 +650,76 @@ struct Sharing_Nearby_Frame: Sendable {
} }
/// NEXT_ID=8 /// NEXT_ID=8
struct Sharing_Nearby_V1Frame: @unchecked Sendable { struct Sharing_Nearby_V1Frame: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the // SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for // `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages. // methods supported on all messages.
var type: Sharing_Nearby_V1Frame.FrameType { var type: Sharing_Nearby_V1Frame.FrameType {
get {return _storage._type ?? .unknownFrameType} get {return _type ?? .unknownFrameType}
set {_uniqueStorage()._type = newValue} set {_type = newValue}
} }
/// Returns true if `type` has been explicitly set. /// Returns true if `type` has been explicitly set.
var hasType: Bool {return _storage._type != nil} var hasType: Bool {return self._type != nil}
/// Clears the value of `type`. Subsequent reads from it will return its default value. /// Clears the value of `type`. Subsequent reads from it will return its default value.
mutating func clearType() {_uniqueStorage()._type = nil} mutating func clearType() {self._type = nil}
/// At most one of the following fields will be set. /// At most one of the following fields will be set.
var introduction: Sharing_Nearby_IntroductionFrame { var introduction: Sharing_Nearby_IntroductionFrame {
get {return _storage._introduction ?? Sharing_Nearby_IntroductionFrame()} get {return _introduction ?? Sharing_Nearby_IntroductionFrame()}
set {_uniqueStorage()._introduction = newValue} set {_introduction = newValue}
} }
/// Returns true if `introduction` has been explicitly set. /// Returns true if `introduction` has been explicitly set.
var hasIntroduction: Bool {return _storage._introduction != nil} var hasIntroduction: Bool {return self._introduction != nil}
/// Clears the value of `introduction`. Subsequent reads from it will return its default value. /// Clears the value of `introduction`. Subsequent reads from it will return its default value.
mutating func clearIntroduction() {_uniqueStorage()._introduction = nil} mutating func clearIntroduction() {self._introduction = nil}
var connectionResponse: Sharing_Nearby_ConnectionResponseFrame { var connectionResponse: Sharing_Nearby_ConnectionResponseFrame {
get {return _storage._connectionResponse ?? Sharing_Nearby_ConnectionResponseFrame()} get {return _connectionResponse ?? Sharing_Nearby_ConnectionResponseFrame()}
set {_uniqueStorage()._connectionResponse = newValue} set {_connectionResponse = newValue}
} }
/// Returns true if `connectionResponse` has been explicitly set. /// Returns true if `connectionResponse` has been explicitly set.
var hasConnectionResponse: Bool {return _storage._connectionResponse != nil} var hasConnectionResponse: Bool {return self._connectionResponse != nil}
/// Clears the value of `connectionResponse`. Subsequent reads from it will return its default value. /// Clears the value of `connectionResponse`. Subsequent reads from it will return its default value.
mutating func clearConnectionResponse() {_uniqueStorage()._connectionResponse = nil} mutating func clearConnectionResponse() {self._connectionResponse = nil}
var pairedKeyEncryption: Sharing_Nearby_PairedKeyEncryptionFrame { var pairedKeyEncryption: Sharing_Nearby_PairedKeyEncryptionFrame {
get {return _storage._pairedKeyEncryption ?? Sharing_Nearby_PairedKeyEncryptionFrame()} get {return _pairedKeyEncryption ?? Sharing_Nearby_PairedKeyEncryptionFrame()}
set {_uniqueStorage()._pairedKeyEncryption = newValue} set {_pairedKeyEncryption = newValue}
} }
/// Returns true if `pairedKeyEncryption` has been explicitly set. /// Returns true if `pairedKeyEncryption` has been explicitly set.
var hasPairedKeyEncryption: Bool {return _storage._pairedKeyEncryption != nil} var hasPairedKeyEncryption: Bool {return self._pairedKeyEncryption != nil}
/// Clears the value of `pairedKeyEncryption`. Subsequent reads from it will return its default value. /// Clears the value of `pairedKeyEncryption`. Subsequent reads from it will return its default value.
mutating func clearPairedKeyEncryption() {_uniqueStorage()._pairedKeyEncryption = nil} mutating func clearPairedKeyEncryption() {self._pairedKeyEncryption = nil}
var pairedKeyResult: Sharing_Nearby_PairedKeyResultFrame { var pairedKeyResult: Sharing_Nearby_PairedKeyResultFrame {
get {return _storage._pairedKeyResult ?? Sharing_Nearby_PairedKeyResultFrame()} get {return _pairedKeyResult ?? Sharing_Nearby_PairedKeyResultFrame()}
set {_uniqueStorage()._pairedKeyResult = newValue} set {_pairedKeyResult = newValue}
} }
/// Returns true if `pairedKeyResult` has been explicitly set. /// Returns true if `pairedKeyResult` has been explicitly set.
var hasPairedKeyResult: Bool {return _storage._pairedKeyResult != nil} var hasPairedKeyResult: Bool {return self._pairedKeyResult != nil}
/// Clears the value of `pairedKeyResult`. Subsequent reads from it will return its default value. /// Clears the value of `pairedKeyResult`. Subsequent reads from it will return its default value.
mutating func clearPairedKeyResult() {_uniqueStorage()._pairedKeyResult = nil} mutating func clearPairedKeyResult() {self._pairedKeyResult = nil}
/// NOTE: This field was marked as deprecated in the .proto file. /// NOTE: This field was marked as deprecated in the .proto file.
var certificateInfo: Sharing_Nearby_CertificateInfoFrame { var certificateInfo: Sharing_Nearby_CertificateInfoFrame {
get {return _storage._certificateInfo ?? Sharing_Nearby_CertificateInfoFrame()} get {return _certificateInfo ?? Sharing_Nearby_CertificateInfoFrame()}
set {_uniqueStorage()._certificateInfo = newValue} set {_certificateInfo = newValue}
} }
/// Returns true if `certificateInfo` has been explicitly set. /// Returns true if `certificateInfo` has been explicitly set.
var hasCertificateInfo: Bool {return _storage._certificateInfo != nil} var hasCertificateInfo: Bool {return self._certificateInfo != nil}
/// Clears the value of `certificateInfo`. Subsequent reads from it will return its default value. /// Clears the value of `certificateInfo`. Subsequent reads from it will return its default value.
mutating func clearCertificateInfo() {_uniqueStorage()._certificateInfo = nil} mutating func clearCertificateInfo() {self._certificateInfo = nil}
/// NOTE: This field was marked as deprecated in the .proto file. /// NOTE: This field was marked as deprecated in the .proto file.
var progressUpdate: Sharing_Nearby_ProgressUpdateFrame { var progressUpdate: Sharing_Nearby_ProgressUpdateFrame {
get {return _storage._progressUpdate ?? Sharing_Nearby_ProgressUpdateFrame()} get {return _progressUpdate ?? Sharing_Nearby_ProgressUpdateFrame()}
set {_uniqueStorage()._progressUpdate = newValue} set {_progressUpdate = newValue}
} }
/// Returns true if `progressUpdate` has been explicitly set. /// Returns true if `progressUpdate` has been explicitly set.
var hasProgressUpdate: Bool {return _storage._progressUpdate != nil} var hasProgressUpdate: Bool {return self._progressUpdate != nil}
/// Clears the value of `progressUpdate`. Subsequent reads from it will return its default value. /// Clears the value of `progressUpdate`. Subsequent reads from it will return its default value.
mutating func clearProgressUpdate() {_uniqueStorage()._progressUpdate = nil} mutating func clearProgressUpdate() {self._progressUpdate = nil}
var unknownFields = SwiftProtobuf.UnknownStorage() var unknownFields = SwiftProtobuf.UnknownStorage()
@@ -600,56 +745,98 @@ struct Sharing_Nearby_V1Frame: @unchecked Sendable {
init() {} init() {}
fileprivate var _storage = _StorageClass.defaultInstance fileprivate var _type: Sharing_Nearby_V1Frame.FrameType? = nil
fileprivate var _introduction: Sharing_Nearby_IntroductionFrame? = nil
fileprivate var _connectionResponse: Sharing_Nearby_ConnectionResponseFrame? = nil
fileprivate var _pairedKeyEncryption: Sharing_Nearby_PairedKeyEncryptionFrame? = nil
fileprivate var _pairedKeyResult: Sharing_Nearby_PairedKeyResultFrame? = nil
fileprivate var _certificateInfo: Sharing_Nearby_CertificateInfoFrame? = nil
fileprivate var _progressUpdate: Sharing_Nearby_ProgressUpdateFrame? = nil
} }
/// An introduction packet sent by the sending side. Contains a list of files /// An introduction packet sent by the sending side. Contains a list of files
/// they'd like to share. /// they'd like to share.
/// NEXT_ID=10 /// NEXT_ID=1003
struct Sharing_Nearby_IntroductionFrame: Sendable { struct Sharing_Nearby_IntroductionFrame: @unchecked Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the // SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for // `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages. // methods supported on all messages.
var fileMetadata: [Sharing_Nearby_FileMetadata] = [] var fileMetadata: [Sharing_Nearby_FileMetadata] {
get {return _storage._fileMetadata}
set {_uniqueStorage()._fileMetadata = newValue}
}
var textMetadata: [Sharing_Nearby_TextMetadata] = [] var textMetadata: [Sharing_Nearby_TextMetadata] {
get {return _storage._textMetadata}
set {_uniqueStorage()._textMetadata = newValue}
}
/// The required app package to open the content. May be null. /// The required app package to open the content. May be null.
var requiredPackage: String { var requiredPackage: String {
get {return _requiredPackage ?? String()} get {return _storage._requiredPackage ?? String()}
set {_requiredPackage = newValue} set {_uniqueStorage()._requiredPackage = newValue}
} }
/// Returns true if `requiredPackage` has been explicitly set. /// Returns true if `requiredPackage` has been explicitly set.
var hasRequiredPackage: Bool {return self._requiredPackage != nil} var hasRequiredPackage: Bool {return _storage._requiredPackage != nil}
/// Clears the value of `requiredPackage`. Subsequent reads from it will return its default value. /// Clears the value of `requiredPackage`. Subsequent reads from it will return its default value.
mutating func clearRequiredPackage() {self._requiredPackage = nil} mutating func clearRequiredPackage() {_uniqueStorage()._requiredPackage = nil}
var wifiCredentialsMetadata: [Sharing_Nearby_WifiCredentialsMetadata] = [] var wifiCredentialsMetadata: [Sharing_Nearby_WifiCredentialsMetadata] {
get {return _storage._wifiCredentialsMetadata}
set {_uniqueStorage()._wifiCredentialsMetadata = newValue}
}
var appMetadata: [Sharing_Nearby_AppMetadata] = [] var appMetadata: [Sharing_Nearby_AppMetadata] {
get {return _storage._appMetadata}
set {_uniqueStorage()._appMetadata = newValue}
}
var startTransfer: Bool { var startTransfer: Bool {
get {return _startTransfer ?? false} get {return _storage._startTransfer ?? false}
set {_startTransfer = newValue} set {_uniqueStorage()._startTransfer = newValue}
} }
/// Returns true if `startTransfer` has been explicitly set. /// Returns true if `startTransfer` has been explicitly set.
var hasStartTransfer: Bool {return self._startTransfer != nil} var hasStartTransfer: Bool {return _storage._startTransfer != nil}
/// Clears the value of `startTransfer`. Subsequent reads from it will return its default value. /// Clears the value of `startTransfer`. Subsequent reads from it will return its default value.
mutating func clearStartTransfer() {self._startTransfer = nil} mutating func clearStartTransfer() {_uniqueStorage()._startTransfer = nil}
var streamMetadata: [Sharing_Nearby_StreamMetadata] = [] var streamMetadata: [Sharing_Nearby_StreamMetadata] {
get {return _storage._streamMetadata}
set {_uniqueStorage()._streamMetadata = newValue}
}
var useCase: Sharing_Nearby_IntroductionFrame.SharingUseCase { var useCase: Sharing_Nearby_IntroductionFrame.SharingUseCase {
get {return _useCase ?? .unknown} get {return _storage._useCase ?? .unknown}
set {_useCase = newValue} set {_uniqueStorage()._useCase = newValue}
} }
/// Returns true if `useCase` has been explicitly set. /// Returns true if `useCase` has been explicitly set.
var hasUseCase: Bool {return self._useCase != nil} var hasUseCase: Bool {return _storage._useCase != nil}
/// Clears the value of `useCase`. Subsequent reads from it will return its default value. /// Clears the value of `useCase`. Subsequent reads from it will return its default value.
mutating func clearUseCase() {self._useCase = nil} mutating func clearUseCase() {_uniqueStorage()._useCase = nil}
var previewPayloadIds: [Int64] = [] var previewPayloadIds: [Int64] {
get {return _storage._previewPayloadIds}
set {_uniqueStorage()._previewPayloadIds = newValue}
}
var mirroredNotificationMetadata: Sharing_Nearby_MirroredNotificationMetadata {
get {return _storage._mirroredNotificationMetadata ?? Sharing_Nearby_MirroredNotificationMetadata()}
set {_uniqueStorage()._mirroredNotificationMetadata = newValue}
}
/// Returns true if `mirroredNotificationMetadata` has been explicitly set.
var hasMirroredNotificationMetadata: Bool {return _storage._mirroredNotificationMetadata != nil}
/// Clears the value of `mirroredNotificationMetadata`. Subsequent reads from it will return its default value.
mutating func clearMirroredNotificationMetadata() {_uniqueStorage()._mirroredNotificationMetadata = nil}
var pairingMetadata: Sharing_Nearby_PairingMetadata {
get {return _storage._pairingMetadata ?? Sharing_Nearby_PairingMetadata()}
set {_uniqueStorage()._pairingMetadata = newValue}
}
/// Returns true if `pairingMetadata` has been explicitly set.
var hasPairingMetadata: Bool {return _storage._pairingMetadata != nil}
/// Clears the value of `pairingMetadata`. Subsequent reads from it will return its default value.
mutating func clearPairingMetadata() {_uniqueStorage()._pairingMetadata = nil}
var unknownFields = SwiftProtobuf.UnknownStorage() var unknownFields = SwiftProtobuf.UnknownStorage()
@@ -666,9 +853,7 @@ struct Sharing_Nearby_IntroductionFrame: Sendable {
init() {} init() {}
fileprivate var _requiredPackage: String? = nil fileprivate var _storage = _StorageClass.defaultInstance
fileprivate var _startTransfer: Bool? = nil
fileprivate var _useCase: Sharing_Nearby_IntroductionFrame.SharingUseCase? = nil
} }
/// A progress update packet sent by the sending side. Contains transfer progress /// A progress update packet sent by the sending side. Contains transfer progress
@@ -1371,6 +1556,123 @@ extension Sharing_Nearby_WifiCredentialsMetadata.SecurityType: SwiftProtobuf._Pr
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN_SECURITY_TYPE\0\u{1}OPEN\0\u{1}WPA_PSK\0\u{1}WEP\0\u{1}SAE\0") static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN_SECURITY_TYPE\0\u{1}OPEN\0\u{1}WPA_PSK\0\u{1}WEP\0\u{1}SAE\0")
} }
extension Sharing_Nearby_MirroredNotificationMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".MirroredNotificationMetadata"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}version\0\u{3}notification_key\0\u{3}package_name\0\u{3}app_name\0\u{1}title\0\u{1}body\0\u{3}posted_at_epoch_ms\0\u{c}\u{8}\u{1}\u{c}\u{9}\u{1}")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularInt32Field(value: &self._version) }()
case 2: try { try decoder.decodeSingularStringField(value: &self._notificationKey) }()
case 3: try { try decoder.decodeSingularStringField(value: &self._packageName) }()
case 4: try { try decoder.decodeSingularStringField(value: &self._appName) }()
case 5: try { try decoder.decodeSingularStringField(value: &self._title) }()
case 6: try { try decoder.decodeSingularStringField(value: &self._body) }()
case 7: try { try decoder.decodeSingularInt64Field(value: &self._postedAtEpochMs) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
try { if let v = self._version {
try visitor.visitSingularInt32Field(value: v, fieldNumber: 1)
} }()
try { if let v = self._notificationKey {
try visitor.visitSingularStringField(value: v, fieldNumber: 2)
} }()
try { if let v = self._packageName {
try visitor.visitSingularStringField(value: v, fieldNumber: 3)
} }()
try { if let v = self._appName {
try visitor.visitSingularStringField(value: v, fieldNumber: 4)
} }()
try { if let v = self._title {
try visitor.visitSingularStringField(value: v, fieldNumber: 5)
} }()
try { if let v = self._body {
try visitor.visitSingularStringField(value: v, fieldNumber: 6)
} }()
try { if let v = self._postedAtEpochMs {
try visitor.visitSingularInt64Field(value: v, fieldNumber: 7)
} }()
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: Sharing_Nearby_MirroredNotificationMetadata, rhs: Sharing_Nearby_MirroredNotificationMetadata) -> Bool {
if lhs._version != rhs._version {return false}
if lhs._notificationKey != rhs._notificationKey {return false}
if lhs._packageName != rhs._packageName {return false}
if lhs._appName != rhs._appName {return false}
if lhs._title != rhs._title {return false}
if lhs._body != rhs._body {return false}
if lhs._postedAtEpochMs != rhs._postedAtEpochMs {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension Sharing_Nearby_PairingMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".PairingMetadata"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}version\0\u{3}use_case\0\u{3}setup_request\0\u{3}setup_pin_hash\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularInt32Field(value: &self._version) }()
case 2: try { try decoder.decodeSingularEnumField(value: &self._useCase) }()
case 3: try { try decoder.decodeSingularBoolField(value: &self._setupRequest) }()
case 4: try { try decoder.decodeSingularBytesField(value: &self._setupPinHash) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
try { if let v = self._version {
try visitor.visitSingularInt32Field(value: v, fieldNumber: 1)
} }()
try { if let v = self._useCase {
try visitor.visitSingularEnumField(value: v, fieldNumber: 2)
} }()
try { if let v = self._setupRequest {
try visitor.visitSingularBoolField(value: v, fieldNumber: 3)
} }()
try { if let v = self._setupPinHash {
try visitor.visitSingularBytesField(value: v, fieldNumber: 4)
} }()
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: Sharing_Nearby_PairingMetadata, rhs: Sharing_Nearby_PairingMetadata) -> Bool {
if lhs._version != rhs._version {return false}
if lhs._useCase != rhs._useCase {return false}
if lhs._setupRequest != rhs._setupRequest {return false}
if lhs._setupPinHash != rhs._setupPinHash {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension Sharing_Nearby_PairingMetadata.UseCase: SwiftProtobuf._ProtoNameProviding {
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN\0\u{1}NOTIFICATION_SYNC\0\u{1}CLIPBOARD_SYNC\0")
}
extension Sharing_Nearby_AppMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { extension Sharing_Nearby_AppMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".AppMetadata" static let protoMessageName: String = _protobuf_package + ".AppMetadata"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}app_name\0\u{1}size\0\u{3}payload_id\0\u{1}id\0\u{3}file_name\0\u{3}file_size\0\u{3}package_name\0") static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}app_name\0\u{1}size\0\u{3}payload_id\0\u{1}id\0\u{3}file_name\0\u{3}file_size\0\u{3}package_name\0")
@@ -1531,14 +1833,86 @@ extension Sharing_Nearby_V1Frame: SwiftProtobuf.Message, SwiftProtobuf._MessageI
static let protoMessageName: String = _protobuf_package + ".V1Frame" static let protoMessageName: String = _protobuf_package + ".V1Frame"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}introduction\0\u{3}connection_response\0\u{3}paired_key_encryption\0\u{3}paired_key_result\0\u{3}certificate_info\0\u{3}progress_update\0") static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}introduction\0\u{3}connection_response\0\u{3}paired_key_encryption\0\u{3}paired_key_result\0\u{3}certificate_info\0\u{3}progress_update\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularEnumField(value: &self._type) }()
case 2: try { try decoder.decodeSingularMessageField(value: &self._introduction) }()
case 3: try { try decoder.decodeSingularMessageField(value: &self._connectionResponse) }()
case 4: try { try decoder.decodeSingularMessageField(value: &self._pairedKeyEncryption) }()
case 5: try { try decoder.decodeSingularMessageField(value: &self._pairedKeyResult) }()
case 6: try { try decoder.decodeSingularMessageField(value: &self._certificateInfo) }()
case 7: try { try decoder.decodeSingularMessageField(value: &self._progressUpdate) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
try { if let v = self._type {
try visitor.visitSingularEnumField(value: v, fieldNumber: 1)
} }()
try { if let v = self._introduction {
try visitor.visitSingularMessageField(value: v, fieldNumber: 2)
} }()
try { if let v = self._connectionResponse {
try visitor.visitSingularMessageField(value: v, fieldNumber: 3)
} }()
try { if let v = self._pairedKeyEncryption {
try visitor.visitSingularMessageField(value: v, fieldNumber: 4)
} }()
try { if let v = self._pairedKeyResult {
try visitor.visitSingularMessageField(value: v, fieldNumber: 5)
} }()
try { if let v = self._certificateInfo {
try visitor.visitSingularMessageField(value: v, fieldNumber: 6)
} }()
try { if let v = self._progressUpdate {
try visitor.visitSingularMessageField(value: v, fieldNumber: 7)
} }()
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: Sharing_Nearby_V1Frame, rhs: Sharing_Nearby_V1Frame) -> Bool {
if lhs._type != rhs._type {return false}
if lhs._introduction != rhs._introduction {return false}
if lhs._connectionResponse != rhs._connectionResponse {return false}
if lhs._pairedKeyEncryption != rhs._pairedKeyEncryption {return false}
if lhs._pairedKeyResult != rhs._pairedKeyResult {return false}
if lhs._certificateInfo != rhs._certificateInfo {return false}
if lhs._progressUpdate != rhs._progressUpdate {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension Sharing_Nearby_V1Frame.FrameType: SwiftProtobuf._ProtoNameProviding {
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN_FRAME_TYPE\0\u{1}INTRODUCTION\0\u{1}RESPONSE\0\u{1}PAIRED_KEY_ENCRYPTION\0\u{1}PAIRED_KEY_RESULT\0\u{1}CERTIFICATE_INFO\0\u{1}CANCEL\0\u{1}PROGRESS_UPDATE\0")
}
extension Sharing_Nearby_IntroductionFrame: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".IntroductionFrame"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}file_metadata\0\u{3}text_metadata\0\u{3}required_package\0\u{3}wifi_credentials_metadata\0\u{3}app_metadata\0\u{3}start_transfer\0\u{3}stream_metadata\0\u{3}use_case\0\u{3}preview_payload_ids\0\u{4}`\u{f}mirrored_notification_metadata\0\u{3}pairing_metadata\0")
fileprivate class _StorageClass { fileprivate class _StorageClass {
var _type: Sharing_Nearby_V1Frame.FrameType? = nil var _fileMetadata: [Sharing_Nearby_FileMetadata] = []
var _introduction: Sharing_Nearby_IntroductionFrame? = nil var _textMetadata: [Sharing_Nearby_TextMetadata] = []
var _connectionResponse: Sharing_Nearby_ConnectionResponseFrame? = nil var _requiredPackage: String? = nil
var _pairedKeyEncryption: Sharing_Nearby_PairedKeyEncryptionFrame? = nil var _wifiCredentialsMetadata: [Sharing_Nearby_WifiCredentialsMetadata] = []
var _pairedKeyResult: Sharing_Nearby_PairedKeyResultFrame? = nil var _appMetadata: [Sharing_Nearby_AppMetadata] = []
var _certificateInfo: Sharing_Nearby_CertificateInfoFrame? = nil var _startTransfer: Bool? = nil
var _progressUpdate: Sharing_Nearby_ProgressUpdateFrame? = nil var _streamMetadata: [Sharing_Nearby_StreamMetadata] = []
var _useCase: Sharing_Nearby_IntroductionFrame.SharingUseCase? = nil
var _previewPayloadIds: [Int64] = []
var _mirroredNotificationMetadata: Sharing_Nearby_MirroredNotificationMetadata? = nil
var _pairingMetadata: Sharing_Nearby_PairingMetadata? = nil
// This property is used as the initial default value for new instances of the type. // This property is used as the initial default value for new instances of the type.
// The type itself is protecting the reference to its storage via CoW semantics. // The type itself is protecting the reference to its storage via CoW semantics.
@@ -1549,13 +1923,17 @@ extension Sharing_Nearby_V1Frame: SwiftProtobuf.Message, SwiftProtobuf._MessageI
private init() {} private init() {}
init(copying source: _StorageClass) { init(copying source: _StorageClass) {
_type = source._type _fileMetadata = source._fileMetadata
_introduction = source._introduction _textMetadata = source._textMetadata
_connectionResponse = source._connectionResponse _requiredPackage = source._requiredPackage
_pairedKeyEncryption = source._pairedKeyEncryption _wifiCredentialsMetadata = source._wifiCredentialsMetadata
_pairedKeyResult = source._pairedKeyResult _appMetadata = source._appMetadata
_certificateInfo = source._certificateInfo _startTransfer = source._startTransfer
_progressUpdate = source._progressUpdate _streamMetadata = source._streamMetadata
_useCase = source._useCase
_previewPayloadIds = source._previewPayloadIds
_mirroredNotificationMetadata = source._mirroredNotificationMetadata
_pairingMetadata = source._pairingMetadata
} }
} }
@@ -1574,13 +1952,17 @@ extension Sharing_Nearby_V1Frame: SwiftProtobuf.Message, SwiftProtobuf._MessageI
// allocates stack space for every case branch when no optimizations are // allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034 // enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber { switch fieldNumber {
case 1: try { try decoder.decodeSingularEnumField(value: &_storage._type) }() case 1: try { try decoder.decodeRepeatedMessageField(value: &_storage._fileMetadata) }()
case 2: try { try decoder.decodeSingularMessageField(value: &_storage._introduction) }() case 2: try { try decoder.decodeRepeatedMessageField(value: &_storage._textMetadata) }()
case 3: try { try decoder.decodeSingularMessageField(value: &_storage._connectionResponse) }() case 3: try { try decoder.decodeSingularStringField(value: &_storage._requiredPackage) }()
case 4: try { try decoder.decodeSingularMessageField(value: &_storage._pairedKeyEncryption) }() case 4: try { try decoder.decodeRepeatedMessageField(value: &_storage._wifiCredentialsMetadata) }()
case 5: try { try decoder.decodeSingularMessageField(value: &_storage._pairedKeyResult) }() case 5: try { try decoder.decodeRepeatedMessageField(value: &_storage._appMetadata) }()
case 6: try { try decoder.decodeSingularMessageField(value: &_storage._certificateInfo) }() case 6: try { try decoder.decodeSingularBoolField(value: &_storage._startTransfer) }()
case 7: try { try decoder.decodeSingularMessageField(value: &_storage._progressUpdate) }() case 7: try { try decoder.decodeRepeatedMessageField(value: &_storage._streamMetadata) }()
case 8: try { try decoder.decodeSingularEnumField(value: &_storage._useCase) }()
case 9: try { try decoder.decodeRepeatedInt64Field(value: &_storage._previewPayloadIds) }()
case 1001: try { try decoder.decodeSingularMessageField(value: &_storage._mirroredNotificationMetadata) }()
case 1002: try { try decoder.decodeSingularMessageField(value: &_storage._pairingMetadata) }()
default: break default: break
} }
} }
@@ -1593,125 +1975,63 @@ extension Sharing_Nearby_V1Frame: SwiftProtobuf.Message, SwiftProtobuf._MessageI
// allocates stack space for every if/case branch local when no optimizations // allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182 // https://github.com/apple/swift-protobuf/issues/1182
try { if let v = _storage._type { if !_storage._fileMetadata.isEmpty {
try visitor.visitSingularEnumField(value: v, fieldNumber: 1) try visitor.visitRepeatedMessageField(value: _storage._fileMetadata, fieldNumber: 1)
} }()
try { if let v = _storage._introduction {
try visitor.visitSingularMessageField(value: v, fieldNumber: 2)
} }()
try { if let v = _storage._connectionResponse {
try visitor.visitSingularMessageField(value: v, fieldNumber: 3)
} }()
try { if let v = _storage._pairedKeyEncryption {
try visitor.visitSingularMessageField(value: v, fieldNumber: 4)
} }()
try { if let v = _storage._pairedKeyResult {
try visitor.visitSingularMessageField(value: v, fieldNumber: 5)
} }()
try { if let v = _storage._certificateInfo {
try visitor.visitSingularMessageField(value: v, fieldNumber: 6)
} }()
try { if let v = _storage._progressUpdate {
try visitor.visitSingularMessageField(value: v, fieldNumber: 7)
} }()
}
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: Sharing_Nearby_V1Frame, rhs: Sharing_Nearby_V1Frame) -> Bool {
if lhs._storage !== rhs._storage {
let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in
let _storage = _args.0
let rhs_storage = _args.1
if _storage._type != rhs_storage._type {return false}
if _storage._introduction != rhs_storage._introduction {return false}
if _storage._connectionResponse != rhs_storage._connectionResponse {return false}
if _storage._pairedKeyEncryption != rhs_storage._pairedKeyEncryption {return false}
if _storage._pairedKeyResult != rhs_storage._pairedKeyResult {return false}
if _storage._certificateInfo != rhs_storage._certificateInfo {return false}
if _storage._progressUpdate != rhs_storage._progressUpdate {return false}
return true
} }
if !storagesAreEqual {return false} if !_storage._textMetadata.isEmpty {
} try visitor.visitRepeatedMessageField(value: _storage._textMetadata, fieldNumber: 2)
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension Sharing_Nearby_V1Frame.FrameType: SwiftProtobuf._ProtoNameProviding {
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN_FRAME_TYPE\0\u{1}INTRODUCTION\0\u{1}RESPONSE\0\u{1}PAIRED_KEY_ENCRYPTION\0\u{1}PAIRED_KEY_RESULT\0\u{1}CERTIFICATE_INFO\0\u{1}CANCEL\0\u{1}PROGRESS_UPDATE\0")
}
extension Sharing_Nearby_IntroductionFrame: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".IntroductionFrame"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}file_metadata\0\u{3}text_metadata\0\u{3}required_package\0\u{3}wifi_credentials_metadata\0\u{3}app_metadata\0\u{3}start_transfer\0\u{3}stream_metadata\0\u{3}use_case\0\u{3}preview_payload_ids\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeRepeatedMessageField(value: &self.fileMetadata) }()
case 2: try { try decoder.decodeRepeatedMessageField(value: &self.textMetadata) }()
case 3: try { try decoder.decodeSingularStringField(value: &self._requiredPackage) }()
case 4: try { try decoder.decodeRepeatedMessageField(value: &self.wifiCredentialsMetadata) }()
case 5: try { try decoder.decodeRepeatedMessageField(value: &self.appMetadata) }()
case 6: try { try decoder.decodeSingularBoolField(value: &self._startTransfer) }()
case 7: try { try decoder.decodeRepeatedMessageField(value: &self.streamMetadata) }()
case 8: try { try decoder.decodeSingularEnumField(value: &self._useCase) }()
case 9: try { try decoder.decodeRepeatedInt64Field(value: &self.previewPayloadIds) }()
default: break
} }
} try { if let v = _storage._requiredPackage {
} try visitor.visitSingularStringField(value: v, fieldNumber: 3)
} }()
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws { if !_storage._wifiCredentialsMetadata.isEmpty {
// The use of inline closures is to circumvent an issue where the compiler try visitor.visitRepeatedMessageField(value: _storage._wifiCredentialsMetadata, fieldNumber: 4)
// allocates stack space for every if/case branch local when no optimizations }
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and if !_storage._appMetadata.isEmpty {
// https://github.com/apple/swift-protobuf/issues/1182 try visitor.visitRepeatedMessageField(value: _storage._appMetadata, fieldNumber: 5)
if !self.fileMetadata.isEmpty { }
try visitor.visitRepeatedMessageField(value: self.fileMetadata, fieldNumber: 1) try { if let v = _storage._startTransfer {
} try visitor.visitSingularBoolField(value: v, fieldNumber: 6)
if !self.textMetadata.isEmpty { } }()
try visitor.visitRepeatedMessageField(value: self.textMetadata, fieldNumber: 2) if !_storage._streamMetadata.isEmpty {
} try visitor.visitRepeatedMessageField(value: _storage._streamMetadata, fieldNumber: 7)
try { if let v = self._requiredPackage { }
try visitor.visitSingularStringField(value: v, fieldNumber: 3) try { if let v = _storage._useCase {
} }() try visitor.visitSingularEnumField(value: v, fieldNumber: 8)
if !self.wifiCredentialsMetadata.isEmpty { } }()
try visitor.visitRepeatedMessageField(value: self.wifiCredentialsMetadata, fieldNumber: 4) if !_storage._previewPayloadIds.isEmpty {
} try visitor.visitRepeatedInt64Field(value: _storage._previewPayloadIds, fieldNumber: 9)
if !self.appMetadata.isEmpty { }
try visitor.visitRepeatedMessageField(value: self.appMetadata, fieldNumber: 5) try { if let v = _storage._mirroredNotificationMetadata {
} try visitor.visitSingularMessageField(value: v, fieldNumber: 1001)
try { if let v = self._startTransfer { } }()
try visitor.visitSingularBoolField(value: v, fieldNumber: 6) try { if let v = _storage._pairingMetadata {
} }() try visitor.visitSingularMessageField(value: v, fieldNumber: 1002)
if !self.streamMetadata.isEmpty { } }()
try visitor.visitRepeatedMessageField(value: self.streamMetadata, fieldNumber: 7)
}
try { if let v = self._useCase {
try visitor.visitSingularEnumField(value: v, fieldNumber: 8)
} }()
if !self.previewPayloadIds.isEmpty {
try visitor.visitRepeatedInt64Field(value: self.previewPayloadIds, fieldNumber: 9)
} }
try unknownFields.traverse(visitor: &visitor) try unknownFields.traverse(visitor: &visitor)
} }
static func ==(lhs: Sharing_Nearby_IntroductionFrame, rhs: Sharing_Nearby_IntroductionFrame) -> Bool { static func ==(lhs: Sharing_Nearby_IntroductionFrame, rhs: Sharing_Nearby_IntroductionFrame) -> Bool {
if lhs.fileMetadata != rhs.fileMetadata {return false} if lhs._storage !== rhs._storage {
if lhs.textMetadata != rhs.textMetadata {return false} let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in
if lhs._requiredPackage != rhs._requiredPackage {return false} let _storage = _args.0
if lhs.wifiCredentialsMetadata != rhs.wifiCredentialsMetadata {return false} let rhs_storage = _args.1
if lhs.appMetadata != rhs.appMetadata {return false} if _storage._fileMetadata != rhs_storage._fileMetadata {return false}
if lhs._startTransfer != rhs._startTransfer {return false} if _storage._textMetadata != rhs_storage._textMetadata {return false}
if lhs.streamMetadata != rhs.streamMetadata {return false} if _storage._requiredPackage != rhs_storage._requiredPackage {return false}
if lhs._useCase != rhs._useCase {return false} if _storage._wifiCredentialsMetadata != rhs_storage._wifiCredentialsMetadata {return false}
if lhs.previewPayloadIds != rhs.previewPayloadIds {return false} if _storage._appMetadata != rhs_storage._appMetadata {return false}
if _storage._startTransfer != rhs_storage._startTransfer {return false}
if _storage._streamMetadata != rhs_storage._streamMetadata {return false}
if _storage._useCase != rhs_storage._useCase {return false}
if _storage._previewPayloadIds != rhs_storage._previewPayloadIds {return false}
if _storage._mirroredNotificationMetadata != rhs_storage._mirroredNotificationMetadata {return false}
if _storage._pairingMetadata != rhs_storage._pairingMetadata {return false}
return true
}
if !storagesAreEqual {return false}
}
if lhs.unknownFields != rhs.unknownFields {return false} if lhs.unknownFields != rhs.unknownFields {return false}
return true return true
} }
@@ -96,8 +96,8 @@ message ConnectionRequestFrame {
} }
// LINT.ThenChange(//depot/google3/third_party/nearby/proto/connections_enums.proto) // LINT.ThenChange(//depot/google3/third_party/nearby/proto/connections_enums.proto)
optional string endpoint_id = 1; optional bytes endpoint_id = 1;
optional string endpoint_name = 2; optional bytes endpoint_name = 2;
optional bytes handshake_data = 3; optional bytes handshake_data = 3;
// A random number generated for each outgoing connection that is presently // A random number generated for each outgoing connection that is presently
// used to act as a tiebreaker when 2 devices connect to each other // used to act as a tiebreaker when 2 devices connect to each other
@@ -127,6 +127,32 @@ message WifiCredentialsMetadata {
optional int64 id = 5; optional int64 id = 5;
} }
// NEXT_ID=10
message MirroredNotificationMetadata {
optional int32 version = 1 [default = 1];
optional string notification_key = 2;
optional string package_name = 3;
optional string app_name = 4;
optional string title = 5;
optional string body = 6;
optional int64 posted_at_epoch_ms = 7;
reserved 8, 9;
}
// NEXT_ID=5
message PairingMetadata {
enum UseCase {
UNKNOWN = 0;
NOTIFICATION_SYNC = 1;
CLIPBOARD_SYNC = 2;
}
optional int32 version = 1 [default = 1];
optional UseCase use_case = 2 [default = UNKNOWN];
optional bool setup_request = 3;
optional bytes setup_pin_hash = 4;
}
// NEXT_ID=8 // NEXT_ID=8
message AppMetadata { message AppMetadata {
// The app name. This will be sent in introduction. // The app name. This will be sent in introduction.
@@ -212,7 +238,7 @@ message V1Frame {
// An introduction packet sent by the sending side. Contains a list of files // An introduction packet sent by the sending side. Contains a list of files
// they'd like to share. // they'd like to share.
// NEXT_ID=10 // NEXT_ID=1003
message IntroductionFrame { message IntroductionFrame {
enum SharingUseCase { enum SharingUseCase {
UNKNOWN = 0; UNKNOWN = 0;
@@ -230,6 +256,8 @@ message IntroductionFrame {
repeated StreamMetadata stream_metadata = 7; repeated StreamMetadata stream_metadata = 7;
optional SharingUseCase use_case = 8; optional SharingUseCase use_case = 8;
repeated int64 preview_payload_ids = 9; repeated int64 preview_payload_ids = 9;
optional MirroredNotificationMetadata mirrored_notification_metadata = 1001;
optional PairingMetadata pairing_metadata = 1002;
} }
// A progress update packet sent by the sending side. Contains transfer progress // A progress update packet sent by the sending side. Contains transfer progress
+88
View File
@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import Security
/// Contains information about the app group used to share data between main app and extensions /// Contains information about the app group used to share data between main app and extensions
struct AppGroup { struct AppGroup {
@@ -19,3 +20,90 @@ struct AppGroup {
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupName) return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupName)
} }
} }
struct KeychainStore {
private static let service = "com.leonboettger.quickdrop.securestorage"
// Must match the shared keychain access group declared in all Apple entitlements.
private static let sharedAccessGroup = "92SDAWLN76.com.leonboettger.neardrop"
static func loadData(account: String) throws -> Data? {
var query = baseQuery(account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
return result as? Data
case errSecItemNotFound:
return nil
default:
throw KeychainStoreError.unexpectedStatus(status)
}
}
static func saveData(_ data: Data, account: String) throws {
var addQuery = baseQuery(account: account)
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus == errSecSuccess {
return
}
if addStatus != errSecDuplicateItem {
throw KeychainStoreError.unexpectedStatus(addStatus)
}
let attributesToUpdate: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let updateStatus = SecItemUpdate(baseQuery(account: account) as CFDictionary, attributesToUpdate as CFDictionary)
guard updateStatus == errSecSuccess else {
throw KeychainStoreError.unexpectedStatus(updateStatus)
}
}
static func deleteItem(account: String) throws {
let status = SecItemDelete(baseQuery(account: account) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainStoreError.unexpectedStatus(status)
}
}
private static func baseQuery(account: String) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
]
query[kSecAttrAccessGroup as String] = sharedAccessGroup
return query
}
}
enum KeychainStoreError: LocalizedError {
case unexpectedStatus(OSStatus)
var errorDescription: String? {
switch self {
case let .unexpectedStatus(status):
if let message = SecCopyErrorMessageString(status, nil) as String? {
return "Keychain error \(status): \(message)"
}
return "Keychain error \(status)"
}
}
}
-60
View File
@@ -1,60 +0,0 @@
//
// EXIFUtils.swift
// QuickDrop
//
// Created by Leon Böttger on 10.11.25.
//
import Foundation
import ImageIO
struct EXIFUtils {
/// Applies EXIF timestamps to a given file or directory.
static func applyEXIFTimestamps(at url: URL) {
let fm = FileManager.default
var isDir: ObjCBool = false
guard fm.fileExists(atPath: url.path, isDirectory: &isDir) else { return }
if isDir.boolValue {
// It's a directory — enumerate files
if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey]) {
for case let fileURL as URL in enumerator {
guard (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true else { continue }
applyEXIFTimestamp(to: fileURL)
}
}
} else {
// It's a single file
applyEXIFTimestamp(to: url)
}
}
/// Applies EXIF timestamp (if available) to a single file
private static func applyEXIFTimestamp(to fileURL: URL) {
guard let exifDate = exifOriginalDate(from: fileURL) else {
return
}
// Set the "creation date"
try? FileManager.default.setAttributes([.creationDate: exifDate], ofItemAtPath: fileURL.path)
}
/// Returns the EXIF original date of an image file, adjusted to the current time zone.
private static func exifOriginalDate(from url: URL) -> Date? {
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any],
let dateString = exif[kCGImagePropertyExifDateTimeOriginal] as? String
else {
return nil
}
let formatter = DateFormatter()
formatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
formatter.timeZone = TimeZone.current // Use the current device time zone
return formatter.date(from: dateString)
}
}
+8 -15
View File
@@ -5,8 +5,9 @@
// Created by Leon Böttger on 04.04.25. // Created by Leon Böttger on 04.04.25.
// //
import Foundation
import CryptoKit import CryptoKit
import Foundation
import LUI
extension Data { extension Data {
func urlSafeBase64EncodedString() -> String { func urlSafeBase64EncodedString() -> String {
@@ -30,17 +31,6 @@ extension Data {
return data return data
} }
func suffixOfAtMost(numBytes: Int) -> Data {
if count <= numBytes {
return self
}
return subdata(in: count-numBytes..<count)
}
static func dataFromUrlSafeBase64(_ str: String) -> Data? { static func dataFromUrlSafeBase64(_ str: String) -> Data? {
var regularB64 = String(str.map { var regularB64 = String(str.map {
if $0 == "_" { if $0 == "_" {
@@ -111,11 +101,14 @@ extension String {
public func fullVersion() -> Bool { public func fullVersion() -> Bool {
// Enable full functionality if app distributed directly #if GITHUB
return Settings.shared.gotPlus || DistributionDetector.isDirectDistributionEnabled return true
#else
return IAPManager.sharedInstance.plusVersionState
#endif
} }
public func isFileTransferRestricted() -> Bool { public func isFileTransferRestricted() -> Bool {
(!fullVersion() && Settings.shared.incomingTransmissionCount > 1) (!fullVersion() && Settings.sharedInstance.incomingTransmissionCount > 1)
} }
+35 -4
View File
@@ -10,6 +10,7 @@ import Foundation
import SwiftECC import SwiftECC
import Security import Security
import CryptoKit import CryptoKit
import LUI
class IdentityManager { class IdentityManager {
@@ -51,16 +52,20 @@ class IdentityManager {
let domain = Domain.instance(curve: .EC256r1) let domain = Domain.instance(curve: .EC256r1)
let (pubKey, privKey) = domain.makeKeyPair() let (pubKey, privKey) = domain.makeKeyPair()
AppGroup.appGroupUD.set(Data(privKey.s.asSignedBytes()), forKey: signingKeyTag) let privateKeyData = Data(privKey.s.asSignedBytes())
do {
try KeychainStore.saveData(privateKeyData, account: signingKeyTag)
AppGroup.appGroupUD.removeObject(forKey: signingKeyTag)
} catch {
log("[IdentityManager] Failed to store private key in keychain: \(error.localizedDescription)")
}
return (privKey, pubKey) return (privKey, pubKey)
} }
private func loadKeys() -> (ECPrivateKey, ECPublicKey)? { private func loadKeys() -> (ECPrivateKey, ECPublicKey)? {
let keyData = AppGroup.appGroupUD.data(forKey: signingKeyTag) guard let keyData = loadStoredKeyData() else { return nil }
guard let keyData = keyData else { return nil }
let domain = Domain.instance(curve: .EC256r1) let domain = Domain.instance(curve: .EC256r1)
guard let privateKey = try? ECPrivateKey(domain: domain, s: .init(signed: Array(keyData))) else { guard let privateKey = try? ECPrivateKey(domain: domain, s: .init(signed: Array(keyData))) else {
@@ -79,6 +84,32 @@ class IdentityManager {
return (privateKey, publicKey) return (privateKey, publicKey)
} }
private func loadStoredKeyData() -> Data? {
do {
if let keyData = try KeychainStore.loadData(account: signingKeyTag) {
AppGroup.appGroupUD.removeObject(forKey: signingKeyTag)
return keyData
}
} catch {
log("[IdentityManager] Failed to load private key from keychain: \(error.localizedDescription)")
}
guard let legacyKeyData = AppGroup.appGroupUD.data(forKey: signingKeyTag) else {
return nil
}
do {
try KeychainStore.saveData(legacyKeyData, account: signingKeyTag)
AppGroup.appGroupUD.removeObject(forKey: signingKeyTag)
log("[IdentityManager] Migrated private key from shared defaults to keychain.")
} catch {
log("[IdentityManager] Failed to migrate private key to keychain: \(error.localizedDescription)")
}
return legacyKeyData
}
} }
-15
View File
@@ -1,15 +0,0 @@
//
// Localized.swift
// QuickDrop
//
// Created by Leon Böttger on 10.03.25.
//
import Foundation
public extension String {
func localized() -> String {
let localizedString = NSLocalizedString(self, comment: "")
return localizedString
}
}
-126
View File
@@ -1,126 +0,0 @@
//
// Logger.swift
// QuickDrop
//
// Created by Leon Böttger on 26.02.25.
//
import Foundation
import os
fileprivate let loggerQueue = DispatchQueue(label: "loggerQueue", qos: .utility)
/// Manages logging functionality
final class LogManager {
/// The URL of the log file
let logFileURL: URL?
/// Shared instance
public static let sharedInstance = LogManager()
@Published var loggingEnabled = true
/// Private initializer
private init() {
// Use the App Group container
let directory = AppGroup.appGroupDirectory
// Create file name from date
let formatter = DateFormatter()
formatter.dateFormat = "dd-MM-yyyy"
let dateString = formatter.string(from: Date())
let fileName = "\(dateString).log"
// Full path in app group container
self.logFileURL = directory?.appendingPathComponent(fileName)
}
/// Writes a specified string to the log file.
func writeToFile(string: String) {
guard let logFileURL = logFileURL else { return }
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
let timestamp = formatter.string(from: Date())
guard let data = (timestamp + " - " + string + "\n").data(using: .utf8) else { return }
loggerQueue.async {
let fileManager = FileManager.default
if !fileManager.fileExists(atPath: logFileURL.path) {
// Clean up older logs
self.deleteLogs()
// Create file atomically
try? data.write(to: logFileURL, options: .atomicWrite)
return
}
// Atomic append with file locking using FileHandle
do {
let fileHandle = try FileHandle(forWritingTo: logFileURL)
defer { try? fileHandle.close() }
// Locking is handled by the serial dispatch queue
try fileHandle.seekToEnd()
try fileHandle.write(contentsOf: data)
} catch {
print("[Logger] Failed to write to log file: \(error.localizedDescription)")
}
}
}
/// Gets the contents of the log file.
func getLogString() -> String {
guard let logFileURL = logFileURL else { return "" }
do {
return try String(contentsOf: logFileURL, encoding: .utf8)
} catch {
log("[LUI] Error reading file: \(error.localizedDescription)")
return ""
}
}
/// Deletes all log files in the shared container.
func deleteLogs() {
log("[LUI] Deleting all logs...")
guard let directory = AppGroup.appGroupDirectory else { return }
let fileManager = FileManager.default
guard let allFiles = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else {
return
}
let logFiles = allFiles.filter { $0.pathExtension == "log" }
for file in logFiles {
do {
log("[LUI] Removing \(file.lastPathComponent)")
try fileManager.removeItem(at: file)
} catch {
print("[Logger] Failed to delete file: \(error.localizedDescription)")
}
}
}
}
/// Logs a specified string
public func log(_ text: String) {
let logger = LogManager.sharedInstance
if logger.loggingEnabled {
loggerQueue.async {
print(text)
#if !os(tvOS)
let isAppExtension = Bundle.main.bundlePath.hasSuffix(".appex")
let prefix = isAppExtension ? "[EXT]" : "[APP]"
logger.writeToFile(string: "\(prefix) \(text)")
#endif
}
}
}
+54
View File
@@ -0,0 +1,54 @@
//
// PairingToken.swift
// QuickDrop
//
// Created by Leon Böttger on 2026-03-15.
//
import Foundation
import QRCode
import SwiftUI
import LUI
enum PairingToken {
static func generate() -> String {
Data.randomData(length: 16).urlSafeBase64EncodedString()
}
static func receiverFingerprintHex() -> String? {
guard let keyId = IdentityManager.shared.getPublicKey()?.toGenericPublicKey().id() else {
return nil
}
return keyId.hex.lowercased()
}
static func qrPayload(token: String, receiverFingerprint: String, useCase: PairingUseCase) -> String {
"quickdrop://pair?token=\(token)&usecase=\(useCase.rawValue)&receiver=\(receiverFingerprint)"
}
static func makeQrImage(
token: String,
receiverFingerprint: String,
useCase: PairingUseCase,
foregroundColor: CGColor = CGColor(gray: 0, alpha: 1),
backgroundColor: CGColor = CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
) -> Image? {
let payload = qrPayload(token: token, receiverFingerprint: receiverFingerprint, useCase: useCase)
do {
let qrCodeImage = try QRCode.build
.text(payload)
.foregroundColor(foregroundColor)
.backgroundColor(backgroundColor)
.quietZonePixelCount(3)
.onPixels.shape(.circle())
.eye.shape(.squircle())
.errorCorrection(.low)
.generate
.image(dimension: 1000)
return Image(decorative: qrCodeImage, scale: 1.0, orientation: .up)
} catch {
log("[PairingToken] QR code generation failed: \(error)")
return nil
}
}
}
+1
View File
@@ -13,6 +13,7 @@ import IOKit.pwr_mgt
#elseif os(iOS) #elseif os(iOS)
import UIKit import UIKit
#endif #endif
import LUI
class SleepManager { class SleepManager {
public static let shared = SleepManager() public static let shared = SleepManager()
+180 -18
View File
@@ -5,17 +5,20 @@
// Created by Leon Böttger on 22.08.25. // Created by Leon Böttger on 22.08.25.
// //
import Foundation import Foundation
import CryptoKit
class TrustStore: ObservableObject { class TrustStore: ObservableObject {
static let shared = TrustStore() static let shared = TrustStore()
static let trustedCertificatesDidChange = Notification.Name("TrustStore.trustedCertificatesDidChange")
@Published private(set) var trustedCertificates: [String: TrustedCertificate] = [:] @Published private(set) var trustedCertificates: [String: TrustedCertificate] = [:]
private let trustedKeysKey = "com.leonboettger.quickdrop.identity.trustedKeys" private let trustedKeysKey = "com.leonboettger.quickdrop.identity.trustedKeys"
private let pendingPairingQueue = DispatchQueue(label: "TrustStore.pairingPending")
private var pendingPairingTrust: [String: PendingPairingTrust] = [:]
private let pendingPairingTTL: TimeInterval = 5 * 60
private init() { private init() {
loadTrustedCertificates() loadTrustedCertificates()
@@ -25,29 +28,46 @@ class TrustStore: ObservableObject {
// MARK: - Persistence // MARK: - Persistence
private func loadTrustedCertificates() { private func loadTrustedCertificates() {
guard let dict = AppGroup.appGroupUD.dictionary(forKey: trustedKeysKey) as? [String: Data] else { do {
if let data = try KeychainStore.loadData(account: trustedKeysKey) {
trustedCertificates = try JSONDecoder().decode([String: TrustedCertificate].self, from: data)
AppGroup.appGroupUD.removeObject(forKey: trustedKeysKey)
return
}
} catch {
print("[TrustStore] Failed to load trusted certificates from keychain: \(error.localizedDescription)")
}
guard let dict = legacyTrustedCertificates() else {
trustedCertificates = [:] trustedCertificates = [:]
return return
} }
var decoded: [String: TrustedCertificate] = [:] trustedCertificates = dict
for (key, data) in dict {
if let cert = try? JSONDecoder().decode(TrustedCertificate.self, from: data) { do {
decoded[key] = cert try persistTrustedCertificates()
} AppGroup.appGroupUD.removeObject(forKey: trustedKeysKey)
print("[TrustStore] Migrated trusted certificates from shared defaults to keychain.")
} catch {
print("[TrustStore] Failed to migrate trusted certificates to keychain: \(error.localizedDescription)")
} }
trustedCertificates = decoded
} }
private func saveTrustedCertificates() { private func saveTrustedCertificates() {
var dict: [String: Data] = [:] do {
for (key, cert) in trustedCertificates { try persistTrustedCertificates()
if let data = try? JSONEncoder().encode(cert) { AppGroup.appGroupUD.removeObject(forKey: trustedKeysKey)
dict[key] = data notifyTrustedCertificatesDidChange()
} } catch {
print("[TrustStore] Failed to save trusted certificates to keychain: \(error.localizedDescription)")
} }
AppGroup.appGroupUD.set(dict, forKey: trustedKeysKey) }
private func notifyTrustedCertificatesDidChange() {
NotificationCenter.default.post(name: Self.trustedCertificatesDidChange, object: self)
} }
@@ -58,30 +78,172 @@ class TrustStore: ObservableObject {
} }
func addTrusted(certificate: Sharing_Nearby_PublicCertificate, device: RemoteDeviceInfo) { func addTrusted(
certificate: Sharing_Nearby_PublicCertificate,
device: RemoteDeviceInfo,
grantedUseCases: Set<PairingUseCase> = []
) {
guard let publicKeyData = try? certificate.serializedData() else { return } guard let publicKeyData = try? certificate.serializedData() else { return }
let fingerprint = certificate.secretID.hex
let existingGrants = trustedCertificates[fingerprint]?.grantedUseCases ?? []
trustedCertificates[certificate.secretID.hex] = TrustedCertificate( trustedCertificates[fingerprint] = TrustedCertificate(
device: device, device: device,
creationDate: Date(), creationDate: Date(),
certificateData: publicKeyData certificateData: publicKeyData,
grantedUseCases: existingGrants.union(grantedUseCases)
) )
saveTrustedCertificates() saveTrustedCertificates()
} }
func grantUseCase(_ useCase: PairingUseCase, for secretIdHex: String) {
let normalized = secretIdHex.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard let existing = trustedCertificates[normalized] else { return }
trustedCertificates[normalized] = existing.withGrantedUseCase(useCase)
saveTrustedCertificates()
}
func hasUseCaseGrant(_ useCase: PairingUseCase, for secretIdHex: String?) -> Bool {
guard let normalized = secretIdHex?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased(),
!normalized.isEmpty else {
return false
}
return trustedCertificates[normalized]?.grantedUseCases.contains(useCase) == true
}
func removeTrusted(secretIdHex: String) { func removeTrusted(secretIdHex: String) {
trustedCertificates.removeValue(forKey: secretIdHex) trustedCertificates.removeValue(forKey: secretIdHex)
saveTrustedCertificates() saveTrustedCertificates()
} }
// MARK: - Pending Pairing Trust
func registerPendingPairingTrust(secretIdHex: String, pinCode: String, useCase: PairingUseCase) {
let normalized = secretIdHex.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let trimmedPin = pinCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty, !trimmedPin.isEmpty else { return }
let pinHash = Self.pinHash(for: trimmedPin)
pendingPairingQueue.sync {
pendingPairingTrust[normalized] = PendingPairingTrust(
useCase: useCase,
pinHash: pinHash,
createdAt: Date()
)
}
}
func confirmPendingPairingTrust(secretIdHex: String, useCase: PairingUseCase, pinHash: Data) -> Bool {
let normalized = secretIdHex.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty, !pinHash.isEmpty else { return false }
let now = Date()
return pendingPairingQueue.sync {
guard let pending = pendingPairingTrust[normalized] else { return false }
pendingPairingTrust.removeValue(forKey: normalized)
let expired = now.timeIntervalSince(pending.createdAt) > pendingPairingTTL
if expired {
return false
}
guard pending.useCase == useCase else {
return false
}
return pending.pinHash == pinHash
}
}
func clearPendingPairingTrust(secretIdHex: String) {
let normalized = secretIdHex.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty else { return }
_ = pendingPairingQueue.sync {
pendingPairingTrust.removeValue(forKey: normalized)
}
}
struct TrustedCertificate: Codable, Equatable { struct TrustedCertificate: Codable, Equatable {
// warning: do not rename for persistence // warning: do not rename for persistence
let device: RemoteDeviceInfo let device: RemoteDeviceInfo
let creationDate: Date let creationDate: Date
let certificateData: Data let certificateData: Data
let grantedUseCases: Set<PairingUseCase>
init(
device: RemoteDeviceInfo,
creationDate: Date,
certificateData: Data,
grantedUseCases: Set<PairingUseCase> = []
) {
self.device = device
self.creationDate = creationDate
self.certificateData = certificateData
self.grantedUseCases = grantedUseCases
}
private enum CodingKeys: String, CodingKey {
case device
case creationDate
case certificateData
case grantedUseCases
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
device = try container.decode(RemoteDeviceInfo.self, forKey: .device)
creationDate = try container.decode(Date.self, forKey: .creationDate)
certificateData = try container.decode(Data.self, forKey: .certificateData)
grantedUseCases = try container.decodeIfPresent(Set<PairingUseCase>.self, forKey: .grantedUseCases) ?? []
}
func withGrantedUseCase(_ useCase: PairingUseCase) -> TrustedCertificate {
TrustedCertificate(
device: device,
creationDate: creationDate,
certificateData: certificateData,
grantedUseCases: grantedUseCases.union([useCase])
)
}
}
struct PendingPairingTrust: Equatable {
let useCase: PairingUseCase
let pinHash: Data
let createdAt: Date
}
private func persistTrustedCertificates() throws {
let data = try JSONEncoder().encode(trustedCertificates)
try KeychainStore.saveData(data, account: trustedKeysKey)
}
private func legacyTrustedCertificates() -> [String: TrustedCertificate]? {
guard let dict = AppGroup.appGroupUD.dictionary(forKey: trustedKeysKey) as? [String: Data] else {
return nil
}
var decoded: [String: TrustedCertificate] = [:]
for (key, data) in dict {
if let cert = try? JSONDecoder().decode(TrustedCertificate.self, from: data) {
decoded[key] = cert
}
}
return decoded
}
private static func pinHash(for pinCode: String) -> Data {
let data = Data(pinCode.utf8)
let digest = SHA256.hash(data: data)
return Data(digest)
} }
} }
+22 -42
View File
@@ -6,63 +6,43 @@
// //
import Foundation import Foundation
import ZIPFoundation
class Zip { final class Zip {
static func createAtTemporaryDirectory(zipFilename: String, zipExtension: String = "zip", fromDirectory directoryURL: URL) throws -> URL { static func createAtTemporaryDirectory(
zipFilename: String,
zipExtension: String = "zip",
fromDirectory directoryURL: URL
) throws -> URL {
let finalURL = FileManager.default.temporaryDirectory guard directoryURL.isDirectory else { throw CreateZipError.urlNotADirectory(directoryURL) }
let zipFinalURL = FileManager.default.temporaryDirectory
.appendingPathComponent(zipFilename) .appendingPathComponent(zipFilename)
.appendingPathExtension(zipExtension) .appendingPathExtension(zipExtension)
return try createAt( if FileManager.default.fileExists(atPath: zipFinalURL.path) {
zipFinalURL: finalURL, try FileManager.default.removeItem(at: zipFinalURL)
fromDirectory: directoryURL }
try FileManager.default.zipItem(
at: directoryURL,
to: zipFinalURL,
shouldKeepParent: true,
compressionMethod: .deflate
) )
}
static func createAt(zipFinalURL: URL, fromDirectory directoryURL: URL) throws -> URL {
guard directoryURL.isDirectory else {
throw CreateZipError.urlNotADirectory(directoryURL)
}
var fileManagerError: Swift.Error?
var coordinatorError: NSError?
let coordinator = NSFileCoordinator()
coordinator.coordinate(readingItemAt: directoryURL, options: .forUploading, error: &coordinatorError) { zipCreatedURL in
do {
// clean if file exists
if FileManager.default.fileExists(atPath: zipFinalURL.path) {
try FileManager.default.removeItem(at: zipFinalURL)
}
try FileManager.default.moveItem(at: zipCreatedURL, to: zipFinalURL)
} catch {
fileManagerError = error
}
}
if let error = coordinatorError ?? fileManagerError {
throw CreateZipError.failedToCreateZIP(error)
}
return zipFinalURL return zipFinalURL
} }
enum CreateZipError: Error {
enum CreateZipError: Swift.Error {
case urlNotADirectory(URL) case urlNotADirectory(URL)
case failedToCreateZIP(Swift.Error)
} }
} }
private extension URL {
extension URL {
var isDirectory: Bool { var isDirectory: Bool {
(try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
} }
} }
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.leonboettger.neardrop</string>
<key>CFBundleURLSchemes</key>
<array>
<string>quickdrop</string>
</array>
</dict>
</array>
<key>UIDesignRequiresCompatibility</key>
<true/>
</dict>
</plist>
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
{ {
"originHash" : "270e8bfd245fae589b12faf08b86042cbbe7c809d59c5b0b8b735c7fb3a8d4bd", "originHash" : "85c9b997510f22b553d79f68fb6d51d149ac9324df643dbf1475023b39da33b3",
"pins" : [ "pins" : [
{ {
"identity" : "applewatchscreensize", "identity" : "applewatchscreensize",
@@ -33,8 +33,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/leif-ibsen/BigInt", "location" : "https://github.com/leif-ibsen/BigInt",
"state" : { "state" : {
"revision" : "8c6f93aa37504b7b1ba3954335b5548a19fbbd82", "revision" : "7c7b1be2f6714ce03833879b0aab4b20293c0207",
"version" : "1.22.0" "version" : "1.24.0"
} }
}, },
{ {
@@ -42,8 +42,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/devicekit/DeviceKit", "location" : "https://github.com/devicekit/DeviceKit",
"state" : { "state" : {
"revision" : "581df61650bc457ec00373a592a84be3e7468eb1", "revision" : "56b997e8a61707218f9af09f32b2a1d1806fd792",
"version" : "5.7.0" "version" : "5.8.0"
} }
}, },
{ {
@@ -52,7 +52,7 @@
"location" : "https://github.com/leonboe1/LUI.git", "location" : "https://github.com/leonboe1/LUI.git",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "0b862decf853e1f542eeab1f8d548f8ff629deca" "revision" : "44013e8ef77d8e0d18e13f449cfe3775f1abb31a"
} }
}, },
{ {
@@ -87,8 +87,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git", "location" : "https://github.com/apple/swift-protobuf.git",
"state" : { "state" : {
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d", "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8",
"version" : "1.33.3" "version" : "1.38.1"
} }
}, },
{ {
@@ -124,7 +124,16 @@
"location" : "https://github.com/siteline/swiftui-introspect", "location" : "https://github.com/siteline/swiftui-introspect",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "11d9782bea80656e8a65a643d6ac6396e4a8662e" "revision" : "aee624c87acee41e1a421f7825b468d38bbfbd6c"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
"version" : "0.9.20"
} }
} }
], ],
@@ -40,6 +40,24 @@
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
allowLocationSimulation = "YES"> allowLocationSimulation = "YES">
<PostActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Register dev QuickDrop for Finder Share"
scriptText = "&quot;${SRCROOT}/scripts/use-dev-share-extension.sh&quot;&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "69DA9A0D29E0BF5100A442DA"
BuildableName = "QuickDrop.app"
BlueprintName = "QuickDrop"
ReferencedContainer = "container:QuickDrop.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PostActions>
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
@@ -50,6 +68,9 @@
ReferencedContainer = "container:QuickDrop.xcodeproj"> ReferencedContainer = "container:QuickDrop.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../ConfigSynced.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EB3B9EF02EF7150400D6704E"
BuildableName = "QuickDropGitHub.app"
BlueprintName = "QuickDropGitHub"
ReferencedContainer = "container:QuickDrop.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EB3B9EF02EF7150400D6704E"
BuildableName = "QuickDropGitHub.app"
BlueprintName = "QuickDropGitHub"
ReferencedContainer = "container:QuickDrop.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EB3B9EF02EF7150400D6704E"
BuildableName = "QuickDropGitHub.app"
BlueprintName = "QuickDropGitHub"
ReferencedContainer = "container:QuickDrop.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EB3B9F4A2EF7151900D6704E"
BuildableName = "QuickDropShareExtensionGitHub.appex"
BlueprintName = "QuickDropShareExtensionGitHub"
ReferencedContainer = "container:QuickDrop.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EB3B9F4A2EF7151900D6704E"
BuildableName = "QuickDropShareExtensionGitHub.appex"
BlueprintName = "QuickDropShareExtensionGitHub"
ReferencedContainer = "container:QuickDrop.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "32A98F34E7D10C21DC2EEA19"
BuildableName = "QuickDropTests.xctest"
BlueprintName = "QuickDropTests"
ReferencedContainer = "container:QuickDrop.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -32,8 +32,8 @@
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
@@ -50,6 +50,9 @@
ReferencedContainer = "container:QuickDrop.xcodeproj"> ReferencedContainer = "container:QuickDrop.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../ConfigSynced.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
+214 -75
View File
@@ -7,11 +7,13 @@
import AudioToolbox import AudioToolbox
import BezelNotification import BezelNotification
import Combine
import Cocoa import Cocoa
import Network import Network
import StoreKit import StoreKit
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
import LUI
@main @main
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSWindowDelegate { class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSWindowDelegate {
@@ -25,23 +27,66 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
private var sheetAttachedWindow: NSWindow? = nil private var sheetAttachedWindow: NSWindow? = nil
private var errorAlertHandler = ErrorAlertHandler.shared private var errorAlertHandler = ErrorAlertHandler.shared
private var iapManager: IAPManager?
private var receiveModel: ReceiveModel? private var receiveModel: ReceiveModel?
private var bluetoothAuthorizationObserver: AnyCancellable?
private let welcomeNavigationState = WelcomeScreenNavigationState()
var showsFirewallAlert = false var showsFirewallAlert = false
var visibleItem: NSMenuItem? = nil var visibleItem: NSMenuItem? = nil
private var didShowAndroidAirDropModeAlert = false
// MARK: NSApplicationDelegate functions // MARK: NSApplicationDelegate functions
func applicationDidFinishLaunching(_: Notification) { func applicationDidFinishLaunching(_: Notification) {
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
LUIInit(configuration: configuration)
return
}
UNUserNotificationCenter.current().delegate = self
// app did not lauch before
if !Settings.sharedInstance.appLaunchedBefore {
runAfter(seconds: 0.1) { log("[AppDelegate] Opening Welcome Screen") }
DispatchQueue.main.async {
// open welcome screen in next cycle (await LUIInit)
self.openMainWindow()
}
// user installed the app after the IAP was implemented, set the user as eligible for IAP
Settings.sharedInstance.isEligibleForIap = true
LUIInit(configuration: configuration)
} else {
// app launched before
#if GITHUB
runAfter(seconds: 0.1) { log("[AppDelegate] Downloaded from GitHub.") }
#else
// user installed the app before the IAP was implemented, grant the plus version
if !Settings.sharedInstance.isEligibleForIap {
runAfter(seconds: 0.1) { log("[AppDelegate] Granting QuickDrop+ for old user") }
UserDefaults.standard.set(true, forKey: Settings.UserDefaultsKeys.plusVersionLegacy.rawValue)
}
#endif
BezelNotification.show(messageText: "ReadyToReceive".localized(), icon: .receiveIcon)
// only start receiving immediately for existing user, for new users we want to delay the permission prompt
LUIInit(configuration: configuration)
startReceiving()
}
// MARK: Menu Bar
let menu = NSMenu() let menu = NSMenu()
let visibleItem = NSMenuItem( let visibleItem = NSMenuItem(
title: getDefaultVisibleLabel(), title: getDefaultVisibleLabel(),
action: nil, action: #selector(visibleMenuItemSelected),
keyEquivalent: "" keyEquivalent: ""
) )
visibleItem.target = self
self.visibleItem = visibleItem self.visibleItem = visibleItem
menu.addItem(visibleItem) menu.addItem(visibleItem)
@@ -53,7 +98,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem.separator())
let userManualItem = NSMenuItem(title: "UserManual".localized(), action: #selector(openWelcomeScreen), keyEquivalent: "") let userManualItem = NSMenuItem(title: "UserManual".localized(), action: #selector(openMainWindow), keyEquivalent: "")
userManualItem.image = NSImage(systemSymbolName: "gear", accessibilityDescription: nil) userManualItem.image = NSImage(systemSymbolName: "gear", accessibilityDescription: nil)
menu.addItem(userManualItem) menu.addItem(userManualItem)
@@ -66,45 +111,41 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
statusItem?.menu = menu statusItem?.menu = menu
statusItem?.behavior = .removalAllowed statusItem?.behavior = .removalAllowed
iapManager = IAPManager.sharedInstance
iapManager?.startObserving()
if DistributionDetector.isDirectDistributionEnabled { // MARK: Nearby Connections Callbacks
log("[AppDelegate] Downloaded from GitHub.")
}
else {
log("[AppDelegate] Downloaded from App Store.")
}
// app did not lauch before NearbyConnectionManager.shared.connectionUpdateCallback = { isConnected in
if !Settings.shared.appLaunchedBefore { if isConnected {
log("[AppDelegate] Opening Welcome Screen") self.statusItem?.button?.image = NSImage(named: "MenuBarIcon")
// open welcome screen self.visibleItem?.title = self.getDefaultVisibleLabel()
openWelcomeScreen() }
Settings.shared.appLaunchedBefore = true else {
self.statusItem?.button?.image = NSImage(named: "MenuBarIconSlash")
// user installed the app after the IAP was implemented, set the user as eligible for IAP self.visibleItem?.title = "NoNetworkConnection".localized()
Settings.shared.isEligibleForIap = true
} else {
// app launched before
// user installed the app before the IAP was implemented, grant the plus version
if !Settings.shared.isEligibleForIap {
log("[AppDelegate] Granting QuickDrop+ for old user")
Settings.shared.gotPlus = true
} else {
if !fullVersion() {
log("[AppDelegate] New user - QuickDrop+ not available")
}
else {
log("[AppDelegate] QuickDrop+ available")
}
} }
BezelNotification.show(messageText: "ReadyToReceive".localized(), icon: .receiveIcon)
} }
UNUserNotificationCenter.current().delegate = self NearbyConnectionManager.shared.changedDeviceNameCallback = {
self.visibleItem?.title = self.getDefaultVisibleLabel()
}
setupAndroidAirDropModeDetection()
requestBluetoothPermissionForExistingUserIfNeeded()
// MARK: Logging
log("[AppDelegate] Application did finish launching")
log("[AppDelegate] Currently running apps: \(NSWorkspace.shared.runningApplications.map { $0.localizedName ?? "-" })")
}
func startReceiving() {
guard receiveModel == nil else {
log("[AppDelegate] startReceiving called while receiver is already active. Ignoring duplicate call.")
ClipboardSyncCoordinator.shared.start()
return
}
receiveModel = ReceiveModel(controlPlusScreen: { shouldOpen in receiveModel = ReceiveModel(controlPlusScreen: { shouldOpen in
if shouldOpen { if shouldOpen {
@@ -119,25 +160,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
} }
}) })
ClipboardSyncCoordinator.shared.start()
NearbyConnectionManager.shared.connectionUpdateCallback = { isConnected in
if isConnected {
self.statusItem?.button?.image = NSImage(named: "MenuBarIcon")
self.visibleItem?.title = self.getDefaultVisibleLabel()
}
else {
self.statusItem?.button?.image = NSImage(named: "MenuBarIconSlash")
self.visibleItem?.title = "NoNetworkConnection".localized()
}
}
log("[AppDelegate] Application did finish launching")
} }
func windowWillClose(_ notification: Notification) { func windowWillClose(_ notification: Notification) {
if let window = notification.object as? NSWindow, window == welcomeWindow { if let window = notification.object as? NSWindow, window == welcomeWindow {
log("Welcome window closed") log("[AppDelegate] Welcome window closed")
welcomeWindow = nil welcomeWindow = nil
NSApp.setActivationPolicy(.accessory) NSApp.setActivationPolicy(.accessory)
} }
@@ -145,36 +174,66 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool { func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
openWelcomeScreen() self.openMainWindow()
statusItem?.isVisible = true statusItem?.isVisible = true
return true return true
} }
public func userNotificationCenter(_: UNUserNotificationCenter, public func userNotificationCenter(_: UNUserNotificationCenter,
willPresent _: UNNotification, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
if ClipboardSyncNotificationPresenter.isClipboardSyncNotification(notification) {
completionHandler([.banner])
return
}
completionHandler([.sound, .banner]) completionHandler([.sound, .banner])
} }
func applicationWillTerminate(_: Notification) { func applicationWillTerminate(_: Notification) {
UNUserNotificationCenter.current().removeAllDeliveredNotifications() UNUserNotificationCenter.current().removeAllDeliveredNotifications()
iapManager?.stopObserving() ClipboardSyncCoordinator.shared.stop()
AndroidAirDropModeDetector.shared.stop()
NearbyConnectionManager.shared.stopDeviceDiscovery()
NearbyConnectionManager.shared.becomeInvisible()
} }
func application(_ application: NSApplication, open urls: [URL]) { func application(_ application: NSApplication, open urls: [URL]) {
log("[AppDelegate] application(open:): Received URLs: \(urls)")
for url in urls { for url in urls {
if url.scheme == "quickdrop", url.host == "sendLog" { if url.scheme == "quickdrop" {
sendLoggingString() // this time it runs in the main app switch url.host {
} case "sendLog":
// If we're in an extension, redirect to the main app
if Bundle.main.bundlePath.hasSuffix(".appex") {
log("[AppDelegate] sendLoggingString: in extension, redirecting to main app")
if let url = URL(string: "quickdrop://sendLog") {
NSWorkspace.shared.open(url)
}
return
}
if url.scheme == "quickdrop", url.host == "openLog", let url = LogManager.sharedInstance.logFileURL { LogExportPresenter.showUploadLogsAlert()
log("Opening log file: \(url)") case "openLog":
// open folder containing the log file if let url = LogManager.sharedInstance.logFileURL {
NSWorkspace.shared.activateFileViewerSelecting([url]) log("[AppDelegate] Opening log file: \(url)")
NSWorkspace.shared.activateFileViewerSelecting([url])
}
else {
log("[AppDelegate] No log file found to open.")
}
case "mockTransferFailure":
ErrorAlertHandler.shared.showErrorAlert(for: "Test Device", error: NearbyError.inputOutput)
case "removeData":
Settings.sharedInstance.deleteAllUserDefaults()
default:
break
}
} }
} }
} }
@@ -187,6 +246,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
// MARK: - Button Actions // MARK: - Button Actions
@objc private func visibleMenuItemSelected() {
showMainWindow(selectedTab: .settings)
}
@objc func sendClipboard() { @objc func sendClipboard() {
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
@@ -203,7 +266,26 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
} }
@objc func openWelcomeScreen() { @objc func openMainWindow() {
showMainWindow()
}
private func showMainWindow(selectedTab: Tab? = nil) {
if let selectedTab {
welcomeNavigationState.select(selectedTab)
}
defer {
if let welcomeWindow, !Settings.sharedInstance.appLaunchedBefore {
runAfter(seconds: 0.2) {
IntroductionSheetManager.sharedInstance.openIntroductionWindow(on: welcomeWindow, startReceiving: self.startReceiving) {
self.startReceiving()
Settings.sharedInstance.appLaunchedBefore = true
}
}
}
}
NSApp.setActivationPolicy(.regular) NSApp.setActivationPolicy(.regular)
@@ -220,11 +302,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
openPlusScreen: openPlusScreen, openPlusScreen: openPlusScreen,
openAppAdvertisementView: { self.openSheetView(type: .downloadAndroidApp) }, openAppAdvertisementView: { self.openSheetView(type: .downloadAndroidApp) },
openCableTransmissionView: { self.openSheetView(type: .downloadCableConnectionApp) }, openCableTransmissionView: { self.openSheetView(type: .downloadCableConnectionApp) },
checkForNetworkIssues: performDeviceToDeviceCheck checkForNetworkIssues: performDeviceToDeviceCheck,
navigationState: welcomeNavigationState
) )
welcomeWindow = NSWindow( welcomeWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 280), contentRect: NSRect(x: 0, y: 0, width: WelcomeScreen.width, height: WelcomeScreen.height),
styleMask: [.titled, .closable], styleMask: [.titled, .closable],
backing: .buffered, backing: .buffered,
defer: false defer: false
@@ -251,18 +334,22 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
if let window = plusWindow, window.isVisible { if let window = plusWindow, window.isVisible {
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
log("Plus screen already open, bringing to front") log("[AppDelegate] Plus screen already open, bringing to front")
return return
} }
log("Opening Plus screen") log("[AppDelegate] Opening Plus screen")
// Create the welcome screen SwiftUI view // Create the welcome screen SwiftUI view
let plusView = PlusView(closeView: { let plusView = GetPlusViewV4(showSheet: Binding(get: { self.plusWindow != nil }, set: { newValue in
log("Closing plus screen") if !newValue {
self.plusWindow?.close() self.plusWindow?.close()
self.plusWindow = nil self.plusWindow = nil
}) }
}), hasSheet: false)
let plusViewWidth: CGFloat = 750
let plusViewHeight: CGFloat = 750
// Create an NSWindow to host the SwiftUI view // Create an NSWindow to host the SwiftUI view
let window = NSWindow( let window = NSWindow(
@@ -291,7 +378,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
// Ensure the window is always on top // Ensure the window is always on top
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
window.level = .normal window.level = .floating
} }
@@ -333,18 +420,70 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
// MARK: - Helper Functions // MARK: - Helper Functions
private func performDeviceToDeviceCheck() { private func performDeviceToDeviceCheck() {
let scanner = DeviceToDeviceHeuristicScanner() let scanner = DeviceToDeviceHeuristicScanner.shared
scanner.scan { allowed in scanner.scan { allowed in
if allowed { if allowed {
log("✅ Device-to-device likely allowed (peer responded on LAN).") log("[AppDelegate] ✅ Device-to-device likely allowed (peer responded on LAN).")
ErrorAlertHandler.shared.closeApIsolationAlert() ErrorAlertHandler.shared.closeApIsolationAlert()
} else { } else {
log("❌ Device-to-device check failed. Informing user...") log("[AppDelegate] ❌ Device-to-device check failed. Informing user...")
ErrorAlertHandler.shared.openAlert(type: .ApIsolation) ErrorAlertHandler.shared.openAlert(type: .ApIsolation)
} }
} }
} }
private func setupAndroidAirDropModeDetection() {
log("[AppDelegate] Setting up Android AirDrop mode detection. authorization=\(BluetoothPermissionManager.shared.authorization.rawValue)")
bluetoothAuthorizationObserver = BluetoothPermissionManager.shared.$authorization
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] authorization in
guard let self else { return }
log("[AppDelegate] Bluetooth authorization changed for Android AirDrop mode detection: \(authorization.rawValue)")
if authorization == .allowedAlways {
self.startAndroidAirDropModeDetection()
}
else {
AndroidAirDropModeDetector.shared.stop()
}
}
BluetoothPermissionManager.shared.refresh()
}
private func requestBluetoothPermissionForExistingUserIfNeeded() {
let bluetoothPermission = BluetoothPermissionManager.shared
bluetoothPermission.refresh()
guard Settings.sharedInstance.appLaunchedBefore, bluetoothPermission.canRequestPermission else { return }
log("[AppDelegate] Requesting Bluetooth permission for existing user.")
bluetoothPermission.requestPermission { allowed in
log("[AppDelegate] Existing user Bluetooth permission request completed. allowed=\(allowed)")
}
}
private func startAndroidAirDropModeDetection() {
guard !didShowAndroidAirDropModeAlert else { return }
AndroidAirDropModeDetector.shared.start { [weak self] _ in
DispatchQueue.main.async {
self?.showAndroidAirDropModeAlertIfNeeded()
}
}
}
private func showAndroidAirDropModeAlertIfNeeded() {
guard !didShowAndroidAirDropModeAlert else { return }
didShowAndroidAirDropModeAlert = true
ErrorAlertHandler.shared.openAlert(type: .AndroidAirDropMode)
}
private func getDefaultVisibleLabel() -> String { private func getDefaultVisibleLabel() -> String {
let name = NearbyConnectionManager.shared.deviceInfo.name ?? "Unknown".localized() let name = NearbyConnectionManager.shared.deviceInfo.name ?? "Unknown".localized()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

@@ -12,15 +12,6 @@
{ {
"idiom" : "universal", "idiom" : "universal",
"scale" : "3x" "scale" : "3x"
},
{
"filename" : "1024-1024.png",
"idiom" : "mac",
"scale" : "1x"
},
{
"idiom" : "mac",
"scale" : "2x"
} }
], ],
"info" : { "info" : {
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "Plain.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "Smartphone.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "FileTransfer Kopie-512.png", "filename" : "iDroidIcon-iOS-Default-1024x1024@1x.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
}, },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+58
View File
@@ -0,0 +1,58 @@
//
// Configuration.swift
// QuickDrop
//
// Created by Leon Böttger on 15.08.25.
//
import LUI
import SwiftUI
let configuration = LUIConfiguration(
appName: "QuickDrop",
plusVersionInfo: GetPlusViewInformation(
lifetimeID: "plusversion",
monthlyID: "quickdrop.monthly",
yearlyID: "quickdrop.yearly",
plusViewHeader: isMac() ? "plusview_title" : nil,
description: isMac() ? "plusview_description" : "plusview_description_phone",
onBoughtPlusVersionAlertMessage: "plusview_success_description",
plusVersionFeatures: [],
settingsLabelColor: .orange,
settingsFooter: "plusview_footer_phone"
),
hasRoundedFont: false,
switchedToInAppPurchaseWithBundleVersion: AppVersionBundle(appVersion: "1.2", buildVersion: 0, legacyUserDefaultsKey: Settings.UserDefaultsKeys.plusVersionLegacy.rawValue),
copyrightInformation: licenseText,
logUploadDisclaimerMessage: "log_upload_disclaimer_message",
loggingUrl: AppGroup.appGroupDirectory,
introductionViewInformation: "QuickDropWelcomeDescription",
introductionViewImage: Image(.quickDropMockup),
usesAutomaticReviewPromptAndShareSheet: false,
usesPagedIntroduction: true,
supportsSplitView: false,
supportsLiquidGlass: true,
supportsPortraitSplitView: false,
supportsDarkModeShadow: false,
privacyPolicyURL: .privacyPolicyURL
)
extension String {
static let privacyPolicyURL = "https://leonboettger.com/quickdrop-privacy"
}
File diff suppressed because it is too large. Load diff
+27483 -5191
View File
File diff suppressed because it is too large. Load diff
-66
View File
@@ -1,66 +0,0 @@
//
// StoreChecker.swift
// LUI
//
// Created by Leon Böttger on 19.08.24.
//
import StoreKit
class IAPConverter: NSObject, SKRequestDelegate {
let switchToIAPAfterAppVersion = "1.2"
static let shared = IAPConverter()
private override init() {
super.init()
}
func restoreIAPfromPreviousPurchase(success: ((Bool) -> ())? = nil) {
Task {
do {
// Get the appTransaction.
if #available(macOS 13.0, *) {
let shared = try await AppTransaction.shared
if case .verified(let appTransaction) = shared {
let originalVersion = appTransaction.originalAppVersion
let newModelVersion = switchToIAPAfterAppVersion
log("[LUI] Original Purchased Version: \(originalVersion) | New Model Version: \(newModelVersion)")
if originalVersion.compare(newModelVersion, options: .numeric) == .orderedAscending {
grant(success: success)
return
}
}
} else {
// Fallback on earlier versions
// Grant IAP, as we cannot check the original purchase date.
DispatchQueue.main.async {
log("[LUI] Cannot check version - Plus Version Granted!")
success?(true)
}
}
}
catch {
// Handle errors.
log("[LUI] Error: \(error.localizedDescription)")
}
DispatchQueue.main.async {
log("[LUI] Plus Version Denied!")
success?(false)
}
}
}
private func grant(success: ((Bool) -> ())? = nil) {
log("[LUI] Existing User - Plus Version Granted!")
DispatchQueue.main.async {
success?(true)
}
}
}
-76
View File
@@ -1,76 +0,0 @@
//
// DistributionDetector.swift
// QuickDrop
//
// Created by Leon Böttger on 11.08.25.
//
import Security
import Foundation
class DistributionDetector {
static var isDirectDistributionEnabled: Bool {
get {
#if os(macOS)
if let cached = _directDistributionCached {
return cached
}
let result = isDirectDistribution()
self._directDistributionCached = result
return result
#else
return false
#endif
}
}
static var _directDistributionCached: Bool? = nil
#if os(macOS)
private static func isDirectDistribution() -> Bool {
// 1. Get dynamic code object for our process
var code: SecCode?
var status = SecCodeCopySelf([], &code)
guard status == errSecSuccess, let codeRef = code else {
log("[DistributionDetector] Could not get code object)")
return false
}
// 2. Convert to static code object
var staticCode: SecStaticCode?
status = SecCodeCopyStaticCode(codeRef, [], &staticCode)
guard status == errSecSuccess, let staticCodeRef = staticCode else {
log("[DistributionDetector] Could not get static code object)")
return false
}
// 3. Get signing info
var infoCF: CFDictionary?
status = SecCodeCopySigningInformation(staticCodeRef,
SecCSFlags(rawValue: kSecCSSigningInformation),
&infoCF)
guard status == errSecSuccess, let info = infoCF as? [String: Any] else {
log("[DistributionDetector] Could not get signing info")
return false
}
// 4. Look at the first certificate's common name
if let certificates = info[kSecCodeInfoCertificates as String] as? [SecCertificate],
let firstCert = certificates.first {
var commonName: CFString?
status = SecCertificateCopyCommonName(firstCert, &commonName)
if status == errSecSuccess, let commonNameStr = commonName as String? {
if commonNameStr.contains("Developer ID Application") {
return true
}
}
}
return false
}
#endif
}
-191
View File
@@ -1,191 +0,0 @@
//
// PurchaseHelper.swift
// Loop
//
// Created by Leon Böttger on 26.10.20.
// Copyright © 2020 Leon Böttger. All rights reserved.
//
import StoreKit
let iapName = "plusversion"
class IAPManager: NSObject, ObservableObject {
enum IAPManagerError: Error {
case noProductIDsFound
case noProductsFound
case paymentWasCancelled
case productRequestFailed
}
static let plusVersionID = iapName
static let sharedInstance = IAPManager()
var onReceiveProductsHandler: ((Result<[SKProduct], IAPManagerError>) -> Void)?
var onBuyProductHandler: ((Result<Bool, Error>) -> Void)?
var totalRestoredPurchases = 0
var isActive = false
private override init() {
super.init()
getProducts { _ in }
}
fileprivate func getProductIDs() -> [String]? {
return [IAPManager.plusVersionID]
}
func startObserving() {
SKPaymentQueue.default().add(self)
}
func stopObserving() {
SKPaymentQueue.default().remove(self)
}
func canMakePayments() -> Bool {
return SKPaymentQueue.canMakePayments()
}
func getProducts(withHandler productsReceiveHandler: @escaping (_ result: Result<[SKProduct], IAPManagerError>) -> Void) {
// Keep the handler (closure) that will be called when requesting for
// products on the App Store is finished.
onReceiveProductsHandler = productsReceiveHandler
// Get the product identifiers.
guard let productIDs = getProductIDs() else {
productsReceiveHandler(.failure(.noProductIDsFound))
return
}
// Initialize a product request.
let request = SKProductsRequest(productIdentifiers: Set(productIDs))
// Set self as the its delegate.
request.delegate = self
// Make the request.
request.start()
}
func buy(product: SKProduct, withHandler handler: @escaping ((_ result: Result<Bool, Error>) -> Void)) {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
// Keep the completion handler.
onBuyProductHandler = handler
}
func restorePurchases(withHandler handler: @escaping ((_ result: Result<Bool, Error>) -> Void)) {
onBuyProductHandler = handler
totalRestoredPurchases = 0
SKPaymentQueue.default().restoreCompletedTransactions()
}
}
extension IAPManager: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
log("IAP updatedTransactions")
transactions.forEach { (transaction) in
switch transaction.transactionState {
case .purchased:
log("IAP purchased")
onBuyProductHandler?(.success(true))
SKPaymentQueue.default().finishTransaction(transaction)
case .restored:
log("IAP restored")
totalRestoredPurchases += 1
onBuyProductHandler?(.success(true))
SKPaymentQueue.default().finishTransaction(transaction)
case .failed:
log("[LUI] IAP failed")
if let error = transaction.error as? SKError {
if error.code != .paymentCancelled {
onBuyProductHandler?(.failure(error))
} else {
onBuyProductHandler?(.failure(IAPManagerError.paymentWasCancelled))
}
log("[LUI] IAP Error:" + error.localizedDescription)
}
onBuyProductHandler?(.failure(transaction.error ?? IAPManagerError.productRequestFailed))
SKPaymentQueue.default().finishTransaction(transaction)
case .deferred, .purchasing: log("[LUI] other cases")
@unknown default: log("[LUI] other default cases")
}
}
}
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if self.totalRestoredPurchases == 0 {
log("[LUI] IAP: No purchases to restore!")
self.onBuyProductHandler?(.success(false))
}
}
}
func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
if let error = error as? SKError {
if error.code != .paymentCancelled {
log("[LUI] IAP Restore Error:" + error.localizedDescription)
onBuyProductHandler?(.failure(error))
} else {
onBuyProductHandler?(.failure(IAPManagerError.paymentWasCancelled))
}
}
}
}
extension IAPManager: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
// Get the available products contained in the response.
let products = response.products
// Check if there are any products available.
if products.count > 0 {
// Call the following handler passing the received products.
onReceiveProductsHandler?(.success(products))
} else {
// No products were found.
onReceiveProductsHandler?(.failure(.noProductsFound))
}
}
func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool {
return true
}
func request(_ request: SKRequest, didFailWithError error: Error) {
onReceiveProductsHandler?(.failure(.productRequestFailed))
}
func requestDidFinish(_ request: SKRequest) {
// Implement this method OPTIONALLY and add any custom logic
// you want to apply when a product request is finished.
}
}
extension IAPManager.IAPManagerError: LocalizedError {
var errorDescription: String? {
switch self {
case .noProductIDsFound: return "No In-App Purchase product identifiers were found."
case .noProductsFound: return "No In-App Purchases were found."
case .productRequestFailed: return "plusview_productRequestFailed".localized()
case .paymentWasCancelled: return "In-App Purchase process was cancelled."
}
}
}
-248
View File
@@ -1,248 +0,0 @@
//
// PlusView.swift
// QuickDrop
//
// Created by Leon Böttger on 23.02.25.
//
import SwiftUI
import StoreKit
let plusViewWidth: CGFloat = 440
let plusViewHeight: CGFloat = 200
struct PlusView: View {
@ObservedObject private var settings = Settings.shared
@ObservedObject private var iaphelper = IAPManager.sharedInstance
let closeView: () -> Void
@State private var restoring = false
@State private var showRestoreError = false
@State private var restoreError = ""
@Environment(\.colorScheme) var colorScheme
@State var products = [SKProduct]()
@State var price: String = ""
@State var warning = false
@State var buying = false
@State var errorText = ""
@State var boughtSuccessAlert = false
var body: some View {
ZStack {
Color.defaultBackground
.edgesIgnoringSafeArea(.all)
let validPrice = price != ""
VStack(spacing: 12) {
Text("plusview_title")
.font(.title)
.bold()
Text("plusview_description")
.multilineTextAlignment(.center)
.padding()
.padding(.top, -10)
.alert(isPresented: $boughtSuccessAlert, content: {
Alert(title: Text("plusview_success_title"), message: Text("plusview_success_description"), dismissButton: .default(Text("plusview_success_button"), action: {
closeView()
}))
})
HStack(spacing: 10) {
Button("plusview_restorepurchase") {
restorePurchases()
}
.alert(isPresented: $showRestoreError, content: {
Alert(title: Text("plusview_restorefailed"), message: Text(restoreError), dismissButton: .default(Text("plusview_restorefailed_proceed")))
})
.opacity(restoring ? 0 : 1)
.overlay(
ProgressView()
.scaleEffect(0.5)
.opacity(restoring ? 1 : 0)
)
let priceString = validPrice ? " (\(price))" : ""
Button("plusview_unlock_label".localized() + priceString) {
buttonAction()
}
.keyboardShortcut(.defaultAction)
.alert(isPresented: $warning, content: {
Alert(title: Text("plusview_purchasefailed"), message: Text("plusview_purchasefaileddescription".localized() + (errorText != "" ? " Error: " : "") + errorText), dismissButton: .default(Text("plusview_restorefailed_proceed")))
})
}
.opacity(buying || restoring ? 0 : 1)
.overlay(
ProgressView()
.scaleEffect(0.5)
.opacity(buying || restoring ? 1 : 0)
)
}
.onAppear {
loadProducts(shouldBuy: false)
}
.animation(.default, value: validPrice)
.padding()
}
.frame(width: plusViewWidth, height: plusViewHeight)
}
func restorePurchases() {
withAnimation {
restoring = true
}
iaphelper.restorePurchases { result in
DispatchQueue.main.async {
switch result {
case .success(let success):
if success {
log("[LUI] Restored purchases successfully")
withAnimation {
settings.gotPlus = true
}
boughtSuccessAlert = true
} else {
log("[LUI] Nothing to restore")
//nothing to restore
restoreFailed(message: "plusview_nothingtorestore".localized())
}
case .failure(let error):
log("[LUI] Error restoring purchases: \(error.localizedDescription)")
log(error.localizedDescription)
restoreFailed(message: error.localizedDescription)
}
}
}
func restoreFailed(message: String) {
#if targetEnvironment(simulator)
isPlusVersion = true
dismissView()
#else
// now, try to convert from old version
IAPConverter.shared.restoreIAPfromPreviousPurchase { success in
DispatchQueue.main.async {
if success {
withAnimation {
settings.gotPlus = true
}
boughtSuccessAlert = true
}
else {
restoreError = message
showRestoreError = true
withAnimation {
restoring = false
}
}
}
}
#endif
}
}
func buttonAction() {
if let product = products.first {
purchase(product: product)
}
else {
loadProducts(shouldBuy: true)
}
}
func purchase(product: SKProduct) {
if !iaphelper.canMakePayments() {
warning = true
} else {
withAnimation{
buying = true
}
iaphelper.buy(product: product) { (result) in
DispatchQueue.main.async {
withAnimation{
buying = false
}
switch result {
case .success(_):
withAnimation {
settings.gotPlus = true
boughtSuccessAlert = true
warning = false
}
case .failure(let error):
warning = true
errorText = error.localizedDescription
}
}
}
}
}
func loadProducts(shouldBuy: Bool) {
iaphelper.getProducts { (result) in
DispatchQueue.main.async {
switch result {
case .success(let products):
self.products = products
price = products.first?.localizedPrice ?? ""
if let product = products.first, shouldBuy {
purchase(product: product)
}
case .failure(let error):
errorText = error.localizedDescription
if shouldBuy {
warning = true
}
withAnimation {
buying = false
}
}
}
}
}
}
extension SKProduct {
var localizedPrice: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = priceLocale
if let price = formatter.string(from: price) {
return price
}
return ""
}
}
#Preview {
PlusView(closeView: {})
}
+4
View File
@@ -6,5 +6,9 @@
<array> <array>
<string>group.com.leonboettger.neardrop</string> <string>group.com.leonboettger.neardrop</string>
</array> </array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.leonboettger.neardrop</string>
</array>
</dict> </dict>
</plist> </plist>
+17 -11
View File
@@ -5,21 +5,18 @@
// Created by Leon Böttger on 09.09.25. // Created by Leon Böttger on 09.09.25.
// //
import LUI
import SwiftUI import SwiftUI
class Settings: ObservableObject { final class Settings: SharedInstance {
private init() {} private init() {}
static var shared = Settings() static var sharedInstance = Settings()
// MARK: - Published UserDefaults Backed Properties // MARK: - Published UserDefaults Backed Properties
@Published var gotPlus = UserDefaults.standard.bool(forKey: UserDefaultsKeys.plusVersion.rawValue)
{ didSet { UserDefaults.standard.set(gotPlus, forKey: UserDefaultsKeys.plusVersion.rawValue) }}
@Published var automaticallyAcceptFiles: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue) @Published var automaticallyAcceptFiles: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue)
{ didSet { UserDefaults.standard.set(automaticallyAcceptFiles, forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue) }} { didSet { UserDefaults.standard.set(automaticallyAcceptFiles, forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue) }}
@@ -39,6 +36,10 @@ class Settings: ObservableObject {
{ didSet { UserDefaults.standard.set(incomingTransmissionCount, forKey: UserDefaultsKeys.transmissionCount.rawValue) }} { didSet { UserDefaults.standard.set(incomingTransmissionCount, forKey: UserDefaultsKeys.transmissionCount.rawValue) }}
var incomingNotificationCount: Int = UserDefaults.standard.integer(forKey: UserDefaultsKeys.incomingNotificationCount.rawValue)
{ didSet { UserDefaults.standard.set(incomingNotificationCount, forKey: UserDefaultsKeys.incomingNotificationCount.rawValue) }}
var endpointID: String? = UserDefaults.standard.string(forKey: UserDefaultsKeys.endpointID.rawValue) var endpointID: String? = UserDefaults.standard.string(forKey: UserDefaultsKeys.endpointID.rawValue)
{ didSet { UserDefaults.standard.set(endpointID, forKey: UserDefaultsKeys.endpointID.rawValue) }} { didSet { UserDefaults.standard.set(endpointID, forKey: UserDefaultsKeys.endpointID.rawValue) }}
@@ -55,25 +56,30 @@ class Settings: ObservableObject {
// MARK: - Debug // MARK: - Debug
func deleteAllUserDefaults() { func deleteAllUserDefaults() {
for key in UserDefaultsKeys.allCases { let defaults = UserDefaults.standard
UserDefaults.standard.removeObject(forKey: key.rawValue) if let bundleID = Bundle.main.bundleIdentifier {
defaults.removePersistentDomain(forName: bundleID)
} }
defaults.synchronize()
Settings.shared = Settings() Settings.sharedInstance = Settings()
} }
// MARK: - UserDefaults Keys // MARK: - UserDefaults Keys
public enum UserDefaultsKeys: String, CaseIterable { public enum UserDefaultsKeys: String, CaseIterable, RawRepresentable {
case isEligibleForIap = "isEligibleForIap" case isEligibleForIap = "isEligibleForIap"
case appLaunchedBefore = "ShowedWelcomeScreen" case appLaunchedBefore = "ShowedWelcomeScreen"
case plusVersion = "isPlusVersion"
case transmissionCount = "reviewRequestCountKey" case transmissionCount = "reviewRequestCountKey"
case incomingNotificationCount = "incomingNotificationCount"
case automaticallyAcceptFiles = "automaticallyAcceptFiles" case automaticallyAcceptFiles = "automaticallyAcceptFiles"
case saveFolderBookmark = "saveFolderBookmark" case saveFolderBookmark = "saveFolderBookmark"
case openFinderAfterReceiving = "openFinderAfterReceiving" case openFinderAfterReceiving = "openFinderAfterReceiving"
case endpointID = "endpointID" case endpointID = "endpointID"
// Plus version used before StoreKit 2 update. New key is fetched from App Store directly.
case plusVersionLegacy = "isPlusVersion"
} }
} }
+103
View File
@@ -0,0 +1,103 @@
//
// AllowedWorkMonitor.swift
// QuickDrop
//
// Created by Leon Böttger on 26.12.25.
//
import AppKit
import CoreGraphics
import LUI
final class AllowedWorkMonitor {
var onAllowed: (() -> Void)?
var onStopped: (() -> Void)?
private var screenOn = true
private var sessionActive = true
private var lastAllowed: Bool?
private var observerTokens: [NSObjectProtocol] = []
func start() {
stop()
let nc = NSWorkspace.shared.notificationCenter
observerTokens.append(nc.addObserver(
forName: NSWorkspace.screensDidSleepNotification,
object: nil,
queue: .main
) { [weak self] _ in
log("[AllowedWorkMonitor] Screen did sleep")
self?.screenOn = false
self?.evaluateAndNotify()
})
observerTokens.append(nc.addObserver(
forName: NSWorkspace.screensDidWakeNotification,
object: nil,
queue: .main
) { [weak self] _ in
log("[AllowedWorkMonitor] Screen did wake")
self?.screenOn = true
self?.evaluateAndNotify()
})
observerTokens.append(nc.addObserver(
forName: NSWorkspace.sessionDidResignActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
log("[AllowedWorkMonitor] Session did resign active")
self?.sessionActive = false
self?.evaluateAndNotify()
})
observerTokens.append(nc.addObserver(
forName: NSWorkspace.sessionDidBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
log("[AllowedWorkMonitor] Session did become active")
self?.sessionActive = true
self?.evaluateAndNotify()
})
// Initial fire to establish state
evaluateAndNotify(forceNotify: true)
}
func stop() {
let nc = NSWorkspace.shared.notificationCenter
observerTokens.forEach { nc.removeObserver($0) }
observerTokens.removeAll()
lastAllowed = nil
}
deinit { stop() }
private func computeAllowed() -> Bool {
return screenOn && sessionActive
}
private func evaluateAndNotify(forceNotify: Bool = false) {
let allowed = computeAllowed()
if forceNotify || lastAllowed == nil {
lastAllowed = allowed
allowed ? onAllowed?() : onStopped?()
return
}
guard allowed != lastAllowed else { return }
lastAllowed = allowed
allowed ? onAllowed?() : onStopped?()
}
}
@@ -0,0 +1,259 @@
//
// AndroidAirDropModeDetector.swift
// QuickDrop
//
// Created by Leon Böttger on 10.06.26.
//
import CoreBluetooth
import Foundation
import LUI
struct AndroidAirDropAdvertisement: Equatable {
let rssi: Int
let manufacturerDataHex: String?
let serviceUUIDs: Set<String>
let serviceDataHexByUUID: [String: String]
let isConnectable: Bool?
init(
rssi: Int,
manufacturerDataHex: String?,
serviceUUIDs: Set<String>,
serviceDataHexByUUID: [String: String] = [:],
isConnectable: Bool? = nil
) {
self.rssi = rssi
self.manufacturerDataHex = manufacturerDataHex?.lowercased()
self.serviceUUIDs = Set(serviceUUIDs.map { $0.uppercased() })
self.serviceDataHexByUUID = Dictionary(
uniqueKeysWithValues: serviceDataHexByUUID.map { ($0.key.uppercased(), $0.value.lowercased()) }
)
self.isConnectable = isConnectable
}
}
struct AndroidAirDropModeObservation: Equatable {
let airDropModePacketCount: Int
let strongestAirDropModeRSSI: Int?
let minimumRSSI: Int
let requiredPacketsPerSignal: Int
var isDetected: Bool {
airDropModePacketCount >= requiredPacketsPerSignal
}
}
struct AndroidAirDropModeClassifier {
static let defaultMinimumRSSI = -70
static let defaultRequiredPacketsPerSignal = 2
static let airDropModeManufacturerPrefix = "4c000512000000000000000001"
static let airDropModeServiceUUID = "FCF1"
let minimumRSSI: Int
let requiredPacketsPerSignal: Int
private(set) var airDropModePacketCount = 0
private(set) var strongestAirDropModeRSSI: Int?
init(
minimumRSSI: Int = Self.defaultMinimumRSSI,
requiredPacketsPerSignal: Int = Self.defaultRequiredPacketsPerSignal
) {
self.minimumRSSI = minimumRSSI
self.requiredPacketsPerSignal = requiredPacketsPerSignal
}
var observation: AndroidAirDropModeObservation {
AndroidAirDropModeObservation(
airDropModePacketCount: airDropModePacketCount,
strongestAirDropModeRSSI: strongestAirDropModeRSSI,
minimumRSSI: minimumRSSI,
requiredPacketsPerSignal: requiredPacketsPerSignal
)
}
mutating func reset() {
airDropModePacketCount = 0
strongestAirDropModeRSSI = nil
}
@discardableResult
mutating func record(_ advertisement: AndroidAirDropAdvertisement) -> AndroidAirDropModeObservation {
guard advertisement.rssi >= minimumRSSI else {
return observation
}
if Self.isAirDropModeAdvertisement(advertisement) {
airDropModePacketCount += 1
strongestAirDropModeRSSI = Self.strongerRSSI(strongestAirDropModeRSSI, advertisement.rssi)
}
return observation
}
static func isAirDropModeAdvertisement(_ advertisement: AndroidAirDropAdvertisement) -> Bool {
#if os(iOS)
// iOS exposes the AirDrop-compatible Android share-mode service UUID,
// but hides the Apple manufacturer payload from third-party apps. The
// share sheet emits a non-connectable FCF1 packet without service data;
// background AirDrop-compatible discovery emits connectable FCF1 packets
// with FCF1 service data.
advertisement.serviceUUIDs.contains(airDropModeServiceUUID) &&
advertisement.isConnectable == false &&
advertisement.serviceDataHexByUUID[airDropModeServiceUUID] == nil
#else
advertisement.manufacturerDataHex?.hasPrefix(airDropModeManufacturerPrefix) == true &&
advertisement.serviceUUIDs.contains(airDropModeServiceUUID)
#endif
}
private static func strongerRSSI(_ current: Int?, _ candidate: Int) -> Int {
guard let current else { return candidate }
return max(current, candidate)
}
}
final class AndroidAirDropModeDetector: NSObject, CBCentralManagerDelegate {
static let shared = AndroidAirDropModeDetector()
static let scanServiceUUIDs = [CBUUID(string: AndroidAirDropModeClassifier.airDropModeServiceUUID)]
static let scanOptions: [String: Any] = [CBCentralManagerScanOptionAllowDuplicatesKey: true]
private var centralManager: CBCentralManager?
private var classifier = AndroidAirDropModeClassifier()
private var onDetected: ((AndroidAirDropModeObservation) -> Void)?
private var isScanning = false
private var hasDeliveredDetection = false
func start(onDetected: @escaping (AndroidAirDropModeObservation) -> Void) {
guard BluetoothPermissionManager.shared.isAllowed else {
log("[AndroidAirDropModeDetector] Bluetooth permission is not granted; skipping scan. authorization=\(BluetoothPermissionManager.shared.authorization.rawValue)")
return
}
self.onDetected = onDetected
guard !hasDeliveredDetection else { return }
guard !isScanning else { return }
log("[AndroidAirDropModeDetector] Starting detector.")
classifier.reset()
if centralManager == nil {
log("[AndroidAirDropModeDetector] Creating central manager.")
centralManager = CBCentralManager(delegate: self, queue: .main)
}
else if centralManager?.state == .poweredOn {
beginScanning()
}
}
func stop() {
log("[AndroidAirDropModeDetector] Stopping detector.")
centralManager?.stopScan()
classifier.reset()
onDetected = nil
hasDeliveredDetection = false
isScanning = false
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
log("[AndroidAirDropModeDetector] Central state changed: \(central.state.rawValue)")
guard central.state == .poweredOn else {
if isScanning {
central.stopScan()
isScanning = false
}
return
}
if onDetected != nil {
beginScanning()
}
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
guard !hasDeliveredDetection else { return }
let advertisement = Self.advertisement(from: advertisementData, rssi: RSSI.intValue)
let observation = classifier.record(advertisement)
guard observation.isDetected else { return }
hasDeliveredDetection = true
isScanning = false
central.stopScan()
let callback = onDetected
onDetected = nil
log(
"[AndroidAirDropModeDetector] Detected Android AirDrop-compatible sharing mode. " +
"airDropModePackets=\(observation.airDropModePacketCount), " +
"minimumRSSI=\(observation.minimumRSSI)"
)
callback?(observation)
}
private func beginScanning() {
guard !isScanning, let centralManager, centralManager.state == .poweredOn else { return }
let scanTargetDescription = Self.scanServiceUUIDs.map(\.uuidString).joined(separator: ", ")
log("[AndroidAirDropModeDetector] BLE scan started for \(scanTargetDescription).")
// Duplicate delivery is intentional: the classifier waits for repeated
// matching packets so one transient advertisement does not trigger the alert.
centralManager.scanForPeripherals(
withServices: Self.scanServiceUUIDs,
options: Self.scanOptions
)
isScanning = true
}
private static func advertisement(
from advertisementData: [String: Any],
rssi: Int
) -> AndroidAirDropAdvertisement {
var serviceUUIDs = Set<String>()
var serviceDataHexByUUID: [String: String] = [:]
[
CBAdvertisementDataServiceUUIDsKey,
CBAdvertisementDataOverflowServiceUUIDsKey,
CBAdvertisementDataSolicitedServiceUUIDsKey
].forEach { key in
guard let uuids = advertisementData[key] as? [CBUUID] else { return }
uuids.forEach { serviceUUIDs.insert($0.uuidString) }
}
if let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] {
serviceData.forEach { uuid, data in
serviceUUIDs.insert(uuid.uuidString)
serviceDataHexByUUID[uuid.uuidString] = data.androidAirDropModeHexString
}
}
return AndroidAirDropAdvertisement(
rssi: rssi,
manufacturerDataHex: (advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data)?.androidAirDropModeHexString,
serviceUUIDs: serviceUUIDs,
serviceDataHexByUUID: serviceDataHexByUUID,
isConnectable: advertisementData[CBAdvertisementDataIsConnectable] as? Bool
)
}
}
private extension Data {
var androidAirDropModeHexString: String {
map { String(format: "%02x", $0) }.joined()
}
}
+1
View File
@@ -6,6 +6,7 @@
// //
import AVFoundation import AVFoundation
import LUI
class AudioManager { class AudioManager {
static private var incomingFileSoundPlayer: AVAudioPlayer? static private var incomingFileSoundPlayer: AVAudioPlayer?
@@ -0,0 +1,85 @@
//
// BluetoothPermissionManager.swift
// QuickDrop
//
// Created by Leon Böttger on 31.05.26.
//
import Combine
import CoreBluetooth
import Foundation
import LUI
#if os(iOS) && !EXTENSION
import UIKit
#endif
#if os(macOS)
import AppKit
#endif
final class BluetoothPermissionManager: NSObject, ObservableObject, CBCentralManagerDelegate {
static let shared = BluetoothPermissionManager()
@Published private(set) var authorization: CBManagerAuthorization = CBManager.authorization
private var centralManager: CBCentralManager?
private var completion: ((Bool) -> Void)?
var isAllowed: Bool {
authorization == .allowedAlways
}
var canRequestPermission: Bool {
authorization == .notDetermined
}
private override init() {
super.init()
}
func refresh() {
let currentAuthorization = CBManager.authorization
guard authorization != currentAuthorization else { return }
authorization = currentAuthorization
}
func requestPermission(completion: ((Bool) -> Void)? = nil) {
refresh()
guard authorization == .notDetermined else {
completion?(isAllowed)
return
}
self.completion = completion
centralManager = CBCentralManager(delegate: self, queue: .main)
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
refresh()
guard authorization != .notDetermined else { return }
let completion = completion
self.completion = nil
completion?(isAllowed)
}
func openSystemBluetoothSettings() {
#if os(iOS) && !EXTENSION
openAppSettings()
#elseif os(macOS)
let urls = [
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth"),
URL(string: "x-apple.systempreferences:com.apple.BluetoothSettings")
].compactMap { $0 }
if let url = urls.first {
NSWorkspace.shared.open(url)
}
#endif
}
}
@@ -0,0 +1,317 @@
//
// ClipboardSyncCoordinator.swift
// QuickDrop
//
#if os(macOS) && !EXTENSION
import AppKit
import Foundation
import LUI
final class ClipboardSyncCoordinator {
static let shared = ClipboardSyncCoordinator()
private let pollInterval: TimeInterval = 1
private let transferTimeout: TimeInterval = 75
private var isStarted = false
private var pollTimer: Timer?
private var trustStoreObserver: NSObjectProtocol?
private var lastObservedChangeCount: Int
private var suppressedClipboardText: String?
private var startedOwnDiscovery = false
private var activeTransfersByFingerprint: [String: ClipboardSyncTransferDelegate] = [:]
private var queuedTextByFingerprint: [String: String] = [:]
private init() {
lastObservedChangeCount = NSPasteboard.general.changeCount
}
func start() {
guard !isStarted else {
refreshPollingState()
return
}
isStarted = true
trustStoreObserver = NotificationCenter.default.addObserver(
forName: TrustStore.trustedCertificatesDidChange,
object: TrustStore.shared,
queue: .main
) { [weak self] _ in
self?.refreshPollingState()
}
refreshPollingState()
}
func stop() {
guard isStarted || pollTimer != nil else { return }
log("[ClipboardSyncCoordinator] Stopping clipboard watcher")
isStarted = false
if let trustStoreObserver {
NotificationCenter.default.removeObserver(trustStoreObserver)
self.trustStoreObserver = nil
}
stopPolling(clearTransfers: true)
}
private func refreshPollingState() {
guard isStarted else { return }
guard hasTrustedClipboardSyncDevice else {
if pollTimer != nil {
log("[ClipboardSyncCoordinator] Pausing clipboard watcher because no trusted clipboard sync device is saved")
}
stopPolling(clearTransfers: true)
return
}
startPollingIfNeeded()
}
private var hasTrustedClipboardSyncDevice: Bool {
TrustStore.shared.trustedCertificates.values.contains {
$0.grantedUseCases.contains(.clipboardSync)
}
}
private func startPollingIfNeeded() {
guard pollTimer == nil else { return }
let manager = NearbyConnectionManager.shared
if !manager.isDeviceDiscoveryActive {
startedOwnDiscovery = true
manager.startDeviceDiscovery()
}
lastObservedChangeCount = NSPasteboard.general.changeCount
log("[ClipboardSyncCoordinator] Starting clipboard watcher at changeCount=\(lastObservedChangeCount)")
pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
self?.pollClipboard()
}
}
private func stopPolling(clearTransfers: Bool) {
pollTimer?.invalidate()
pollTimer = nil
suppressedClipboardText = nil
if clearTransfers {
activeTransfersByFingerprint.values.forEach { $0.cancelTimeout() }
activeTransfersByFingerprint.removeAll()
queuedTextByFingerprint.removeAll()
}
guard startedOwnDiscovery else { return }
startedOwnDiscovery = false
NearbyConnectionManager.shared.stopDeviceDiscovery()
}
func suppressNextClipboardSync(for text: String) {
if Thread.isMainThread {
setSuppressedClipboardText(text)
} else {
DispatchQueue.main.sync {
setSuppressedClipboardText(text)
}
}
}
private func setSuppressedClipboardText(_ text: String) {
log("[ClipboardSyncCoordinator] Suppressing next clipboard sync for incoming text with \(text.utf8.count) bytes")
suppressedClipboardText = text
}
private func pollClipboard() {
let pasteboard = NSPasteboard.general
let currentChangeCount = pasteboard.changeCount
guard currentChangeCount != lastObservedChangeCount else { return }
log("[ClipboardSyncCoordinator] Clipboard change detected: \(lastObservedChangeCount) -> \(currentChangeCount)")
lastObservedChangeCount = currentChangeCount
guard let clipboardText = pasteboard.string(forType: .string) else {
log("[ClipboardSyncCoordinator] Clipboard change ignored because no plain text content is available")
return
}
guard !clipboardText.isEmpty else {
log("[ClipboardSyncCoordinator] Clipboard change ignored because text content is empty")
return
}
if suppressedClipboardText == clipboardText {
log("[ClipboardSyncCoordinator] Ignoring clipboard change because it matches an incoming clipboard transfer")
suppressedClipboardText = nil
return
}
suppressedClipboardText = nil
sendClipboardText(clipboardText)
}
private func sendClipboardText(_ clipboardText: String) {
let targets = nearbyTrustedClipboardTargets()
guard !targets.isEmpty else {
log("[ClipboardSyncCoordinator] Clipboard change will not be sent because no trusted clipboard-capable devices are nearby")
return
}
log("[ClipboardSyncCoordinator] Sending clipboard contents to \(targets.count) trusted clipboard-capable device(s)")
for target in targets {
startTransfer(to: target, clipboardText: clipboardText)
}
}
private func startTransfer(to target: ClipboardSyncTarget, clipboardText: String) {
guard activeTransfersByFingerprint[target.fingerprint] == nil else {
queuedTextByFingerprint[target.fingerprint] = clipboardText
log("[ClipboardSyncCoordinator] Queued latest clipboard text for \(target.logName) because a transfer is already active")
return
}
let delegate = ClipboardSyncTransferDelegate(target: target) { [weak self] finishedTarget, finishedDelegate in
DispatchQueue.main.async {
self?.handleTransferCompleted(for: finishedTarget, delegate: finishedDelegate)
}
}
activeTransfersByFingerprint[target.fingerprint] = delegate
delegate.startTimeout(after: transferTimeout)
NearbyConnectionManager.shared.startOutgoingTransfer(
deviceID: target.deviceID,
delegate: delegate,
urls: [],
textToSend: clipboardText,
isClipboardSyncTransfer: true,
receiverAuthenticationPolicy: .trustedReceiver(fingerprint: target.fingerprint)
)
}
private func handleTransferCompleted(for target: ClipboardSyncTarget, delegate: ClipboardSyncTransferDelegate) {
guard activeTransfersByFingerprint[target.fingerprint] === delegate else {
log("[ClipboardSyncCoordinator] Ignoring stale clipboard sync completion for \(target.logName)")
return
}
activeTransfersByFingerprint.removeValue(forKey: target.fingerprint)
guard let queuedText = queuedTextByFingerprint.removeValue(forKey: target.fingerprint) else {
return
}
guard let refreshedTarget = nearbyTrustedClipboardTargets()
.first(where: { $0.fingerprint == target.fingerprint }) else {
log("[ClipboardSyncCoordinator] Dropping queued clipboard text for \(target.logName) because the device is no longer an eligible target")
return
}
log("[ClipboardSyncCoordinator] Sending queued latest clipboard text to \(refreshedTarget.logName)")
startTransfer(to: refreshedTarget, clipboardText: queuedText)
}
private func nearbyTrustedClipboardTargets() -> [ClipboardSyncTarget] {
let trustedFingerprints = Set(
TrustStore.shared.trustedCertificates.keys.map {
$0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
)
var seenFingerprints = Set<String>()
return NearbyConnectionManager.shared.discoveredDevices()
.compactMap { device -> ClipboardSyncTarget? in
guard let deviceID = device.id,
!deviceID.isEmpty,
let fingerprint = device.keyFingerprint,
trustedFingerprints.contains(fingerprint),
TrustStore.shared.hasUseCaseGrant(.clipboardSync, for: fingerprint),
device.supportsClipboardReceive,
device.type != .computer,
seenFingerprints.insert(fingerprint).inserted else {
return nil
}
return ClipboardSyncTarget(
device: device,
deviceID: deviceID,
fingerprint: fingerprint
)
}
.sorted { lhs, rhs in
lhs.logName < rhs.logName
}
}
}
private struct ClipboardSyncTarget {
let device: RemoteDeviceInfo
let deviceID: String
let fingerprint: String
var logName: String {
device.name ?? deviceID
}
}
private final class ClipboardSyncTransferDelegate: OutboundAppDelegate {
private let target: ClipboardSyncTarget
private let onComplete: (ClipboardSyncTarget, ClipboardSyncTransferDelegate) -> Void
private var isCompleted = false
private var timeoutWorkItem: DispatchWorkItem?
init(target: ClipboardSyncTarget, onComplete: @escaping (ClipboardSyncTarget, ClipboardSyncTransferDelegate) -> Void) {
self.target = target
self.onComplete = onComplete
}
func startTimeout(after timeout: TimeInterval) {
let timeoutWorkItem = DispatchWorkItem { [weak self] in
guard let self, !self.isCompleted else { return }
log("[ClipboardSyncCoordinator] Clipboard sync to \(self.target.logName) timed out")
NearbyConnectionManager.shared.cancelTransfer(id: self.target.deviceID)
self.complete()
}
self.timeoutWorkItem = timeoutWorkItem
DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem)
}
func cancelTimeout() {
timeoutWorkItem?.cancel()
timeoutWorkItem = nil
}
func addDevice(device _: RemoteDeviceInfo) {}
func removeDevice(id _: String) {}
func startTransferWithQrCode(device _: RemoteDeviceInfo) {}
func connectionWasEstablished(pinCode _: String) {}
func transferAccepted() {}
func transferProgress(progress _: Double) {}
func connectionFailed(error: Error) {
log("[ClipboardSyncCoordinator] Clipboard sync to \(target.logName) failed: \(error.localizedDescription)")
complete()
}
func transferFinished() {
log("[ClipboardSyncCoordinator] Clipboard sync to \(target.logName) finished")
complete()
}
private func complete() {
guard !isCompleted else { return }
isCompleted = true
cancelTimeout()
onComplete(target, self)
}
}
#endif
@@ -0,0 +1,140 @@
//
// ClipboardSyncNotificationPresenter.swift
// QuickDrop
//
#if os(macOS) && !EXTENSION
import Foundation
import UserNotifications
import LUI
final class ClipboardSyncNotificationPresenter {
static let shared = ClipboardSyncNotificationPresenter()
static let notificationIdentifier = "quickdrop-clipboard-sync"
private static let notificationIdentifierPrefix = "\(notificationIdentifier)-"
private let center = UNUserNotificationCenter.current()
private let queue = DispatchQueue(label: "ClipboardSyncNotificationPresenter")
private var pendingNotifications: [(String, String?)] = []
private var authorizationRequestInFlight = false
private var postedNotificationIdentifiers: Set<String> = []
private init() {}
static func isClipboardSyncNotification(_ notification: UNNotification) -> Bool {
notification.request.identifier == notificationIdentifier
|| notification.request.identifier.hasPrefix(notificationIdentifierPrefix)
|| notification.request.content.threadIdentifier == notificationIdentifier
}
func present(clipboardText: String, senderDeviceName: String?) {
queue.async {
self.postNotificationIfAuthorized(
clipboardText: clipboardText,
senderDeviceName: senderDeviceName
)
}
}
private func postNotificationIfAuthorized(clipboardText: String, senderDeviceName: String?) {
center.getNotificationSettings { settings in
self.queue.async {
self.handleAuthorizationStatus(
settings.authorizationStatus,
clipboardText: clipboardText,
senderDeviceName: senderDeviceName
)
}
}
}
private func handleAuthorizationStatus(
_ status: UNAuthorizationStatus,
clipboardText: String,
senderDeviceName: String?
) {
switch status {
case .authorized, .provisional, .ephemeral:
post(clipboardText: clipboardText, senderDeviceName: senderDeviceName)
case .notDetermined:
pendingNotifications.append((clipboardText, senderDeviceName))
requestAuthorizationIfNeeded()
case .denied:
log("[ClipboardSyncNotificationPresenter] Notifications are denied in system settings; cannot show clipboard sync notification.")
@unknown default:
log("[ClipboardSyncNotificationPresenter] Unknown notification authorization status; skipping clipboard sync notification.")
}
}
private func requestAuthorizationIfNeeded() {
if authorizationRequestInFlight {
return
}
authorizationRequestInFlight = true
DispatchQueue.main.async {
self.center.requestAuthorization(options: [.alert, .badge]) { granted, error in
self.queue.async {
self.authorizationRequestInFlight = false
if let error = error {
log("[ClipboardSyncNotificationPresenter] Authorization request failed: \(error.localizedDescription)")
}
guard granted else {
log("[ClipboardSyncNotificationPresenter] Authorization request not granted; dropping queued clipboard sync notifications.")
self.pendingNotifications.removeAll()
return
}
let queued = self.pendingNotifications
self.pendingNotifications.removeAll()
for (queuedText, queuedSender) in queued {
self.post(clipboardText: queuedText, senderDeviceName: queuedSender)
}
}
}
}
}
private func post(clipboardText: String, senderDeviceName: String?) {
let senderName = senderDeviceName?.trimmingCharacters(in: .whitespacesAndNewlines)
let content = UNMutableNotificationContent()
content.title = "CopiedToClipboard".localized()
content.body = preview(for: clipboardText)
content.subtitle = senderName?.isEmpty == false ? senderName! : "QuickDrop"
content.threadIdentifier = Self.notificationIdentifier
content.interruptionLevel = .active
let identifier = Self.makeNotificationIdentifier()
var identifiersToRemove = postedNotificationIdentifiers
identifiersToRemove.insert(Self.notificationIdentifier)
postedNotificationIdentifiers = [identifier]
center.removePendingNotificationRequests(withIdentifiers: Array(identifiersToRemove))
center.removeDeliveredNotifications(withIdentifiers: Array(identifiersToRemove))
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
center.add(request) { error in
if let error = error {
log("[ClipboardSyncNotificationPresenter] Could not post notification: \(error.localizedDescription)")
} else {
log("[ClipboardSyncNotificationPresenter] Posted clipboard sync notification (\(identifier)).")
}
}
}
private func preview(for clipboardText: String) -> String {
clipboardText
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func makeNotificationIdentifier() -> String {
"\(notificationIdentifierPrefix)\(UUID().uuidString)"
}
}
#endif
+106 -94
View File
@@ -10,12 +10,11 @@ import Network
import StoreKit import StoreKit
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
import LUI
#if os(macOS) #if os(macOS)
import BezelNotification import BezelNotification
import Cocoa import Cocoa
#else
import LUI
#endif #endif
class ErrorAlertHandler { class ErrorAlertHandler {
@@ -28,16 +27,35 @@ class ErrorAlertHandler {
private var firewallAlertWindow: NSWindow? private var firewallAlertWindow: NSWindow?
private var apIsolationAlertWindow: NSWindow? private var apIsolationAlertWindow: NSWindow?
private var networkFilterAlertWindow: NSWindow? private var networkFilterAlertWindow: NSWindow?
private var androidAirDropModeAlertWindow: NSWindow?
#endif #endif
func showErrorAlert(for deviceName: String, error: Error) { /// Resource key for the recoverable-error suffix ("if this keeps happening…") appended to most
/// transfer errors.
///
/// `Error.FixInstructions` suggests downloading the QuickDrop Android app from Google Play, which
/// is pointless for a peer that already runs QuickDrop. For such a peer (`isQuickDropPeer == true`)
/// the `Error.FixInstructionsAppInstalled` variant — the same advice without the download
/// suggestion — is used instead.
static func fixInstructionsKey(isQuickDropPeer: Bool) -> String {
isQuickDropPeer ? "Error.FixInstructionsAppInstalled" : "Error.FixInstructions"
}
/// Presents the transfer-error alert for `deviceName`.
///
/// - Parameter isQuickDropPeer: whether the other device runs the QuickDrop app. When `true`, the
/// "download the app from Google Play" suggestion is omitted from the fix instructions (see
/// `fixInstructionsKey(isQuickDropPeer:)`). Defaults to `false` so callers without device context
/// keep the full hint.
/// - Parameter completion: called after the macOS transfer alert is dismissed. For issue-specific
/// alerts and iOS alerts, it is called after presentation.
func showErrorAlert(for deviceName: String, error: Error, isQuickDropPeer: Bool = false, completion: (() -> Void)? = nil) {
#if os(macOS) #if os(macOS)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
#endif #endif
var description = "" var description = ""
let fixInstructions = " " + "Error.FixInstructions".localized() let fixInstructions = " " + Self.fixInstructionsKey(isQuickDropPeer: isQuickDropPeer).localized()
if let ne = (error as? NearbyError) { if let ne = (error as? NearbyError) {
switch ne { switch ne {
case .inputOutput: case .inputOutput:
@@ -47,6 +65,7 @@ class ErrorAlertHandler {
case .packetFilterError: case .packetFilterError:
#if os(macOS) #if os(macOS)
openAlert(type: .NetworkFilter) openAlert(type: .NetworkFilter)
completion?()
return return
#else #else
description = error.localizedDescription description = error.localizedDescription
@@ -54,6 +73,7 @@ class ErrorAlertHandler {
case .firewallError: case .firewallError:
#if os(macOS) #if os(macOS)
openAlert(type: .Firewall) openAlert(type: .Firewall)
completion?()
return return
#else #else
description = error.localizedDescription description = error.localizedDescription
@@ -62,6 +82,8 @@ class ErrorAlertHandler {
description = errorMessage + fixInstructions description = errorMessage + fixInstructions
case .ukey2: case .ukey2:
description = "Error.Crypto".localized() + ": \(ne.localizedDescription)" + fixInstructions description = "Error.Crypto".localized() + ": \(ne.localizedDescription)" + fixInstructions
case .notificationSyncNotTrusted:
description = "NotificationSyncNotTrustedMessage".localized()
case .canceled(reason: let reason): case .canceled(reason: let reason):
if reason == .timedOut { if reason == .timedOut {
description = reason.localizedDescription() + fixInstructions description = reason.localizedDescription() + fixInstructions
@@ -77,6 +99,7 @@ class ErrorAlertHandler {
// Prevent multiple alerts at the same time // Prevent multiple alerts at the same time
if self.isAlertShown { if self.isAlertShown {
log("Skipping alert for error \(error.localizedDescription) because one is already shown") log("Skipping alert for error \(error.localizedDescription) because one is already shown")
completion?()
return return
} }
else { else {
@@ -84,30 +107,18 @@ class ErrorAlertHandler {
let title = String(format: "TransferError".localized(), arguments: [deviceName]) let title = String(format: "TransferError".localized(), arguments: [deviceName])
log("Showing alert with title: \"\(title)\" and description: \"\(description)\"") log("Showing alert with title: \"\(title)\" and description: \"\(description)\"")
log("Unsuccessful transmission. Already successful transmissions: \(Settings.shared.incomingTransmissionCount)") log("Unsuccessful transmission. Already successful transmissions: \(Settings.sharedInstance.incomingTransmissionCount)")
#if os(macOS) #if os(macOS)
let primaryButtonTitle = "InformDeveloper".localized() // Shown modelessly (the native NSAlert panel via makeKeyAndOrderFront) rather than
let secondaryButtonTitle = "CloseAlert".localized() // runModal(), which blocks the main run loop and would freeze the next incoming transfer's
// consent prompt until dismissed. Same window runModal() uses, so it looks identical.
self.isAlertShown = true self.isAlertShown = true
let alert = NSAlert() presentTransferErrorAlert(title: title, description: description, completion: completion)
alert.alertStyle = .critical
alert.messageText = title
alert.informativeText = description
alert.addButton(withTitle: primaryButtonTitle)
alert.addButton(withTitle: secondaryButtonTitle)
let result = alert.runModal()
self.isAlertShown = false
if result == .alertFirstButtonReturn {
//log("Sending logging string")
//sendLoggingString()
}
#else #else
showAlert(title: title, message: description) showAlert(title: title, message: description)
completion?()
#endif #endif
} }
} }
@@ -119,6 +130,11 @@ class ErrorAlertHandler {
AudioManager.playErrorSound() AudioManager.playErrorSound()
DispatchQueue.main.async { [self] in DispatchQueue.main.async { [self] in
if let existingWindow = alertWindow(for: type), existingWindow.isVisible {
bringAlertWindowToFront(existingWindow)
return
}
// Create an NSWindow to host the SwiftUI view // Create an NSWindow to host the SwiftUI view
let alertWindow = NSWindow( let alertWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: issueViewWidth, height: issueViewHeight), contentRect: NSRect(x: 0, y: 0, width: issueViewWidth, height: issueViewHeight),
@@ -137,6 +153,9 @@ class ErrorAlertHandler {
case .Firewall: case .Firewall:
firewallAlertWindow = alertWindow firewallAlertWindow = alertWindow
alertWindow.contentView = NSHostingView(rootView: FirewallIssueView()) alertWindow.contentView = NSHostingView(rootView: FirewallIssueView())
case .AndroidAirDropMode:
androidAirDropModeAlertWindow = alertWindow
alertWindow.contentView = NSHostingView(rootView: AndroidAirDropModeIssueView())
} }
alertWindow.title = "QuickDrop" alertWindow.title = "QuickDrop"
@@ -144,9 +163,8 @@ class ErrorAlertHandler {
alertWindow.isReleasedWhenClosed = false alertWindow.isReleasedWhenClosed = false
alertWindow.setFrameAutosaveName(type.rawValue) alertWindow.setFrameAutosaveName(type.rawValue)
NSApp.activate(ignoringOtherApps: true)
alertWindow.makeKeyAndOrderFront(nil)
alertWindow.level = .normal alertWindow.level = .normal
bringAlertWindowToFront(alertWindow)
} }
} }
@@ -158,81 +176,75 @@ class ErrorAlertHandler {
self.apIsolationAlertWindow = nil self.apIsolationAlertWindow = nil
} }
} }
private func alertWindow(for type: AlertType) -> NSWindow? {
switch type {
case .ApIsolation:
return apIsolationAlertWindow
case .NetworkFilter:
return networkFilterAlertWindow
case .Firewall:
return firewallAlertWindow
case .AndroidAirDropMode:
return androidAirDropModeAlertWindow
}
}
private func bringAlertWindowToFront(_ window: NSWindow) {
// Keep the app a menu-bar (.accessory) app: do NOT switch to .regular,
// which would add a dock icon that lingers after the alert closes.
// orderFrontRegardless() brings the window to the front even though the
// app stays inactive in the background.
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
}
/// Shows the transfer-error alert as the native `NSAlert` panel, but presented modelessly
/// (`makeKeyAndOrderFront`) instead of `runModal()`, so it does NOT block the main run loop — an
/// incoming transfer's consent prompt can still appear while it's on screen. It's the same
/// `_NSAlertPanel` window `runModal()` would show, so it looks identical; we just route the button
/// clicks ourselves (there's no modal session to end) and reset state on dismissal.
private func presentTransferErrorAlert(title: String, description: String, completion: (() -> Void)? = nil) {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = title
alert.informativeText = description
alert.addButton(withTitle: "InformDeveloper".localized()) // tag == .alertFirstButtonReturn
alert.addButton(withTitle: "CloseAlert".localized()) // tag == .alertSecondButtonReturn
alert.buttons.first?.keyEquivalent = "\r" // default button: Return
if alert.buttons.count > 1 { alert.buttons[1].keyEquivalent = "\u{1b}" } // Close: Escape
ModelessAlertPresenter.present(alert) { [weak self] response in
self?.isAlertShown = false
if response == .alertFirstButtonReturn {
Self.presentLogUpload()
}
completion?()
}
}
/// The "Inform developer" action: from the share extension, hand off to the host app via the
/// `quickdrop://sendLog` URL; otherwise upload logs directly. Mirrors the previous alert's primary
/// button.
private static func presentLogUpload() {
if Bundle.main.bundlePath.hasSuffix(".appex") {
if let url = URL(string: "quickdrop://sendLog") {
NSWorkspace.shared.open(url)
}
} else {
LogExportPresenter.showUploadLogsAlert(openSupportMailAfterUpload: false)
}
}
#endif #endif
} }
#if os(macOS)
func sendLoggingString() {
// If we're in an extension, redirect to the main app
if Bundle.main.bundlePath.hasSuffix(".appex") {
log("sendLoggingString: in extension, redirecting to main app")
if let url = URL(string: "quickdrop://sendLog") {
NSWorkspace.shared.open(url)
}
return
}
// We're in the main app – do the actual sending
if let url = LogManager.sharedInstance.logFileURL {
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
log("Sending logging string with file URL: \(url)")
log("Currently running apps: \(NSWorkspace.shared.runningApplications.map { $0.localizedName ?? "Unknown" })")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: {
sendEmailWithAttachment(
fileURL: url,
recipients: ["quickdrop@leonboettger.com"],
subject: "QuickDrop \(appVersion) - \(getDeviceAndSystem())"
)
})
}
}
fileprivate func getDeviceAndSystem() -> String {
// Get Mac model identifier
var size: Int = 0
sysctlbyname("hw.model", nil, &size, nil, 0)
var modelBuffer = [CChar](repeating: 0, count: size)
sysctlbyname("hw.model", &modelBuffer, &size, nil, 0)
let model = String(cString: modelBuffer)
// Get macOS version
let version = ProcessInfo.processInfo.operatingSystemVersion
let versionString = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
return "\(model) - \(versionString)"
}
func sendEmailWithAttachment(fileURL: URL, recipients: [String], subject: String) {
guard let emailService = NSSharingService(named: .composeEmail) else {
log("No email service available")
return
}
emailService.recipients = recipients
emailService.subject = subject
emailService.perform(withItems: [fileURL])
log("Email service opened with recipients: \(recipients) and subject: \(subject)")
}
func copyToClipboard(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
#endif
enum AlertType: String { enum AlertType: String {
case ApIsolation case ApIsolation
case NetworkFilter case NetworkFilter
case Firewall case Firewall
case AndroidAirDropMode
} }
+1
View File
@@ -19,6 +19,7 @@
import SwiftUI import SwiftUI
import ServiceManagement import ServiceManagement
import os.log import os.log
import LUI
@available(macOS 13.0, *) @available(macOS 13.0, *)
public enum LaunchAtLogin { public enum LaunchAtLogin {
@@ -0,0 +1,78 @@
//
// ModelessAlertPresenter.swift
// QuickDrop
//
// Created by Leon Böttger on 30.06.26.
//
import AppKit
/// Presents an `NSAlert` as its own modeless panel (non-blocking) instead of `runModal()`. It's the
/// exact `_NSAlertPanel` window `runModal()` would show, so it looks identical — but it does not spin a
/// nested modal run loop, so the app keeps working (e.g. can still receive the next transfer) while the
/// alert is on screen.
///
/// Use this in place of `alert.runModal()` for any alert that may appear while the app is busy. The
/// clicked button is reported to `completion` as the `NSApplication.ModalResponse` that `runModal()`
/// would have returned (first button = `.alertFirstButtonReturn`, second = `.alertSecondButtonReturn`,
/// …). Must be called on the main thread.
final class ModelessAlertPresenter: NSObject {
/// Keeps each live presenter — and thus its `NSAlert` and panel — retained while on screen; the
/// entry is removed when the alert is dismissed. `NSButton.target` is weak and the panel is owned by
/// the `NSAlert`, so without this the alert would deallocate immediately.
private static var active: Set<ModelessAlertPresenter> = []
private let alert: NSAlert
private let completion: (NSApplication.ModalResponse) -> Void
private init(alert: NSAlert, completion: @escaping (NSApplication.ModalResponse) -> Void) {
self.alert = alert
self.completion = completion
}
static func present(_ alert: NSAlert,
completion: @escaping (NSApplication.ModalResponse) -> Void = { _ in }) {
if let window = NSApp.mainWindow ?? NSApp.keyWindow {
alert.beginSheetModal(for: window, completionHandler: completion)
}
else {
let presenter = ModelessAlertPresenter(alert: alert, completion: completion)
active.insert(presenter)
presenter.show()
}
}
private func show() {
alert.layout()
// runModal() makes the default button respond to Return; modelessly we set it ourselves — but
// only if the caller specified no key equivalents, so explicit ones (e.g. Escape) are kept.
if alert.buttons.allSatisfy({ $0.keyEquivalent.isEmpty }) {
alert.buttons.first?.keyEquivalent = "\r"
}
for button in alert.buttons {
button.target = self
button.action = #selector(buttonClicked(_:))
}
let window = alert.window
window.center()
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
}
@objc private func buttonClicked(_ sender: NSButton) {
let response = NSApplication.ModalResponse(rawValue: sender.tag)
alert.window.orderOut(nil)
// Defer teardown so the alert and its panel aren't deallocated while this button's action is
// still on the stack.
DispatchQueue.main.async {
self.completion(response)
ModelessAlertPresenter.active.remove(self)
}
}
}
@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import LUI
/// Returns the name of the currently active network interface (e.g. "en0", "en1") /// Returns the name of the currently active network interface (e.g. "en0", "en1")
func getActiveNetworkInterface() -> String? { func getActiveNetworkInterface() -> String? {
@@ -7,6 +7,7 @@
import Foundation import Foundation
import Network import Network
import LUI
class IPv6DeviceScanner { class IPv6DeviceScanner {
/// Scans for IPv6 devices using `ping6 ff02::1` multicast on the given interface. /// Scans for IPv6 devices using `ping6 ff02::1` multicast on the given interface.
+165 -29
View File
@@ -7,45 +7,147 @@
import Foundation import Foundation
import Network import Network
import LUI
final class DeviceToDeviceHeuristicScanner {
static let shared = DeviceToDeviceHeuristicScanner()
private init() {}
private enum ScanMode {
case peerReachability // true only if some peer is reachable
case localNetworkAccess // true if EHOSTDOWN seen (or ready)
}
class DeviceToDeviceHeuristicScanner {
private let scanQueue = DispatchQueue(label: "DeviceToDeviceScanQueue") private let scanQueue = DispatchQueue(label: "DeviceToDeviceScanQueue")
private var reachableIPs = [String]()
private var totalToScan = 0 private var reachableIPs: [String] = []
private var activeConnections: [NWConnection] = []
private var completion: ((Bool) -> Void)? private var completion: ((Bool) -> Void)?
private var finished = false
private var currentMode: ScanMode = .peerReachability
// Cache: once true, stays true for subsequent calls
private var cachedLocalNetworkAccess = false
private var peerReachableCached: Bool { !reachableIPs.isEmpty }
// MARK: - Public API
/// Start scanning the subnet for reachable peers. /// Start scanning the subnet for reachable peers.
/// - Parameters: /// - Returns: true if any peer device was reachable (same semantics as before).
/// - baseSubnet: e.g. `"192.168.1"` — if nil, will auto-detect func scan(subnet baseSubnet: String? = nil,
/// - completion: true if any peer device was reachable port: UInt16 = 80,
func scan(subnet baseSubnet: String? = nil, port: UInt16 = 80, completion: @escaping (Bool) -> Void) { timeout: TimeInterval = 10.0,
self.completion = completion completion: @escaping (Bool) -> Void) {
let subnet = baseSubnet ?? Self.getLocalSubnetPrefix() ?? "192.168.1"
let ipsToScan = (2...254).map { "\(subnet).\($0)" }
totalToScan = ipsToScan.count
for ip in ipsToScan { if peerReachableCached {
testConnection(to: ip, port: port) completion(true)
return
} }
// Timeout fallback startScan(
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { mode: .peerReachability,
self.finish() subnet: baseSubnet,
port: port,
timeout: timeout,
completion: completion
)
}
/// Returns true if:
/// - cached value is true, OR
/// - during a scan at least one host produces `.waiting(.posix(.EHOSTDOWN))`.
///
/// As soon as EHOSTDOWN is observed, returns true immediately.
/// Otherwise waits until timeout (default 10s) and returns false.
func hasLocalNetworkAccess(subnet baseSubnet: String? = nil,
port: UInt16 = 80,
timeout: TimeInterval = 10.0,
completion: @escaping (Bool) -> Void) -> Bool {
if cachedLocalNetworkAccess {
return true
}
startScan(
mode: .localNetworkAccess,
subnet: baseSubnet,
port: port,
timeout: timeout,
completion: completion
)
return false
}
// MARK: - Core scan logic
private func startScan(mode: ScanMode,
subnet baseSubnet: String?,
port: UInt16,
timeout: TimeInterval,
completion: @escaping (Bool) -> Void) {
scanQueue.async {
// Cancel any in-flight scan to keep behavior deterministic.
self.cancelActiveConnections()
self.currentMode = mode
self.completion = completion
self.finished = false
let subnet = baseSubnet ?? Self.getLocalSubnetPrefix() ?? "192.168.1"
let ipsToScan = (2...254).map { "\(subnet).\($0)" }
for ip in ipsToScan {
self.testConnection(to: ip, port: port)
}
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
self.finish(result: self.resultForCurrentModeOnTimeout())
}
} }
} }
private func testConnection(to ip: String, port: UInt16) { private func testConnection(to ip: String, port: UInt16) {
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return } guard let nwPort = NWEndpoint.Port(rawValue: port) else { return }
let connection = NWConnection(host: NWEndpoint.Host(ip), port: nwPort, using: .tcp)
connection.stateUpdateHandler = { state in let connection = NWConnection(host: NWEndpoint.Host(ip), port: nwPort, using: .tcp)
activeConnections.append(connection)
connection.stateUpdateHandler = { [weak self] state in
guard let self else { return }
switch state { switch state {
case .ready: case .ready:
self.reachableIPs.append(ip) self.scanQueue.async {
self.finish() self.reachableIPs.append(ip)
// If a TCP connection succeeds, local network access is effectively confirmed as well.
self.cachedLocalNetworkAccess = true
// For scan(): success means peer reachable.
// For hasLocalNetworkAccess(): success also means local network access.
self.finish(result: true)
}
connection.cancel() connection.cancel()
case .waiting(let error):
// "Host is down" is treated as a strong signal that local-network traffic is possible,
// even if the specific host doesn't respond.
if error == .posix(.EHOSTDOWN) {
self.scanQueue.async {
self.cachedLocalNetworkAccess = true
if self.currentMode == .localNetworkAccess {
self.finish(result: true)
}
}
}
case .failed, .cancelled: case .failed, .cancelled:
connection.cancel() // Ignore; scan completes via early success or timeout.
break
default: default:
break break
} }
@@ -54,11 +156,38 @@ class DeviceToDeviceHeuristicScanner {
connection.start(queue: scanQueue) connection.start(queue: scanQueue)
} }
private func finish() { private func resultForCurrentModeOnTimeout() -> Bool {
completion?(reachableIPs.count > 0) switch currentMode {
completion = nil case .peerReachability:
return peerReachableCached
case .localNetworkAccess:
return cachedLocalNetworkAccess
}
} }
private func finish(result: Bool) {
scanQueue.async {
guard !self.finished else { return }
self.finished = true
let completion = self.completion
self.completion = nil
self.cancelActiveConnections()
DispatchQueue.main.async {
completion?(result)
}
}
}
private func cancelActiveConnections() {
for c in activeConnections { c.cancel() }
activeConnections.removeAll()
}
// MARK: - Subnet detection
/// Attempts to detect the local subnet prefix (e.g., "192.168.0") /// Attempts to detect the local subnet prefix (e.g., "192.168.0")
private static func getLocalSubnetPrefix() -> String? { private static func getLocalSubnetPrefix() -> String? {
var ifaddr: UnsafeMutablePointer<ifaddrs>? var ifaddr: UnsafeMutablePointer<ifaddrs>?
@@ -68,20 +197,27 @@ class DeviceToDeviceHeuristicScanner {
defer { ptr = ptr!.pointee.ifa_next } defer { ptr = ptr!.pointee.ifa_next }
let interface = ptr!.pointee let interface = ptr!.pointee
let addrFamily = interface.ifa_addr.pointee.sa_family let addrFamily = interface.ifa_addr.pointee.sa_family
if addrFamily == UInt8(AF_INET), if addrFamily == UInt8(AF_INET),
let name = String(validatingUTF8: interface.ifa_name), let name = String(validatingUTF8: interface.ifa_name),
name == getActiveNetworkInterface() { name == getActiveNetworkInterface() {
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
getnameinfo(interface.ifa_addr, socklen_t(interface.ifa_addr.pointee.sa_len), getnameinfo(interface.ifa_addr,
&hostname, socklen_t(hostname.count), nil, 0, NI_NUMERICHOST) socklen_t(interface.ifa_addr.pointee.sa_len),
&hostname,
socklen_t(hostname.count),
nil,
0,
NI_NUMERICHOST)
if let ip = String(validatingUTF8: hostname), if let ip = String(validatingUTF8: hostname),
let lastDot = ip.lastIndex(of: ".") { let lastDot = ip.lastIndex(of: ".") {
freeifaddrs(ifaddr) freeifaddrs(ifaddr)
let result = String(ip[..<lastDot]) let result = String(ip[..<lastDot])
log("[LUI] Found local subnet prefix: \(result) for interface \(name)") log("[LUI] Found local subnet prefix: \(result) for interface \(name)")
return result return result
} }
} }
+7 -2
View File
@@ -19,9 +19,14 @@ func sendToSharingService(items: [Any]) {
let alert = NSAlert() let alert = NSAlert()
alert.alertStyle = .critical alert.alertStyle = .critical
alert.messageText = "ShareExtensionDisabledTitle".localized() alert.messageText = "ShareExtensionDisabledTitle".localized()
alert.informativeText = "ShareExtensionDisabledDescription".localized() if #available(macOS 13.0, *) {
alert.informativeText = "ShareExtensionDisabledDescription".localized()
}
else {
alert.informativeText = "ShareExtensionDisabledDescriptionLegacy".localized()
}
alert.addButton(withTitle: "CloseAlert".localized()) alert.addButton(withTitle: "CloseAlert".localized())
alert.runModal() ModelessAlertPresenter.present(alert)
} }
} }
} }
@@ -0,0 +1,65 @@
//
// AlertManager.swift
// QuickDrop
//
// Created by Leon Böttger on 01.02.26.
//
import LUI
import SwiftUI
public final class IntroductionSheetManager: ObservableObject {
public static let sharedInstance = IntroductionSheetManager()
private var sheetView: NSPanel? = nil
private var sheetAttachedWindow: NSWindow? = nil
func openIntroductionWindow(on window: NSWindow, startReceiving: @escaping () -> Void, onFinish: @escaping () -> Void) {
openSheetWindow(on: window, contentView: IntroductionView(startReceiving: startReceiving, onFinish: {
self.closeSheetWindow()
onFinish()
}))
}
private func openSheetWindow<Content: View>(on window: NSWindow, contentView: Content) {
if sheetView == nil {
// Create the panel
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: IntroductionView.width, height: IntroductionView.height),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
// Set content (can be a NSHostingView for SwiftUI views)
let hostingView = NSHostingView(rootView: contentView)
panel.contentView = hostingView
// Present as sheet
window.beginSheet(panel, completionHandler: { response in
log("[IntroductionSheetManager] Sheet dismissed with response: \(response)")
})
self.sheetAttachedWindow = window
self.sheetView = panel
}
}
func closeSheetWindow() {
if let mainWindow = sheetAttachedWindow, let sheetView = sheetView {
log("[IntroductionSheetManager] Closing sheetView window")
mainWindow.endSheet(sheetView)
self.sheetView = nil
self.sheetAttachedWindow = nil
}
}
}
@@ -0,0 +1,443 @@
//
// IntroductionView.swift
// QuickDrop
//
// Created by Leon Böttger on 20.12.25.
//
import SwiftUI
import LUI
struct IntroductionView: View {
static let width: CGFloat = 810
static let height: CGFloat = 540
@Environment(\.colorScheme) private var colorScheme
@Environment(\.scenePhase) private var scenePhase
@ObservedObject private var bluetooth = BluetoothPermissionManager.shared
@State private var currentPage: IntroductionPage = .splash
@State private var pollingPage: IntroductionPage? = nil
@State private var skipAction: IntroductionPage.SkipAction? = nil
@State private var helpActionAlert: IntroductionPage.HelpAction? = nil
let startReceiving: (() -> Void)
let onFinish: (() -> Void)
var body: some View {
GeometryReader { geo in
ZStack {
ZStack {
if colorScheme == .dark {
Color.black.opacity(0.2)
} else {
Color.white
}
}
.ignoresSafeArea()
VStack {
let topHeight = geo.size.height * 0.35
ZStack {
Color.gray.opacity(colorScheme == .light ? 0.1 : 0.12)
currentPage.topHeader(with: topHeight)
}
.edgesIgnoringSafeArea(.top)
.frame(height: topHeight)
Spacer()
VStack {
Text(currentPage.title.localized())
.font(.system(size: 32))
.fontWeight(.semibold)
.multilineTextAlignment(.center)
.minimumScaleFactor(0.5)
.lineLimit(1)
Text(currentPage.subtitle.localized())
.font(.system(size: 15))
.lineSpacing(5)
.multilineTextAlignment(.center)
.padding(.top)
.minimumScaleFactor(0.5)
.opacity(0.8)
if currentPage == .enableShareExtension {
EnableExtensionView(canShowIntialCheckmark: true)
}
}
.padding(.horizontal, 40)
Spacer()
if currentPage == .splash {
BottomFootnoteView(type: .privacyBanner)
}
else {
Spacer()
}
Divider()
HStack {
if let skipAction = currentPage.skipAction, pollingPage != nil {
Button("introduction_skip") {
self.skipAction = skipAction
}
.foregroundColor(.gray)
.buttonStyle(.borderless)
.alert(item: $skipAction) { action in
.init(title: Text(action.warningTitle.localized()), message: Text(action.warningMessage.localized()), primaryButton: .default(Text("introduction_continue".localized()), action: {
stopPolling()
self.skipAction = nil
if let nextPage = action.action() {
currentPage = nextPage
}
else {
onFinish()
}
}), secondaryButton: .cancel()
)
}
}
Spacer()
continueArea
}
.padding(.horizontal)
.padding(.bottom, 10)
}
}
}
.onAppear { pageDidChange(to: currentPage) }
.onChange(of: currentPage) { newValue in
pageDidChange(to: newValue)
}
.onChange(of: scenePhase) { newValue in
guard newValue == .active, currentPage == .bluetoothAccess else { return }
refreshBluetoothPermissionAndAdvanceIfAllowed()
}
.task(id: pollingPage) {
guard let page = pollingPage else { return }
// Poll every 1 second until condition is satisfied or canceled
while !Task.isCancelled {
if page.canContinue() {
await MainActor.run {
// Only advance if we're still on the page we started polling for
guard currentPage == page else {
stopPolling()
return
}
stopPolling()
advance()
}
return
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
}
@ViewBuilder
private var continueArea: some View {
ZStack {
let isLoading = pollingPage != nil
Button("introduction_continue") {
continueTapped()
}
.buttonStyle(.bordered)
.controlSize(.large)
.keyboardShortcut(.defaultAction)
.opacity(isLoading ? 0 : 1)
.overlay (
LoadingIndicator()
.frame(width: 20, height: 20)
.opacity(isLoading ? 1 : 0)
.frame(maxWidth: .infinity, alignment: .trailing)
)
.padding(.horizontal)
.padding(.vertical, 10)
}
}
private func pageDidChange(to newPage: IntroductionPage) {
stopPolling()
// Cannot perform action, default to loading screen
if !newPage.canPerformAction {
pollingPage = newPage
}
}
private func continueTapped() {
if currentPage == .bluetoothAccess {
continueBluetoothPermission()
return
}
// If already allowed, advance immediately
if currentPage.canContinue() {
advance()
return
}
// Not allowed yet: show loading and start polling every 1s
pollingPage = currentPage
}
private func triggerLocalNetworkPermission() {
self.startReceiving()
}
private func continueBluetoothPermission() {
bluetooth.refresh()
if bluetooth.isAllowed {
advance()
return
}
pollingPage = .bluetoothAccess
guard bluetooth.canRequestPermission else {
bluetooth.openSystemBluetoothSettings()
return
}
bluetooth.requestPermission { allowed in
DispatchQueue.main.async {
guard currentPage == .bluetoothAccess else {
stopPolling()
return
}
if allowed {
stopPolling()
advance()
}
else {
pollingPage = .bluetoothAccess
}
}
}
}
private func refreshBluetoothPermissionAndAdvanceIfAllowed() {
bluetooth.refresh()
guard currentPage == .bluetoothAccess, bluetooth.isAllowed else { return }
stopPolling()
advance()
}
private func advance() {
if currentPage == .localNetworkAccess {
let _ = DeviceToDeviceHeuristicScanner.shared.hasLocalNetworkAccess(completion: {_ in })
triggerLocalNetworkPermission()
}
if let nextPage = currentPage.presentNextPage() {
currentPage = nextPage
} else {
onFinish()
}
}
private func stopPolling() {
// Cancels the .task(id:) by changing the id and resets UI state
pollingPage = nil
}
}
enum IntroductionPage: CaseIterable {
case splash
case noWifi
case localNetworkAccess
case bluetoothAccess
case enableShareExtension
case finished
var title: String {
switch self {
case .splash:
return "WelcomeToQuickDrop"
case .noWifi:
return "introduction_no_wifi"
case .localNetworkAccess:
return "introduction_local_network_access"
case .bluetoothAccess:
return "BluetoothPermissionRequiredTitle"
case .enableShareExtension:
return "introduction_enable_share_extension"
case .finished:
return "introduction_finished"
}
}
var subtitle: String {
switch self {
case .splash:
return "QuickDropWelcomeDescription"
case .noWifi:
return "introduction_no_wifi_description"
case .localNetworkAccess:
return "introduction_local_network_access_description".localized(with: "introduction_continue".localized())
case .bluetoothAccess:
return "BluetoothPermissionRequiredSubtitle"
case .enableShareExtension:
if #available(macOS 13.0, *) {
return "introduction_enable_share_extension_description".localized(with: "EnableQuickDropExtension".localized())
}
else {
return "introduction_enable_share_extension_description_legacy".localized(with: "EnableQuickDropExtension".localized())
}
case .finished:
return "introduction_finished_description"
}
}
var skipAction: SkipAction? {
switch self {
case .noWifi:
return .init(action: presentNextPage, warningTitle: "introduction_no_wifi_skip_title", warningMessage: "introduction_no_wifi_skip_message")
case .bluetoothAccess:
return .init(action: { .enableShareExtension }, warningTitle: "BluetoothPermissionSkipAlertTitle", warningMessage: "BluetoothPermissionSkipAlertMessage")
default:
return nil
}
}
var canPerformAction: Bool {
switch self {
case .noWifi:
return false
default:
return true
}
}
func presentNextPage() -> IntroductionPage? {
switch self {
case .splash:
return NearbyConnectionManager.shared.isConnectedToLocalNetwork ? firstPermissionPane() : .noWifi
case .noWifi:
return firstPermissionPane()
case .localNetworkAccess:
return .bluetoothAccess
case .bluetoothAccess:
return .enableShareExtension
case .enableShareExtension:
return .finished
case .finished:
return nil
}
}
private func firstPermissionPane() -> IntroductionPage {
if #available(macOS 15.0, *) {
// Local network access is requested first on macOS 15+
return .localNetworkAccess
}
return .bluetoothAccess
}
func canContinue() -> Bool {
switch self {
case .noWifi:
return NearbyConnectionManager.shared.isConnectedToLocalNetwork
case .bluetoothAccess:
return BluetoothPermissionManager.shared.isAllowed
default:
return true
}
}
func topHeader(with height: CGFloat) -> some View {
ZStack {
switch self {
case .splash:
Image(.quickDropMockup)
.resizable()
.scaledToFit()
.padding(.top, height * 0.33)
.padding(.bottom, height * 0.1)
case .noWifi:
imageView(for: "wifi.slash", with: height)
case .localNetworkAccess:
imageView(for: "network", with: height)
case .bluetoothAccess:
imageView(for: "antenna.radiowaves.left.and.right", with: height)
case .enableShareExtension:
imageView(for: "square.and.arrow.up", with: height)
case .finished:
imageView(for: "checkmark.seal.fill", with: height)
}
}
.foregroundColor(.blue)
}
private func imageView(for image: String, with height: CGFloat) -> some View {
Image(systemName: image)
.resizable()
.scaledToFit()
.frame(height: height * 0.5)
.padding(.top, height * 0.13)
}
struct SkipAction: Identifiable {
let action: () -> IntroductionPage?
let warningTitle: String
let warningMessage: String
var id: String { warningTitle }
}
struct HelpAction: Identifiable {
let question: String
let answer: String
var id: String { question }
}
}
#Preview {
IntroductionView(startReceiving: {}){}
.frame(width: IntroductionView.width, height: IntroductionView.height)
}
@@ -0,0 +1,24 @@
//
// AndroidAirDropModeIssueView.swift
// QuickDrop
//
// Created by Leon Böttger on 10.06.26.
//
import Foundation
import SwiftUI
struct AndroidAirDropModeIssueView: View {
var body: some View {
IssueView(
image: .smartphone,
header: "AndroidAirDropModeHeader".localized(),
description: "AndroidAirDropModeDescription".localized()
)
}
}
#Preview {
AndroidAirDropModeIssueView()
.frame(width: issueViewWidth, height: issueViewHeight)
}
@@ -9,6 +9,7 @@ import SwiftUI
import Foundation import Foundation
import AppKit import AppKit
import Network import Network
import LUI
fileprivate let routerCheckQueue = DispatchQueue(label: "routerCheckQueue") fileprivate let routerCheckQueue = DispatchQueue(label: "routerCheckQueue")
@@ -17,7 +18,7 @@ struct ApIsolationIssueView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
let closeView : () -> Void let closeView : () -> Void
let scanner = DeviceToDeviceHeuristicScanner() let scanner = DeviceToDeviceHeuristicScanner.shared
var body: some View { var body: some View {
@@ -70,7 +71,7 @@ func openRouterSettingsPage() async {
alert.informativeText = "ApIsolationFindRouterFailedDescription".localized() alert.informativeText = "ApIsolationFindRouterFailedDescription".localized()
alert.addButton(withTitle: "CloseAlert".localized()) alert.addButton(withTitle: "CloseAlert".localized())
alert.runModal() ModelessAlertPresenter.present(alert)
} }
return return
} }
+1 -2
View File
@@ -102,7 +102,6 @@ struct IssueView: View {
#Preview { #Preview {
NetworkFilterIssueView() IssueView(image: .smartphone, header: "Header", description: "Description")
.frame(width: 600, height: 400) .frame(width: 600, height: 400)
} }
@@ -0,0 +1,95 @@
//
// PairingQrSheetManager.swift
// QuickDrop
//
// Created by Leon Böttger on 2026-03-15.
//
import AppKit
import SwiftUI
final class PairingQrSheetManager: NSObject, NSWindowDelegate {
static let shared = PairingQrSheetManager()
private var alert: NSAlert?
private weak var alertWindow: NSWindow?
func open(
token: String,
receiverFingerprint: String,
deviceName: String,
useCase: PairingUseCase
) {
guard alert == nil else { return }
guard let qrImage = PairingToken.makeQrImage(
token: token,
receiverFingerprint: receiverFingerprint,
useCase: useCase
) else { return }
let alert = NSAlert()
alert.messageText = "PairingQrSheetTitle".localized()
alert.informativeText = "PairingQrSheetDescription".localized(with: deviceName)
alert.addButton(withTitle: "Cancel".localized())
alert.alertStyle = .informational
let accessoryView = NSHostingView(rootView: PairingQrAccessoryView(qrImage: qrImage))
accessoryView.frame = NSRect(x: 0, y: 0, width: 180, height: 180)
alert.accessoryView = accessoryView
alert.layout()
alert.buttons.forEach { button in
button.target = self
button.action = #selector(handleAlertButton(_:))
}
self.alert = alert
let alertWindow = alert.window
alertWindow.delegate = self
self.alertWindow = alertWindow
if let screen = NSApp.mainWindow?.screen ?? NSScreen.main {
let frame = alertWindow.frame
let screenFrame = screen.visibleFrame
let origin = NSPoint(
x: screenFrame.midX - (frame.width / 2),
y: screenFrame.midY - (frame.height / 2)
)
alertWindow.setFrameOrigin(origin)
} else {
alertWindow.center()
}
NSApp.activate(ignoringOtherApps: true)
alertWindow.makeKeyAndOrderFront(nil)
}
func close() {
alertWindow?.close()
alert = nil
alertWindow = nil
}
func windowWillClose(_ notification: Notification) {
alert = nil
alertWindow = nil
}
@objc private func handleAlertButton(_ sender: NSButton) {
close()
}
}
private struct PairingQrAccessoryView: View {
let qrImage: Image
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.white)
qrImage
.resizable()
.interpolation(.none)
.aspectRatio(1, contentMode: .fit)
.padding(10)
}
.frame(width: 160, height: 160)
}
}
-147
View File
@@ -1,147 +0,0 @@
//
// IncomingFileTransmissionView.swift
// QuickDrop
//
// Created by Leon Böttger on 05.10.25.
//
import SwiftUI
import AppKit
let toastViewSize = CGSize(width: 400, height: 100)
struct QuickDropToastView: View {
@ObservedObject var settings = Settings.shared
@ObservedObject var receiveModel: ReceiveModel
public var onCancel: () -> Void = {}
init(receiveModel: ReceiveModel,
onCancel: @escaping () -> Void = {}) {
self.receiveModel = receiveModel
self.onCancel = onCancel
}
public var body: some View {
VStack(spacing: 12) {
HStack(spacing: 12) {
ZStack(alignment: .bottomLeading) {
Image(.quickDropIcon)
.resizable()
.scaledToFill()
.frame(width: 44, height: 44)
.clipShape(Circle())
}
VStack(alignment: .leading, spacing: 2) {
Text(String("QuickDrop"))
.font(.system(size: 16, weight: .semibold))
Text("Receiving")
.font(.system(size: 13))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 12)
}
HStack() {
CapsuleProgress(value: receiveModel.progress ?? 1.0)
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
.frame(width: 18, height: 18)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(16)
.background(
VisualEffectView(material: .hudWindow)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.black.opacity(0.06), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.1), radius: 20)
.frame(maxWidth: .infinity)
.opacity(isFileTransferRestricted() ? 0 : 1)
}
}
// NSVisualEffectView wrapper for macOS 11 compatible blur/vibrancy
struct VisualEffectView: NSViewRepresentable {
var material: NSVisualEffectView.Material = .hudWindow
var blendingMode: NSVisualEffectView.BlendingMode = .withinWindow
var state: NSVisualEffectView.State = .active
func makeNSView(context: Context) -> NSVisualEffectView {
let v = NSVisualEffectView()
v.material = material
v.blendingMode = blendingMode
v.state = state
v.isEmphasized = true
return v
}
func updateNSView(_ v: NSVisualEffectView, context: Context) {
v.material = material
v.blendingMode = blendingMode
v.state = state
}
}
// Capsule-style linear progress (0...1)
struct CapsuleProgress: View {
var value: Double
var track = Color(NSColor.separatorColor).opacity(0.28)
var fill = Color(NSColor.systemBlue)
var body: some View {
GeometryReader { geo in
let progress = max(0, min(1, value))
ZStack(alignment: .leading) {
Capsule()
.fill(track)
Capsule() // Use a plain rectangle
.fill(fill)
.mask(
Capsule()
.frame(width: progress * geo.size.width)
.frame(maxWidth: .infinity, alignment: .leading)
)
}
}
.frame(height: 8)
.animation(.easeInOut(duration: 0.25), value: value)
}
}
// MARK: - Preview
struct QuickDropToastView_Previews: PreviewProvider {
struct Demo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.padding()
.frame(width: toastViewSize.width, height: toastViewSize.height)
.onAppear {
model.progress = 0.01
}
}
}
static var previews: some View { Demo() }
}
@@ -7,10 +7,18 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
import LUI
struct SettingsView: View { struct SettingsView: View {
@ObservedObject var settings = Settings.shared let openPlus: () -> Void
@ObservedObject var settings = Settings.sharedInstance
@ObservedObject var connectionManager = NearbyConnectionManager.shared
@ObservedObject var iapManager = IAPManager.sharedInstance
@State private var isChangeDeviceNameAlertPresented = false
@State private var licenseWindow: NSWindow?
var body: some View { var body: some View {
@@ -31,12 +39,23 @@ struct SettingsView: View {
Text("SaveFilesTo") Text("SaveFilesTo")
Spacer() Spacer()
Text(getSavedFolderPath() ?? "DownloadsFolder".localized()) Text(getSavedFolderPath() ?? "DownloadsFolder".localized())
.foregroundColor((settings.saveFolderBookmark ?? Data()).isEmpty ? .gray : .primary) .foregroundColor(.gray)
Button("SaveFilesToButton") { Button("SaveFilesToButton") {
selectFolder() selectFolder()
} }
} }
HStack {
Text("DeviceName")
Spacer()
Text(connectionManager.deviceInfo.name ?? "Unknown".localized())
.foregroundColor(.gray)
Button("Edit") {
presentRenameAlert()
}
}
Divider() Divider()
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -53,17 +72,33 @@ struct SettingsView: View {
Divider() Divider()
.padding(.vertical, 10) .padding(.vertical, 10)
Button { HStack(spacing: 30) {
openTrustedDevicesWindow() SettingsFooterButton("ManageTrustedDevices") {
} label: { openTrustedDevicesWindow()
Text("ManageTrustedDevices") }
.underline()
.font(.subheadline) SettingsFooterButton("Acknowledgements") {
.opacity(0.5) licenseWindow = openLicenseWindow()
.frame(maxWidth: .infinity) }
.padding(.vertical, 15)
#if !GITHUB
if !iapManager.plusVersionState {
SettingsFooterButton("SupportQuickDrop") {
openPlus()
}
}
#endif
#if DEBUG
SettingsFooterButton("ResetAllSettings") {
IAPManager.sharedInstance.plusVersionState = false
Settings.sharedInstance.deleteAllUserDefaults()
}
#endif
} }
.buttonStyle(PlainButtonStyle()) .minimumScaleFactor(0.7)
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
} }
} }
} }
@@ -100,8 +135,8 @@ struct SettingsView: View {
let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
Settings.shared.saveFolderBookmark = bookmarkData Settings.sharedInstance.saveFolderBookmark = bookmarkData
Settings.shared.openFinderAfterReceiving = true Settings.sharedInstance.openFinderAfterReceiving = true
} catch { } catch {
log("Failed to save security-scoped bookmark: \(error)") log("Failed to save security-scoped bookmark: \(error)")
@@ -116,7 +151,7 @@ struct SettingsView: View {
alert.informativeText = "FolderSelectionFailedMessage".localized() alert.informativeText = "FolderSelectionFailedMessage".localized()
alert.addButton(withTitle: "CloseAlert".localized()) alert.addButton(withTitle: "CloseAlert".localized())
alert.runModal() ModelessAlertPresenter.present(alert)
} }
} }
} }
@@ -133,18 +168,77 @@ struct SettingsView: View {
window.center() window.center()
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
window.title = "TrustedDevices".localized() window.title = "TrustedDevices".localized()
window.backgroundColor = NSColor(Color.defaultMacBackgroundColor)
window.contentView = NSHostingView(rootView: TrustedDevicesView()) window.contentView = NSHostingView(rootView: TrustedDevicesView())
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
window.level = .normal window.level = .normal
} }
private func presentRenameAlert() {
guard let window = NSApplication.shared.keyWindow else { return }
let alert = NSAlert()
alert.messageText = "ChangeDeviceName".localized()
alert.informativeText = "ChangeDeviceNameDescription".localized()
alert.alertStyle = .informational
let textField = NSTextField(string: NearbyConnectionManager.shared.deviceInfo.name ?? "")
textField.translatesAutoresizingMaskIntoConstraints = false
textField.frame = NSRect(x: 0, y: 0, width: 260, height: 24)
alert.accessoryView = textField
alert.addButton(withTitle: "Save".localized())
alert.addButton(withTitle: "Cancel".localized())
alert.beginSheetModal(for: window) { response in
if response == .alertFirstButtonReturn {
let trimmed = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
NearbyConnectionManager.shared.setCustomDeviceName(to: trimmed)
}
}
DispatchQueue.main.async {
let alertWindow = alert.window
alertWindow.makeFirstResponder(textField)
if let editor = alertWindow.fieldEditor(true, for: textField) as? NSTextView {
editor.selectedRange = NSRange(location: 0, length: textField.stringValue.count)
}
}
}
} }
struct SettingsView_Previews: PreviewProvider { struct SettingsView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
SettingsView() SettingsView(openPlus: {})
.frame(width: 800, height: 500) .frame(width: 800, height: 500)
} }
} }
private struct SettingsFooterButton: View {
let title: String
let action: () -> Void
init(_ title: String, action: @escaping () -> Void) {
self.title = title
self.action = action
}
var body: some View {
Button {
action()
} label: {
Text(title.localized())
.underline()
.font(.subheadline)
.lineLimit(1)
.opacity(0.5)
}
.buttonStyle(.plain)
}
}
@@ -0,0 +1,104 @@
//
// TrustedDevicesView.swift
// QuickDrop
//
// Created by Leon Böttger on 23.08.25.
//
import LUI
import SwiftUI
struct TrustedDevicesView: View {
@StateObject private var store = TrustStore.shared
@State private var deviceToRemove: String? = nil
@State private var showRemoveAlert = false
var body: some View {
NavigationSubView(header: isMac() ? "" : "TrustedDevices") {
VStack {
if store.trustedCertificates.isEmpty {
VStack(spacing: 12) {
Text("NoTrustedDevices")
.font(.headline)
.foregroundColor(.secondary)
Text("TrustedDevicesDescription")
.font(.subheadline)
.foregroundColor(.secondary)
}
.multilineTextAlignment(.center)
.padding()
.frame(maxHeight: .infinity)
} else {
CustomSection(footer: "TrustedDevicesDescription") {
devicesView
.padding(.vertical, 4)
}
}
}
.padding(.top, isMac() ? 18 : 0)
}
.animation(.default, value: store.trustedCertificates)
.alert(isPresented: $showRemoveAlert) {
Alert(
title: Text("RemoveTrustedDeviceAlertTitle"),
message: Text("RemoveTrustedDeviceAlertDescription"),
primaryButton: .destructive(Text("Remove")) {
if let key = deviceToRemove {
store.removeTrusted(secretIdHex: key)
}
},
secondaryButton: .cancel()
)
}
}
var devicesView: some View {
let entries = store.trustedCertificates.sorted { $0.value.creationDate < $1.value.creationDate }
return ForEach(entries, id: \.key) { key, cert in
HStack {
VStack(alignment: .leading, spacing: 4) {
LUIText(cert.device.name ?? "Unknown".localized(), isBold: true)
LUIText(Self.dateFormatter.string(from: cert.creationDate), color: .mainColor.opacity(0.7))
}
Spacer()
LUIButton {
deviceToRemove = key
showRemoveAlert = true
} label: {
ReorderListIcon(imageName: "minus.circle.fill", color: .red)
}
}
.padding(.vertical, 8)
}
}
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter
}()
}
#Preview {
TrustedDevicesView()
.onAppear {
var cert1 = Sharing_Nearby_PublicCertificate()
cert1.secretID = Data([0x01, 0x02, 0x03, 0x04])
TrustStore.shared.addTrusted(certificate: cert1, device: RemoteDeviceInfo(name: "MacBook Pro", type: .computer))
var cert2 = Sharing_Nearby_PublicCertificate()
cert2.secretID = Data([0x0A, 0x0B, 0x0C, 0x0D])
TrustStore.shared.addTrusted(certificate: cert2, device: RemoteDeviceInfo(name: "iPhone", type: .phone))
}
}
@@ -0,0 +1,646 @@
//
// IncomingFileTransmissionView.swift
// QuickDrop
//
// Created by Leon Böttger on 05.10.25.
//
import AppKit
import LUI
import SwiftUI
let toastViewSize = CGSize(width: 340, height: 65)
let toastCornerRadius: CGFloat = 22
let toastTrailingPadding: CGFloat = 20
let toastSlideDistance: CGFloat = 420
let toastSlideAnimationDuration: TimeInterval = 0.5
struct QuickDropToastView: View {
@ObservedObject var settings = Settings.sharedInstance
@ObservedObject var receiveModel: ReceiveModel
let animateProgressVisibility: Bool
@Environment(\.colorScheme) var colorScheme
@State var autoHider = DispatchWorkItem(block: {})
@State private var isHovering = false
/// Cancel while receiving.
public var onCancel: () -> Void
init(
receiveModel: ReceiveModel,
animateProgressVisibility: Bool = true,
onCancel: @escaping () -> Void = {}
) {
self.receiveModel = receiveModel
self.animateProgressVisibility = animateProgressVisibility
self.onCancel = onCancel
}
private func startAutoHide(_ actions: ReceiveModel.ToastViewAction) {
autoHider.cancel()
autoHider = DispatchWorkItem(block: {
actions.autoHideAction()
})
DispatchQueue.main.asyncAfter(deadline: .now() + actions.autoHideDelay, execute: autoHider)
}
private func startAutoHideIfPossible() {
guard !isHovering, let actions = receiveModel.toastActions else { return }
startAutoHide(actions)
}
public var body: some View {
let consent = receiveModel.consentState
let actions = receiveModel.toastActions
let hasActionButtons = actions?.openFilesAction != nil || actions?.importPhotosAction != nil
let deviceName = receiveModel.activeDeviceName ?? "AndroidDevice".localized()
let isNotificationSyncToast = consent?.notificationSyncStage != nil
let isNotificationSyncConsent = consent?.notificationSyncStage == .consent
let isNotificationSyncPinStage = consent?.notificationSyncStage == .pin
let showConsentActions = consent != nil && (!isNotificationSyncToast || isNotificationSyncConsent)
let showsRightColumn = showConsentActions || hasActionButtons
let subHeaderSize = 21.0
let closeButtonVisible = isHovering && (actions != nil || isNotificationSyncPinStage)
ZStack(alignment: .topTrailing) {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
let iconSize = 32.0
let headerFont = Font.system(.body).bold()
let subheaderFont = Font.system(.body)
let subheaderColor = Color.secondary
AppIconView(hasPlusIcon: false, size: iconSize, supportsDarkModeShadow: false)
.frame(width: iconSize, height: iconSize)
VStack(alignment: .leading, spacing: 1) {
if let consent = consent {
if isNotificationSyncToast {
HStack {
Text(String("QuickDrop"))
.font(headerFont)
.lineLimit(1)
Text("FromDevice".localized(with: deviceName))
.font(subheaderFont)
.foregroundColor(subheaderColor)
.lineLimit(1)
.truncationMode(.tail)
}
Text(consent.message)
.font(subheaderFont)
.foregroundColor(subheaderColor)
.lineLimit(2)
.truncationMode(.tail)
} else {
Text(verbatim: "QuickDrop | \(consent.pinCodeMessage)")
.font(headerFont)
.lineLimit(1)
Text(consent.message)
.font(subheaderFont)
.foregroundColor(subheaderColor)
.lineLimit(2)
.truncationMode(.tail)
}
} else {
HStack {
Text(String("QuickDrop"))
.font(headerFont)
.lineLimit(1)
if consent == nil, actions == nil {
Text("FromDevice".localized(with: deviceName))
.font(subheaderFont)
.foregroundColor(subheaderColor)
.lineLimit(1)
.truncationMode(.tail)
}
}
if consent == nil, actions == nil {
HStack {
ZStack {
CapsuleProgress(value: 0.5)
.opacity(0)
if let progress = receiveModel.progress {
CapsuleProgress(value: progress)
}
}
.animation(
animateProgressVisibility
? .easeInOut
: .easeInOut.delay(toastSlideAnimationDuration),
value: receiveModel.progress == nil
)
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
.frame(width: 18, height: 18)
}
.buttonStyle(PlainButtonStyle())
}
.padding(.trailing, 16)
.frame(height: subHeaderSize)
}
else {
Text(LocalizedStringKey(actions?.completionMessageKey ?? "FileTransferCompleted"))
.font(.system(size: 13))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(height: subHeaderSize)
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
if showsRightColumn {
ActionColumn() {
if showConsentActions, let consent = consent {
ActionButtonRow {
QuickDropToastViewButton(title: "Decline") {
consent.declineAction()
}
}
ActionButtonRow {
if consent.allowsTrust {
QuickDropToastViewMenuButton(title: "Accept") {
Button("AcceptOnce".localized()) {
consent.acceptAction(false)
}
Button("AutoAcceptFromThisDevice".localized()) {
consent.acceptAction(true)
}
}
} else {
QuickDropToastViewButton(title: "Accept") {
consent.acceptAction(false)
}
}
}
} else if let actions = actions {
if let openFilesAction = actions.openFilesAction {
ActionButtonRow {
QuickDropToastViewButton(title: "OpenInFinder") {
openFilesAction()
actions.closeToastAction()
}
}
}
if let onImportToPhotos = actions.importPhotosAction {
ActionButtonRow {
QuickDropToastViewButton(title: "ImportToPhotos") {
onImportToPhotos()
actions.closeToastAction()
}
}
}
}
}
}
}
}
.padding(.leading, 16)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.contentShape(Rectangle())
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
if hovering {
autoHider.cancel()
} else {
startAutoHideIfPossible()
}
}
.onChange(of: receiveModel.toastActions != nil) { hasActions in
if hasActions {
startAutoHideIfPossible()
} else {
autoHider.cancel()
}
}
.onAppear {
startAutoHideIfPossible()
}
.background(
AdaptiveToastBackgroundLayer(cornerRadius: toastCornerRadius)
)
.overlay(
RoundedRectangle(cornerRadius: toastCornerRadius, style: .continuous)
.stroke((colorScheme.isLight ? Color.white.opacity(0.5) : Color.gray.opacity(0.33)), lineWidth: 1)
)
.overlay(alignment: .topLeading) {
if closeButtonVisible {
Button(action: {
if let actions = actions {
actions.closeToastAction()
} else if isNotificationSyncPinStage {
receiveModel.hideQuickDropToast(style: .fade)
}
}) {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.secondary)
.frame(width: 18, height: 18)
.background(
Circle()
.fill(Color(NSColor.windowBackgroundColor).opacity(0.9))
.shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1)
)
}
.buttonStyle(PlainButtonStyle())
.padding(.bottom, 5)
.padding(.trailing, 3)
.onHover { hovering in
if hovering {
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = true
}
}
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
.shadow(color: Color.black.opacity(colorScheme.isLight ? 0.16 : 0.30), radius: 10, x: 0, y: 5)
.shadow(color: Color.black.opacity(colorScheme.isLight ? 0.06 : 0.14), radius: 2, x: 0, y: 1)
.frame(maxWidth: .infinity)
}
}
struct QuickDropToastHostView: View {
@ObservedObject var receiveModel: ReceiveModel
let onCancel: () -> Void
@State private var didRenderOnce = false
@State private var hasFinishedSlideIn = false
private let slideAnimation = Animation.smooth
private let fadeAnimation = Animation.easeInOut
var body: some View {
let isVisible = didRenderOnce && receiveModel.toastIsVisible
let isSlide = receiveModel.toastDismissStyle == .slide
let hiddenOffset: CGFloat = isSlide ? toastSlideDistance : 0
let hiddenOpacity: Double = isSlide ? 1 : 0
let hiddenBlur: CGFloat = isSlide ? 0 : 8
let animation = isSlide ? slideAnimation : fadeAnimation
QuickDropToastView(
receiveModel: receiveModel,
animateProgressVisibility: hasFinishedSlideIn,
onCancel: onCancel
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.offset(x: isVisible ? 0 : hiddenOffset)
.opacity(isVisible ? 1 : hiddenOpacity)
.blur(radius: isVisible ? 0 : hiddenBlur)
.animation(animation, value: isVisible)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
.padding(.trailing, toastTrailingPadding)
.onAppear {
// Defer the visibility gate one run-loop tick so initial state is
// always rendered hidden before we animate to visible.
guard !didRenderOnce else { return }
DispatchQueue.main.async {
didRenderOnce = true
}
}
.task(id: isVisible) {
hasFinishedSlideIn = !isVisible || !isSlide
guard isVisible, isSlide else { return }
try? await Task.sleep(nanoseconds: UInt64(toastSlideAnimationDuration * 1_000_000_000))
guard !Task.isCancelled else { return }
hasFinishedSlideIn = true
}
.onDisappear {
hasFinishedSlideIn = false
didRenderOnce = false
}
}
}
fileprivate struct QuickDropToastViewButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
ToastButtonLabel(title: title, fillsWidth: false, showsBackground: true)
}
.buttonStyle(.plain) // prevents blue macOS button look
}
}
fileprivate struct QuickDropToastViewMenuButton<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
ZStack {
ToastButtonLabel(title: title, fillsWidth: false, showsBackground: true)
.allowsHitTesting(false)
Menu {
content()
} label: {
ToastButtonLabel(title: title, fillsWidth: false, showsBackground: false)
}
.menuStyle(BorderlessButtonMenuStyle(showsMenuIndicator: false))
}
.fixedSize()
}
}
fileprivate struct ToastButtonLabel: View {
let title: String
let fillsWidth: Bool
let showsBackground: Bool
var body: some View {
Text(title.localized())
.font(.system(size: 11.5, weight: .medium))
.foregroundColor(.mainColor.opacity(showsBackground ? 0.75 : 0))
.lineLimit(1)
.padding(.horizontal, 7)
.padding(.vertical, 3.5)
.background(Color.gray.opacity(showsBackground ? 0.1 : 0).cornerRadius(10))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.contentShape(Rectangle())
}
}
fileprivate struct ActionColumn<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .trailing, spacing: 0) {
content()
}
.padding(.vertical, 3)
.padding(.trailing, 4)
.frame(maxHeight: .infinity)
}
}
fileprivate struct ActionButtonRow<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
content()
}
}
fileprivate struct ActionDividerLine: View {
var body: some View {
Rectangle()
.foregroundColor(.mainColor.opacity(0.05))
.frame(height: 2)
.frame(maxWidth: .infinity)
}
}
struct AdaptiveToastBackgroundLayer: View {
let cornerRadius: CGFloat
var body: some View {
Group {
if #available(macOS 26.0, *) {
GlassEffectBackgroundView(cornerRadius: cornerRadius)
} else {
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow, cornerRadius: cornerRadius)
}
}
}
}
@available(macOS 26.0, *)
struct GlassEffectBackgroundView: NSViewRepresentable {
var cornerRadius: CGFloat = 0
func makeNSView(context: Context) -> NSGlassEffectView {
let v = NSGlassEffectView()
v.cornerRadius = cornerRadius
return v
}
func updateNSView(_ v: NSGlassEffectView, context: Context) {
v.cornerRadius = cornerRadius
}
}
// NSVisualEffectView wrapper for macOS 11 compatible blur/vibrancy
struct VisualEffectView: NSViewRepresentable {
var material: NSVisualEffectView.Material = .hudWindow
var blendingMode: NSVisualEffectView.BlendingMode = .withinWindow
var state: NSVisualEffectView.State = .active
var cornerRadius: CGFloat = 0
func makeNSView(context: Context) -> NSVisualEffectView {
let v = NSVisualEffectView()
v.material = material
v.blendingMode = blendingMode
v.state = state
v.isEmphasized = true
if cornerRadius > 0 {
v.wantsLayer = true
v.layer?.cornerRadius = cornerRadius
v.layer?.masksToBounds = true
}
return v
}
func updateNSView(_ v: NSVisualEffectView, context: Context) {
v.material = material
v.blendingMode = blendingMode
v.state = state
if cornerRadius > 0 {
v.wantsLayer = true
v.layer?.cornerRadius = cornerRadius
v.layer?.masksToBounds = true
} else {
v.layer?.cornerRadius = 0
v.layer?.masksToBounds = false
}
}
}
// Capsule-style linear progress (0...1)
struct CapsuleProgress: View {
var value: Double
var track = Color(NSColor.systemBlue).opacity(0.28)
var fill = Color(NSColor.systemBlue)
var body: some View {
GeometryReader { geo in
let progress = max(0, min(1, value))
ZStack(alignment: .leading) {
Capsule()
.fill(track)
Capsule()
.fill(fill)
.mask(
Capsule()
.frame(width: progress * geo.size.width)
.frame(maxWidth: .infinity, alignment: .leading)
)
}
}
.frame(height: 6)
.animation(.easeInOut(duration: 0.25), value: value)
}
}
// MARK: - Preview
struct QuickDropToastView_Previews: PreviewProvider {
struct ConsentDemo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.clipped()
.padding(100)
.background(QuickDropToastView_Previews.previewWallpaper)
.onAppear {
model.consentState = .init(
transferID: "preview",
pinCodeMessage: "PIN: 1233",
message: "45 images from Pixel 6 Pro von Leon",
notificationSyncStage: nil,
allowsTrust: true,
acceptAction: { _ in },
declineAction: { }
)
}
}
}
struct ConsentDemo2: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.clipped()
.padding(100)
.background(QuickDropToastView_Previews.previewWallpaper)
.onAppear {
model.consentState = .init(
transferID: "preview",
pinCodeMessage: "PIN: 1233",
message: "45 images from Pixel 6 Pro",
notificationSyncStage: nil,
allowsTrust: false,
acceptAction: { _ in },
declineAction: { }
)
}
}
}
struct ProgressDemo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.clipped()
.padding(100)
.background(QuickDropToastView_Previews.previewWallpaper)
.onAppear {
model.progress = 0.42
model.toastActions = nil
}
}
}
struct CompletedDemo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.clipped()
.padding(100)
.background(previewWallpaper)
.onAppear {
model.progress = 1
model.toastActions = .init(completionMessageKey: "Saved", autoHideDelay: 10, openFilesAction: {}, importPhotosAction: {}, closeToastAction: {}, autoHideAction: {})
}
}
}
private static let previewWallpaper = LinearGradient(
gradient: Gradient(colors: [
Color(red: 0.92, green: 0.82, blue: 0.94),
Color(red: 0.86, green: 0.76, blue: 0.90),
Color(red: 0.78, green: 0.70, blue: 0.86)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static var previews: some View {
Group {
ConsentDemo()
ConsentDemo2()
ProgressDemo()
CompletedDemo()
}
}
}
-125
View File
@@ -1,125 +0,0 @@
//
// TrustedDevicesView.swift
// QuickDrop
//
// Created by Leon Böttger on 23.08.25.
//
import SwiftUI
#if !os(macOS)
import LUI
#endif
struct TrustedDevicesView: View {
@StateObject private var store = TrustStore.shared
@State private var deviceToRemove: String? = nil
@State private var showRemoveAlert = false
var body: some View {
VStack {
if store.trustedCertificates.isEmpty {
VStack(spacing: 12) {
Text("NoTrustedDevices")
.font(.headline)
.foregroundColor(.secondary)
Text("TrustedDevicesDescription")
.font(.subheadline)
.foregroundColor(.secondary)
}
.multilineTextAlignment(.center)
.padding()
.frame(maxHeight: .infinity)
} else {
#if !os(macOS)
CustomSection(footer: "TrustedDevicesDescription") {
devicesView
.padding(.vertical, 4)
}
#else
List {
devicesView
Text("TrustedDevicesDescription")
.font(.footnote)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.secondary)
.padding(.top, 4)
}
#endif
}
}
.animation(.default, value: store.trustedCertificates)
.alert(isPresented: $showRemoveAlert) {
Alert(
title: Text("RemoveTrustedDeviceAlertTitle"),
message: Text("RemoveTrustedDeviceAlertDescription"),
primaryButton: .destructive(Text("Remove")) {
if let key = deviceToRemove {
store.removeTrusted(secretIdHex: key)
}
},
secondaryButton: .cancel()
)
}
}
var devicesView: some View {
ForEach(Array(store.trustedCertificates.keys), id: \.self) { key in
if let cert = store.trustedCertificates[key] {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(cert.device.name ?? "Unknown".localized())
.font(.headline)
// macOS 11: use DateFormatter instead of .formatted
Text(Self.dateFormatter.string(from: cert.creationDate))
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Button(action: {
deviceToRemove = key
showRemoveAlert = true
}) {
Image(systemName: "minus.circle.fill")
.font(.system(size: 20))
.foregroundColor(.red)
}
.buttonStyle(BorderlessButtonStyle())
}
.padding(.vertical, 8)
}
}
}
// MARK: - DateFormatter for macOS 11
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter
}()
}
#Preview {
TrustedDevicesView()
.onAppear {
var cert1 = Sharing_Nearby_PublicCertificate()
cert1.secretID = Data([0x01, 0x02, 0x03, 0x04])
TrustStore.shared.addTrusted(certificate: cert1, device: RemoteDeviceInfo(name: "MacBook Pro", type: .computer))
var cert2 = Sharing_Nearby_PublicCertificate()
cert2.secretID = Data([0x0A, 0x0B, 0x0C, 0x0D])
TrustStore.shared.addTrusted(certificate: cert2, device: RemoteDeviceInfo(name: "iPhone", type: .phone))
}
}
-104
View File
@@ -1,104 +0,0 @@
//
// TutorialView.swift
// QuickDrop
//
// Created by Leon Böttger on 05.03.25.
//
import SwiftUI
struct TutorialView: View {
let title: String
let text: String
let showsLicense: Bool
let openPlus: () -> Void
@State private var licenseWindow: NSWindow?
@ObservedObject var settings = Settings.shared
var body: some View {
LargeAppIconView(title: title) {
VStack {
Text(text.localized())
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.padding()
.frame(width: 550)
if showsLicense {
HStack(spacing: 30) {
Button {
licenseWindow = openLicenseWindow()
} label: {
Text("Acknowledgements")
.underline()
.font(.footnote)
.opacity(0.5)
}
.buttonStyle(.plain)
if !settings.gotPlus {
Button {
openPlus()
} label: {
Text("SupportQuickDrop")
.underline()
.font(.footnote)
.opacity(0.5)
}
.buttonStyle(.plain)
}
#if DEBUG
Button {
Settings.shared.deleteAllUserDefaults()
} label: {
Text("ResetAllSettings")
.underline()
.font(.footnote)
.opacity(0.5)
}
.buttonStyle(.plain)
#endif
}
.padding()
}
}
}
}
}
struct LargeAppIconView<Content: View>: View {
let title: String
let bottomView: () -> Content
var body: some View {
ScrollView {
VStack {
Image("AppIconHighRes")
.resizable()
.frame(width: 150, height: 150)
.padding(.top, 40)
Text(title.localized())
.font(.largeTitle)
.padding()
bottomView()
}
.frame(maxWidth: 500)
.frame(maxWidth: .infinity)
}
}
}
#Preview {
WelcomeScreen(openPlusScreen: {}, openAppAdvertisementView: {}, openCableTransmissionView: {}, checkForNetworkIssues: {})
}
@@ -7,7 +7,6 @@
import SwiftUI import SwiftUI
struct LicensePage: View { struct LicensePage: View {
var body: some View { var body: some View {
ZStack { ZStack {
@@ -0,0 +1,179 @@
//
// TutorialView.swift
// QuickDrop
//
// Created by Leon Böttger on 05.03.25.
//
import AppKit
import LUI
import SwiftUI
struct TutorialView: View {
let tab: Tab
let openAppAdvertisementView: () -> Void
var body: some View {
LargeAppIconView(title: tab.title) {
VStack {
Group {
if tab == .troubleshooting {
FAQView(faqItems: [
.init(question: "FaqNotVisibleOrConnectingQuestion", answer: "FaqNotVisibleOrConnectingAnswer"),
.init(question: "FaqPhotoDateNotPreservedQuestion", answer: "FaqPhotoDateNotPreservedAnswer"),
.init(question: "FaqAndroidDeviceNotVisibleQuestion", answer: "FaqAndroidDeviceNotVisibleAnswer"),
.init(question: "FaqTrustedDevicesQuestion", answer: "FaqTrustedDevicesAnswer"),
.init(question: "MultipleFilesSendingQuestion", answer: "MultipleFilesSendingAnswer"),
.init(question: "FaqBugQuestion", answer: "FaqBugAnswer"),
])
.padding(.top)
}
else {
Text(tab.text)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.padding()
}
}
.frame(width: 550)
if tab == .receive {
Button {
showAndroidAirDropModeAlert()
} label: {
Text("AndroidAirDropModeHelpLink".localized())
.underline()
.font(.footnote)
.multilineTextAlignment(.center)
.opacity(0.7)
}
.buttonStyle(.plain)
.padding()
}
else if tab == .send {
EnableExtensionView()
}
else if tab == .notificationSync || tab == .clipboardSync {
Button {
openAppAdvertisementView()
} label: {
Text("DownloadMobileApp".localized())
}
.buttonStyle(.borderedProminent)
.padding()
}
}
}
}
private func showAndroidAirDropModeAlert() {
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = "AndroidAirDropModeAlertTitle".localized()
alert.informativeText = "AndroidAirDropModeAlertMessage".localized()
alert.addButton(withTitle: "CloseAlert".localized())
ModelessAlertPresenter.present(alert)
}
}
struct EnableExtensionView: View {
@State private var showSharePicker = false
@State private var shareItems: [Any] = []
@State private var isExtensionEnabled = false
@State private var showSuccessCheckmark = false
@State private var animateCheckmark = false
private let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
init(canShowIntialCheckmark: Bool = false) {
let isEnabled = Self.isEnabled()
_isExtensionEnabled = State(initialValue: isEnabled)
if canShowIntialCheckmark && isEnabled {
_showSuccessCheckmark = State(initialValue: true)
_animateCheckmark = State(initialValue: true)
}
}
var body: some View {
if !isExtensionEnabled {
Button("EnableQuickDropExtension".localized()) {
shareItems = [
"EnableQuickDropExtensionDescription".localized()
]
showSharePicker = true
}
.background(
SharingPickerPresenter(
isPresented: $showSharePicker,
sharingItems: shareItems
)
)
.padding()
.onReceive(timer) { _ in
updateEnabledState()
}
}
else if showSuccessCheckmark {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.green)
.background(Circle().foregroundColor(.white).frame(width: 18, height: 18))
.scaleEffect(animateCheckmark ? 1.0 : 0.5)
.animation(.easeOut(duration: 0.4), value: animateCheckmark)
.onAppear {
animateCheckmark = true
}
.padding()
}
}
private func updateEnabledState() {
let enabled = Self.isEnabled()
if enabled != isExtensionEnabled {
withAnimation(.smooth) {
isExtensionEnabled = enabled
if enabled {
showSuccessCheckmark = true
}
}
}
}
private static func isEnabled() -> Bool {
NSSharingService(
named: NSSharingService.Name("com.leonboettger.neardrop.ShareExtension")
) != nil
}
}
private struct SharingPickerPresenter: NSViewRepresentable {
@Binding var isPresented: Bool
let sharingItems: [Any]
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ nsView: NSView, context: Context) {
guard isPresented else { return }
let picker = NSSharingServicePicker(items: sharingItems)
DispatchQueue.main.async {
picker.show(relativeTo: nsView.bounds, of: nsView, preferredEdge: .minY)
isPresented = false
}
}
}
#Preview {
WelcomeScreen(openPlusScreen: {}, openAppAdvertisementView: {}, openCableTransmissionView: {}, checkForNetworkIssues: {}, navigationState: WelcomeScreenNavigationState())
}
@@ -7,9 +7,32 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import LUI
struct WelcomeScreenNavigationRequest {
let tab: Tab
let id = UUID()
}
final class WelcomeScreenNavigationState: ObservableObject {
@Published private(set) var request: WelcomeScreenNavigationRequest
init(selection: Tab = .receive) {
self.request = WelcomeScreenNavigationRequest(tab: selection)
}
func select(_ tab: Tab) {
request = WelcomeScreenNavigationRequest(tab: tab)
}
}
struct WelcomeScreen: View { struct WelcomeScreen: View {
static let width: CGFloat = 1000
static let height: CGFloat = 600
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
let openPlusScreen: () -> Void let openPlusScreen: () -> Void
@@ -17,22 +40,52 @@ struct WelcomeScreen: View {
let openCableTransmissionView: () -> Void let openCableTransmissionView: () -> Void
let checkForNetworkIssues: () -> Void let checkForNetworkIssues: () -> Void
@State private var selection: Tabs? = Tabs.receive @ObservedObject var navigationState: WelcomeScreenNavigationState
@State private var selection: Tab
init(
openPlusScreen: @escaping () -> Void,
openAppAdvertisementView: @escaping () -> Void,
openCableTransmissionView: @escaping () -> Void,
checkForNetworkIssues: @escaping () -> Void,
navigationState: WelcomeScreenNavigationState
) {
self.openPlusScreen = openPlusScreen
self.openAppAdvertisementView = openAppAdvertisementView
self.openCableTransmissionView = openCableTransmissionView
self.checkForNetworkIssues = checkForNetworkIssues
self.navigationState = navigationState
_selection = State(initialValue: navigationState.request.tab)
}
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
List(selection: $selection) {
ForEach(Tabs.allCases.filter({$0 != .settings }), id: \.self) { tab in let listBinding = Binding<Tab?>(
Label(tab.title, systemImage: tab.systemImage) get: { selection },
set: { newValue in
selection = newValue ?? .receive
}
)
List(selection: listBinding) {
ForEach([Tab.receive, .send, .notificationSync, .clipboardSync], id: \.self) { tab in
Label(tab.sidebarTitle, systemImage: tab.systemImage)
.tag(tab) .tag(tab)
.frame(height: 30) .frame(height: 30)
} }
Divider() Divider()
Label(Tab.troubleshooting.sidebarTitle, systemImage: Tab.troubleshooting.systemImage)
.tag(Tab.troubleshooting)
.frame(height: 30)
Divider()
ExternalLinkLabel(label: "GetSupport", icon: "questionmark.circle") { ExternalLinkLabel(label: "GetSupport", icon: "questionmark.circle") {
sendLoggingString() LogExportPresenter.showUploadLogsAlert(openSupportMailWhenNotUploading: true)
} }
ExternalLinkLabel(label: "PrivacyPolicy", icon: "hand.raised") { ExternalLinkLabel(label: "PrivacyPolicy", icon: "hand.raised") {
@@ -49,8 +102,8 @@ struct WelcomeScreen: View {
Divider() Divider()
Label(Tabs.settings.title, systemImage: Tabs.settings.systemImage) Label(Tab.settings.sidebarTitle, systemImage: Tab.settings.systemImage)
.tag(Tabs.settings) .tag(Tab.settings)
.frame(height: 30) .frame(height: 30)
} }
.minimumScaleFactor(0.5) .minimumScaleFactor(0.5)
@@ -58,38 +111,41 @@ struct WelcomeScreen: View {
.listStyle(SidebarListStyle()) .listStyle(SidebarListStyle())
Divider() Divider()
.opacity(colorScheme == .light ? 1 : 0.5)
.edgesIgnoringSafeArea(.vertical) .edgesIgnoringSafeArea(.vertical)
ZStack { ZStack {
Color.defaultBackground.edgesIgnoringSafeArea(.vertical) Color.defaultBackground.edgesIgnoringSafeArea(.vertical)
switch selection { if selection == .settings {
case .receive: SettingsView(openPlus: openPlusScreen)
TutorialView(title: "WelcomeToQuickDrop", text: "UserManualDescription", showsLicense: true, openPlus: openPlusScreen) }
else {
case .send: TutorialView(
TutorialView(title: "SendFiles", text: "SendFilesDescription", showsLicense: false, openPlus: openPlusScreen) tab: selection,
openAppAdvertisementView: openAppAdvertisementView
case .troubleshooting: )
TutorialView(title: "Troubleshooting", text: "TroubleshootingDescription", showsLicense: false, openPlus: openPlusScreen)
.onAppear { .onAppear {
checkForNetworkIssues() if selection == .troubleshooting {
checkForNetworkIssues()
}
} }
default: }
SettingsView() }
} }
.onReceive(navigationState.$request) { request in
if selection != request.tab {
selection = request.tab
} }
} }
.frame(width: 1000, height: 600)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in .onDrop(of: [.fileURL], isTargeted: nil) { providers in
handleDrop(providers: providers) handleDrop(providers: providers)
return true return true
} }
.frame(width: Self.width, height: Self.height)
} }
func openPrivacyPolicy() { func openPrivacyPolicy() {
if let url = URL(string: "https://leonboettger.com/quickdrop-privacy") { if let url = URL(string: .privacyPolicyURL) {
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
@@ -167,31 +223,63 @@ struct ExternalLinkLabel: View {
} }
enum Tabs: CaseIterable { enum Tab: CaseIterable {
case receive case receive
case send case send
case notificationSync
case clipboardSync
case troubleshooting case troubleshooting
case settings case settings
var title: String { var sidebarTitle: String {
switch self { switch self {
case .receive: case .receive:
return "ReceiveFiles".localized() return "ReceiveFiles".localized()
case .send: case .send:
return "SendFiles".localized() return "SendFiles".localized()
case .notificationSync:
return "NotificationSyncSidebar".localized()
case .clipboardSync:
return "ClipboardSyncSidebar".localized()
case .troubleshooting: case .troubleshooting:
return "DeviceNotShown".localized() return "TroubleshootingAndFaq".localized()
case .settings: case .settings:
return "Settings".localized() return "Settings".localized()
} }
} }
var title: String {
switch self {
default:
return sidebarTitle
}
}
var text: String {
switch self {
case .receive:
"UserManualDescription".localized()
case .send:
"SendFilesDescription".localized()
case .notificationSync:
"NotificationSyncManualDescription".localized()
case .clipboardSync:
"ClipboardSyncManualDescription".localized()
default:
""
}
}
var systemImage: String { var systemImage: String {
switch self { switch self {
case .receive: case .receive:
return "tray.and.arrow.down" return "tray.and.arrow.down"
case .send: case .send:
return "tray.and.arrow.up" return "tray.and.arrow.up"
case .notificationSync:
return "bell"
case .clipboardSync:
return "clipboard"
case .troubleshooting: case .troubleshooting:
return "exclamationmark.triangle" return "exclamationmark.triangle"
case .settings: case .settings:
@@ -202,5 +290,5 @@ enum Tabs: CaseIterable {
#Preview { #Preview {
WelcomeScreen(openPlusScreen: {}, openAppAdvertisementView: {}, openCableTransmissionView: {}, checkForNetworkIssues: {}) WelcomeScreen(openPlusScreen: {}, openAppAdvertisementView: {}, openCableTransmissionView: {}, checkForNetworkIssues: {}, navigationState: WelcomeScreenNavigationState())
} }
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more