This commit is contained in:
Ruixuan Tu
2024-12-24 23:51:43 -06:00
parent 4c709f58e1
commit 7aa143f5b3
78 changed files with 2180 additions and 12 deletions

View File

@@ -113,6 +113,8 @@ unused_import:
require_explicit_imports: true
weak_delegate:
severity: error
empty_count:
severity: warning
excluded:
- KDE Connect/fastlane

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 55;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -30,6 +30,11 @@
5EA4114627960D0B0044C559 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5EA4114527960D0B0044C559 /* Assets.xcassets */; };
5EF3BDAB27995FCE005C2E3A /* iOS14CompatibleTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF3BDAA27995FCE005C2E3A /* iOS14CompatibleTextView.swift */; };
5EFA051C279432CA009C91D2 /* SettingsAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFA051B279432CA009C91D2 /* SettingsAboutView.swift */; };
5EFFF3082D1B6F3000A3EFCA /* InternalBattery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFFF3032D1B6F3000A3EFCA /* InternalBattery.swift */; };
5EFFF3092D1B6F3000A3EFCA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFFF3022D1B6F3000A3EFCA /* AppDelegate.swift */; };
5EFFF30A2D1B6F3000A3EFCA /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFFF3062D1B6F3000A3EFCA /* NotificationManager.swift */; };
5EFFF30B2D1B6F3000A3EFCA /* InternalBatteryFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFFF3042D1B6F3000A3EFCA /* InternalBatteryFinder.swift */; };
5EFFF30C2D1B6F3000A3EFCA /* InternalBatteryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFFF3052D1B6F3000A3EFCA /* InternalBatteryMonitor.swift */; };
744FF3AE2C06C5EB001B6234 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 744FF3AD2C06C5EB001B6234 /* DeviceInfo.swift */; };
749868B12C070320003B37FA /* LoopbackLink.m in Sources */ = {isa = PBXBuildFile; fileRef = 749868AD2C070320003B37FA /* LoopbackLink.m */; };
749868B22C070320003B37FA /* LoopbackLinkProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 749868B02C070320003B37FA /* LoopbackLinkProvider.m */; };
@@ -147,6 +152,11 @@
5EA4114527960D0B0044C559 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
5EF3BDAA27995FCE005C2E3A /* iOS14CompatibleTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOS14CompatibleTextView.swift; sourceTree = "<group>"; };
5EFA051B279432CA009C91D2 /* SettingsAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAboutView.swift; sourceTree = "<group>"; };
5EFFF3022D1B6F3000A3EFCA /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
5EFFF3032D1B6F3000A3EFCA /* InternalBattery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalBattery.swift; sourceTree = "<group>"; };
5EFFF3042D1B6F3000A3EFCA /* InternalBatteryFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalBatteryFinder.swift; sourceTree = "<group>"; };
5EFFF3052D1B6F3000A3EFCA /* InternalBatteryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalBatteryMonitor.swift; sourceTree = "<group>"; };
5EFFF3062D1B6F3000A3EFCA /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
744FF3AD2C06C5EB001B6234 /* DeviceInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = "<group>"; };
749868AD2C070320003B37FA /* LoopbackLink.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = LoopbackLink.m; sourceTree = "<group>"; };
749868AE2C070320003B37FA /* LoopbackLinkProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = LoopbackLinkProvider.h; sourceTree = "<group>"; };
@@ -225,6 +235,12 @@
DB8E559D2815961200101059 /* iOS14+FocusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "iOS14+FocusState.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
5E1BB96C2D1B92AC0024F3FF /* Mac */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Mac; sourceTree = "<group>"; };
5E6C9AB82D1B807400F48F25 /* Mac */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Mac; sourceTree = "<group>"; };
5E997BBF2D1B7E300052D2C9 /* Mac */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Mac; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
A0A04429267BF38700CC21DD /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -308,6 +324,18 @@
path = Texts;
sourceTree = "<group>";
};
5EFFF3072D1B6F3000A3EFCA /* Mac */ = {
isa = PBXGroup;
children = (
5EFFF3022D1B6F3000A3EFCA /* AppDelegate.swift */,
5EFFF3032D1B6F3000A3EFCA /* InternalBattery.swift */,
5EFFF3042D1B6F3000A3EFCA /* InternalBatteryFinder.swift */,
5EFFF3052D1B6F3000A3EFCA /* InternalBatteryMonitor.swift */,
5EFFF3062D1B6F3000A3EFCA /* NotificationManager.swift */,
);
path = Mac;
sourceTree = "<group>";
};
749868AC2C0702DC003B37FA /* LoopbackBackend */ = {
isa = PBXGroup;
children = (
@@ -421,6 +449,7 @@
53A0A1DD283ED78700C7C473 /* LocalizedStringKey+Extensions.swift */,
D20ABB0A29A4A04E006F277B /* FileTransferItem.swift */,
D27D727E29B051D8002C00B7 /* NetworkChangeMonitor.swift */,
5EFFF3072D1B6F3000A3EFCA /* Mac */,
);
path = "Swift Backend";
sourceTree = "<group>";
@@ -452,6 +481,7 @@
A0D8CBCB26EF081D00791D07 /* Top Level */ = {
isa = PBXGroup;
children = (
5E997BBF2D1B7E300052D2C9 /* Mac */,
A0A0442F267BF38700CC21DD /* KDE_Connect_App.swift */,
A0A04431267BF38700CC21DD /* MainTabView.swift */,
);
@@ -461,6 +491,7 @@
A0D8CBCC26EF082F00791D07 /* Devices */ = {
isa = PBXGroup;
children = (
5E6C9AB82D1B807400F48F25 /* Mac */,
A0A0445A267BF40400CC21DD /* DevicesView.swift */,
D2A8720D282C95D700DE980E /* DeviceDiscoveryHelp.swift */,
A0A0445C267BF41200CC21DD /* DevicesDetailView.swift */,
@@ -525,6 +556,7 @@
A0D8CBD326EF090800791D07 /* Settings */ = {
isa = PBXGroup;
children = (
5E1BB96C2D1B92AC0024F3FF /* Mac */,
A0A0445E267BF41B00CC21DD /* SettingsView.swift */,
A0B8727C267BFBC500F0EB72 /* SettingsDeviceNameView.swift */,
A0B87284267E73E600F0EB72 /* SettingsChosenThemeView.swift */,
@@ -647,6 +679,11 @@
dependencies = (
D2AB56782988E4B9008A2217 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
5E1BB96C2D1B92AC0024F3FF /* Mac */,
5E6C9AB82D1B807400F48F25 /* Mac */,
5E997BBF2D1B7E300052D2C9 /* Mac */,
);
name = "KDE Connect";
packageProductDependencies = (
1491F15926AC8BD6008C1065 /* OpenSSL */,
@@ -798,6 +835,11 @@
A0FB449A26BA796400733914 /* Backend.swift in Sources */,
A0D76FCE26E6F562009D9B03 /* RemoteInput.swift in Sources */,
D20FCC41282F4D5500A6E16B /* Contributors.swift in Sources */,
5EFFF3082D1B6F3000A3EFCA /* InternalBattery.swift in Sources */,
5EFFF3092D1B6F3000A3EFCA /* AppDelegate.swift in Sources */,
5EFFF30A2D1B6F3000A3EFCA /* NotificationManager.swift in Sources */,
5EFFF30B2D1B6F3000A3EFCA /* InternalBatteryFinder.swift in Sources */,
5EFFF30C2D1B6F3000A3EFCA /* InternalBatteryMonitor.swift in Sources */,
D28C94CC27D1CAD2002EBC2D /* OSLogView.swift in Sources */,
3D5C169E2A49934A005F423D /* MdnsDiscovery.swift in Sources */,
1499A8E82698BFF300FDF493 /* KeychainItemWrapper.m in Sources */,
@@ -1030,7 +1072,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"KDE Connect/Preview Content\"";
DEVELOPMENT_TEAM = 5433B4KXM8;
DEVELOPMENT_TEAM = 3ZWV7C62H6;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = "KDE Connect/Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -1038,11 +1080,15 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.4.1;
PRODUCT_BUNDLE_IDENTIFIER = org.kde.kdeconnect;
PRODUCT_BUNDLE_IDENTIFIER = asia.turx.kdeconnect;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = NO;
RUN_CLANG_STATIC_ANALYZER = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "KDE Connect/ObjC Backend/Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -1070,10 +1116,14 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.4.1;
PRODUCT_BUNDLE_IDENTIFIER = org.kde.kdeconnect;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "KDE Connect/ObjC Backend/Bridging-Header.h";
SWIFT_VERSION = 5.0;

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "AppIcon-Classic-16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "AppIcon-Classic-32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "AppIcon-Classic-32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "AppIcon-Classic-64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "AppIcon-Classic-128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "AppIcon-Classic-256.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "AppIcon-Classic-256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "AppIcon-Classic-512.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "AppIcon-Classic-512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "AppIcon-Classic-1024.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "AppIcon-RoundedRectangle-16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "AppIcon-RoundedRectangle-32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "AppIcon-RoundedRectangle-32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "AppIcon-RoundedRectangle-64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "AppIcon-RoundedRectangle-128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "AppIcon-RoundedRectangle-256.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "AppIcon-RoundedRectangle-256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "AppIcon-RoundedRectangle-512.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "AppIcon-RoundedRectangle-512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "AppIcon-RoundedRectangle-1024.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "AppIcon-16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "AppIcon-32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "AppIcon-32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "AppIcon-64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "AppIcon-128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "AppIcon-256.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "AppIcon-256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "AppIcon-512.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "AppIcon-512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "AppIcon-1024.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -5,6 +5,8 @@
// Created by Apollo Zhu on 3/4/22.
//
#if !os(macOS)
import SwiftUI
import Introspect
@@ -146,3 +148,5 @@ struct NetworkPacketComposer_Previews: PreviewProvider {
}
}
}
#endif

View File

@@ -30,7 +30,9 @@
#import <Foundation/Foundation.h>
#import "BaseLink.h"
#import "NetworkPacket.h"
#if !TARGET_OS_OSX
#import "UIKit/UIKit.h"
#endif
//#import "deviceDelegate.h"
//#import "BackgroundService.h"
@class BaseLink;
@@ -74,7 +76,9 @@ typedef NS_ENUM(NSUInteger, PairStatus)
// data only and are therefore NOT stored persistently
// Remote Input
@property(nonatomic) float _cursorSensitivity;
#if !TARGET_OS_OSX
@property(nonatomic) UIImpactFeedbackStyle hapticStyle;
#endif
// Presenter
@property(nonatomic) float _pointerSensitivity;

View File

@@ -76,7 +76,9 @@ static const NSTimeInterval kPairingTimeout = 30.0;
_pluginsEnableStatus = [NSMutableDictionary dictionary];
self.deviceDelegate = deviceDelegate;
_cursorSensitivity = 3.0;
#if !TARGET_OS_OSX
_hapticStyle = 0;
#endif
_pointerSensitivity = 3.0;
[self addLink:link];
}
@@ -501,7 +503,9 @@ static const NSTimeInterval kPairingTimeout = 30.0;
[coder encodeInteger:_pairStatus forKey:@"_pairStatus"];
[coder encodeObject:_pluginsEnableStatus forKey:@"_pluginsEnableStatus"];
[coder encodeFloat:_cursorSensitivity forKey:@"_cursorSensitivity"];
#if !TARGET_OS_OSX
[coder encodeInteger:_hapticStyle forKey:@"_hapticStyle"];
#endif
[coder encodeFloat:_pointerSensitivity forKey:@"_pointerSensitivity"];
}
@@ -525,7 +529,9 @@ static const NSTimeInterval kPairingTimeout = 30.0;
_pairStatus = [coder decodeIntegerForKey:@"_pairStatus"];
_pluginsEnableStatus = (NSMutableDictionary*)[(NSDictionary*)[coder decodeDictionaryWithKeysOfClass:[NSString class] objectsOfClass:[NSNumber class] forKey:@"_pluginsEnableStatus"] mutableCopy];
_cursorSensitivity = [coder decodeFloatForKey:@"_cursorSensitivity"];
#if !TARGET_OS_OSX
_hapticStyle = [coder decodeIntegerForKey:@"_hapticStyle"];
#endif
_pointerSensitivity = [coder decodeFloatForKey:@"_pointerSensitivity"];
// To be set later in backgroundServices

View File

@@ -104,6 +104,9 @@ FOUNDATION_EXPORT NetworkPacketType const NetworkPacketTypeRunCommand;
- (NetworkPacket *) initWithType:(NetworkPacketType)type;
+ (NetworkPacket *) createIdentityPacketWithTCPPort:(uint16_t)tcpPort;
+ (NetworkPacket *) createPairPacket;
#if TARGET_OS_OSX
+ (NSString *) getMacUUID;
#endif
- (BOOL)bodyHasKey:(nonnull NSString *)key;
- (void)setBool:(BOOL)value forKey:(NSString *)key;

View File

@@ -29,7 +29,12 @@
#import "NetworkPacket.h"
#import "Device.h"
#import "KDE_Connect-Swift.h"
#if !TARGET_OS_OSX
@import UIKit;
#else
@import SystemConfiguration;
#import <IOKit/IOKitLib.h>
#endif
@import os.log;
@@ -76,6 +81,20 @@
return np;
}
#if TARGET_OS_OSX
// https://stackoverflow.com/questions/11113735/how-to-identify-a-mac-system-uniquely
+ (NSString *) getMacUUID {
io_service_t platformExpert = IOServiceGetMatchingService(kIOMainPortDefault,IOServiceMatching("IOPlatformExpertDevice"));
if (!platformExpert) return nil;
CFTypeRef serialNumberAsCFString = IORegistryEntryCreateCFProperty(platformExpert,CFSTR(kIOPlatformUUIDKey),kCFAllocatorDefault, 0);
if (!serialNumberAsCFString) return nil;
IOObjectRelease(platformExpert);
return (__bridge NSString *)(serialNumberAsCFString);
}
#endif
//
- (BOOL) bodyHasKey:(NSString*)key
{

View File

@@ -373,9 +373,11 @@
- (void)socket:(GCDAsyncSocket *)sock didAcceptNewSocket:(GCDAsyncSocket *)newSocket
{
os_log_with_type(logger, self.debugLogLevel, "TCP server: didAcceptNewSocket");
#if !TARGET_OS_OSX
[newSocket performBlock:^{
[newSocket enableBackgroundingOnSocket];
}];
#endif
@synchronized(_pendingSockets) {
[_pendingSockets addObject:newSocket];
}
@@ -397,6 +399,7 @@
//create LanLink and inform the background
NSUInteger index=[_pendingSockets indexOfObject:sock];
NetworkPacket* np=[_pendingNPs objectAtIndex:index];
#if !TARGET_OS_OSX
NSString* deviceId=[np objectForKey:@"deviceId"];
BaseLink *link = self.connectedLinks[deviceId];
@@ -406,6 +409,7 @@
[sock enableBackgroundingOnSocket];
}];
}
#endif
NSArray *myCerts = [[NSArray alloc] initWithObjects: (__bridge id)_identity, nil];
NSDictionary *tlsSettings = [[NSDictionary alloc] initWithObjectsAndKeys:

View File

@@ -13,7 +13,11 @@
//
import SwiftUI
#if !os(macOS)
import UIKit
#else
import AppKit
#endif
// TODO: We might be able to do something with the background activities plugin where it sends out its battery status every once in a while??? But maybe iOS will not unfreeze the entire app for us??? I really don't know...background activity is something that we'll have to figure out later on
@objc class Battery: NSObject, ObservablePlugin {
@@ -24,6 +28,10 @@ import UIKit
@objc var remoteIsCharging: Bool = false
@Published
@objc var remoteThresholdEvent: Int = 0
#if os(macOS)
var batteryObserver: BatteryObserver? = nil
#endif
private let logger = Logger()
@objc init(controlDevice: Device) {
@@ -35,6 +43,7 @@ import UIKit
}
@objc func startBatteryMonitoring() {
#if !os(macOS)
UIDevice.current.isBatteryMonitoringEnabled = true
// Tip: to add an observer with a function/selector in another class that is not self,
@@ -42,6 +51,9 @@ import UIKit
NotificationCenter.default.addObserver(self, selector: #selector(self.batteryStateDidChange(notification:)), name: UIDevice.batteryStateDidChangeNotification, object: UIDevice.current)
NotificationCenter.default.addObserver(self, selector: #selector(self.batteryLevelDidChange(notification:)), name: UIDevice.batteryLevelDidChangeNotification, object: UIDevice.current)
#else
self.batteryObserver = BatteryObserver(batteryInfoDidChange)
#endif
}
@objc func onDevicePacketReceived(np: NetworkPacket) {
@@ -62,9 +74,10 @@ import UIKit
}
@objc func sendBatteryStatusOut() {
let np: NetworkPacket = NetworkPacket(type: .battery)
#if !os(macOS)
let batteryLevel: Int = Int(UIDevice.current.batteryLevel * 100)
let batteryStatus = UIDevice.current.batteryState
let np: NetworkPacket = NetworkPacket(type: .battery)
if (batteryStatus != .unknown) {
let batteryThresholdEvent: Int = (batteryLevel < 10) ? 1 : 0
np.setInteger(batteryLevel, forKey: "currentCharge")
@@ -79,6 +92,26 @@ import UIKit
np.setInteger(0, forKey: "thresholdEvent")
logger.notice("Battery status reported as unknown, reporting 0 for all values")
}
#else
let internalFinder = InternalFinder()
if (internalFinder.batteryPresent) {
let internalBattery = internalFinder.getInternalBattery()
let batteryLevel = Int(internalBattery?.charge ?? 0)
let acPowered: Bool = internalBattery?.acPowered ?? false
let batteryThresholdEvent: Int = (batteryLevel < 10) ? 1 : 0
np.setInteger(Int(batteryLevel), forKey: "currentCharge")
np.setBool((internalBattery?.acPowered ?? false), forKey: "isCharging")
np.setInteger(batteryThresholdEvent, forKey: "thresholdEvent")
print("Battery status accessed successfully, sending out:")
print("BatteryLevel=\(batteryLevel)")
print("BatteryisCharging=\(acPowered)")
} else {
np.setInteger(0, forKey: "currentCharge")
np.setBool(false, forKey: "isCharging")
np.setInteger(0, forKey: "thresholdEvent")
print("Battery status reported as unknown, reporting 0 for all values")
}
#endif
guard let controlDevice = controlDevice else {
logger.fault("Sending battery status with leaked instance, \(CFGetRetainCount(self)) references remaining")
return
@@ -111,7 +144,7 @@ import UIKit
}
}
var statusColor: Color? {
var statusColor: Color {
if remoteThresholdEvent == 1 || remoteChargeLevel < 10 {
return .red
} else if remoteIsCharging {
@@ -119,13 +152,18 @@ import UIKit
} else if remoteChargeLevel < 40 {
return .yellow
} else {
return nil
#if !os(macOS)
return .primary
#else
return .blue
#endif
}
}
// Global functions for setting up and responding to the device's own events when battery
// status changes
#if !os(macOS)
// When the state of the battery changes: plugged, unplugged, full charge, unknown
@objc func batteryStateDidChange(notification: Notification) {
sendBatteryStatusOut()
@@ -135,6 +173,11 @@ import UIKit
@objc func batteryLevelDidChange(notification: Notification) {
sendBatteryStatusOut()
}
#else
func batteryInfoDidChange(info: BatteryInfo) {
sendBatteryStatusOut()
}
#endif
}
// Global functions for Battery handling

View File

@@ -12,7 +12,11 @@
// Created by Lucas Wang on 2021-09-05.
//
#if !os(macOS)
import UIKit
#else
import AppKit
#endif
@objc class Clipboard: NSObject, Plugin {
static var lastLocalClipboardUpdateTimestamp: Int = 0
@@ -27,7 +31,11 @@ import UIKit
if (np.type == .clipboard || np.type == .clipboardConnect) {
if (np.object(forKey: "content") != nil) {
if (np.type == .clipboard) {
#if !os(macOS)
UIPasteboard.general.string = np.object(forKey: "content") as? String
#else
NSPasteboard.general.setString(np.object(forKey: "content") as? String ?? "", forType: .string)
#endif
Self.lastLocalClipboardUpdateTimestamp = Int(Date().millisecondsSince1970)
logger.debug("Local clipboard synced with remote packet, timestamp updated")
} else if (np.type == .clipboardConnect) {
@@ -35,7 +43,11 @@ import UIKit
if (packetTimeStamp == 0 || packetTimeStamp < Self.lastLocalClipboardUpdateTimestamp) {
logger.info("Invalid timestamp from \(np.type.rawValue, privacy: .public), doing nothing")
} else {
#if !os(macOS)
UIPasteboard.general.string = np.object(forKey: "content") as? String
#else
NSPasteboard.general.setString(np.object(forKey: "content") as? String ?? "", forType: .string)
#endif
Self.lastLocalClipboardUpdateTimestamp = Int(Date().millisecondsSince1970)
logger.debug("Local clipboard synced with remote packet, timestamp updated")
}
@@ -48,6 +60,7 @@ import UIKit
// FIXME: unused function
func connectClipboardContent() {
#if !os(macOS)
if let clipboardContent = UIPasteboard.general.string {
let np = NetworkPacket(type: .clipboardConnect)
np.setObject(clipboardContent, forKey: "content")
@@ -56,9 +69,20 @@ import UIKit
} else {
logger.info("Attempt to connect local clipboard content with remote device returned nil")
}
#else
if let clipboardContent = NSPasteboard.general.string(forType: .string) {
let np = NetworkPacket(type: .clipboardConnect)
np.setObject(clipboardContent, forKey: "content")
np.setInteger(Self.lastLocalClipboardUpdateTimestamp, forKey: "timestamp")
controlDevice.send(np, tag: Int(PACKET_TAG_CLIPBOARD))
} else {
print("Attempt to connect local clipboard content with remote device returned nil")
}
#endif
}
func sendClipboardContentOut() {
#if !os(macOS)
if let clipboardContent = UIPasteboard.general.string {
let np = NetworkPacket(type: .clipboard)
np.setObject(clipboardContent, forKey: "content")
@@ -66,5 +90,14 @@ import UIKit
} else {
logger.info("Attempt to grab and update local clipboard content returned nil")
}
#else
if let clipboardContent = NSPasteboard.general.string(forType: .string) {
let np = NetworkPacket(type: .clipboard)
np.setObject(clipboardContent, forKey: "content")
controlDevice.send(np, tag: Int(PACKET_TAG_CLIPBOARD))
} else {
print("Attempt to grab and update local clipboard content returned nil")
}
#endif
}
}

View File

@@ -12,6 +12,8 @@
// Created by Lucas Wang on 2021-09-13.
//
#if !os(macOS)
import SwiftUI
import UIKit.UIDevice
@@ -275,3 +277,5 @@ struct PresenterView_Previews: PreviewProvider {
PresenterView(detailsDeviceId: "Hi")
}
}
#endif

View File

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#if !os(macOS)
import UIKit
import SwiftUI
import Introspect
@@ -64,3 +66,5 @@ fileprivate struct _KeyboardListenerPlaceholderView: UIViewRepresentable {
// do nothing
}
}
#endif

View File

@@ -12,6 +12,8 @@
// Created by Lucas Wang on 2021-09-06.
//
#if !os(macOS)
import SwiftUI
struct RemoteInputView: View {
@@ -276,3 +278,5 @@ struct MousePadView_Previews: PreviewProvider {
RemoteInputView(detailsDeviceId: "HI")
}
}
#endif

View File

@@ -27,7 +27,9 @@ struct RunCommandView: View {
ForEach(runCommandPlugin.commandEntries) { entry in
Button {
runCommandPlugin.runCommand(cmdKey: entry.key)
#if !os(macOS)
notificationHapticsGenerator.notificationOccurred(.success)
#endif
} label: {
VStack(alignment: .leading) {
Text(entry.name)
@@ -42,9 +44,11 @@ struct RunCommandView: View {
}
.environment(\.defaultMinListRowHeight, 50) // TODO: make this dynamic with GeometryReader???
.navigationTitle("Run Command")
#if !os(macOS)
.navigationBarItems(trailing: Button(action: runCommandPlugin.sendSetupPacket) {
Image(systemName: "command") // is there a better choice for this? This is a nice reference though I think
})
#endif
}
}

View File

@@ -15,7 +15,11 @@
import Foundation
import AVFoundation
#if !os(macOS)
import UIKit
#else
import AppKit
#endif
import OrderedCollections
import Photos
@@ -79,7 +83,9 @@ extension Notification.Name {
guard let payloadPath = np.payloadPath else {
logger.fault("File \(filename, privacy: .public) missing actual file contents")
// FIXME: show error to UI
#if !os(macOS)
notificationHapticsGenerator.notificationOccurred(.error)
#endif
return
}
Task {
@@ -87,7 +93,9 @@ extension Notification.Name {
try await save(payloadPath, as: filename, for: np)
// connectedDevicesViewModel.showFileReceivedAlert()
logger.debug("File \(filename, privacy: .private(mask: .hash)) saved successfully")
#if !os(macOS)
await notificationHapticsGenerator.notificationOccurred(.success)
#endif
} catch {
logger.fault("File \(filename, privacy: .public) failed to save due to \(error.localizedDescription, privacy: .public)")
await MainActor.run {
@@ -97,7 +105,9 @@ extension Notification.Name {
error: error,
countOtherFailedFilesInTheSameTransfer: 0
))
#if !os(macOS)
notificationHapticsGenerator.notificationOccurred(.error)
#endif
}
}
await MainActor.run {
@@ -107,18 +117,28 @@ extension Notification.Name {
}
} else if let sharedText = np._Body["text"] as? String {
// Text sharing: copy to clipboard
#if !os(macOS)
UIPasteboard.general.string = sharedText
#else
NSPasteboard.general.setString(sharedText, forType: .string)
#endif
} else if let sharedURLText = np._Body["url"] as? String {
// TODO: avoid to handle URL open in the share extension
// URL sharing: open it through URL scheme
if let sharedURL = URL(string: sharedURLText) {
DispatchQueue.main.async {
#if !os(macOS)
UIApplication.shared.open(sharedURL)
#else
NSWorkspace.shared.open(sharedURL)
#endif
}
}
} else {
logger.fault("Nil received when trying to parse filename")
#if !os(macOS)
notificationHapticsGenerator.notificationOccurred(.error)
#endif
}
}
}
@@ -166,7 +186,9 @@ extension Notification.Name {
let filename = url.lastPathComponent
guard let fileSize = attributes.fileSize else {
logger.fault("Unable to read size of \(filename, privacy: .public), skipping")
#if !os(macOS)
notificationHapticsGenerator.notificationOccurred(.error)
#endif
continue
}
@@ -250,7 +272,9 @@ extension Notification.Name {
if noConcurrentJobs {
// Only 1 concurrent receiving job,
// cancellation cancels everything
#if !os(macOS)
notificationHapticsGenerator.notificationOccurred(.error)
#endif
self.numFilesReceived = 0
self.totalNumOfFilesToReceive = 0
} else {
@@ -281,7 +305,9 @@ extension Notification.Name {
if self.currentFilesSending.removeValue(forKey: path) != nil {
self.numFilesSuccessfullySent += 1
#if !os(macOS)
notificationHapticsGenerator.notificationOccurred(.success)
#endif
self.sendSinglePayload()
} else {
self.logger.fault("Sent \(np) not currently sending")
@@ -310,7 +336,9 @@ extension Notification.Name {
} else {
logger.fault("Cannot find info for \(np) after failed to send with \(error)")
}
#if !os(macOS)
notificationHapticsGenerator.notificationOccurred(.error)
#endif
self.resetTransferData()
}
}

View File

@@ -44,8 +44,10 @@ let backgroundService: BackgroundService = {
)
}()
#if !os(macOS)
// Device motion manager
let motionManager: CMMotionManager = CMMotionManager()
#endif
// Given the deviceId, saves/overwrites the device object from _device into _settings by encoding it and then into UserDefaults
func saveDeviceToUserDefaults(deviceId: String) {

View File

@@ -59,6 +59,9 @@ public extension DeviceType {
}
}
}
#if os(macOS)
return macDeviceType
#else
switch UIDevice.current.userInterfaceIdiom {
case .unspecified:
return .unknown
@@ -80,5 +83,6 @@ public extension DeviceType {
@unknown default:
return .unknown
}
#endif
}
}

View File

@@ -5,6 +5,8 @@
// Created by Claudio Cambra on 25/5/22.
//
#if !os(macOS)
import Foundation
import SwiftUI
@@ -30,3 +32,5 @@ extension UIImpactFeedbackGenerator.FeedbackStyle: CaseIterable {
// UIImpactFeedbackGenerator.FeedbackStyle.init(rawValue: Int)
let notificationHapticsGenerator: UINotificationFeedbackGenerator = UINotificationFeedbackGenerator()
#endif

View File

@@ -58,11 +58,17 @@ class KdeConnectSettings: NSObject, ObservableObject {
if let savedUUID = wrapper.object(forKey: kSecValueData as String) as? String, !savedUUID.isEmpty {
cachedUuid = savedUUID
} else {
#if !os(macOS)
// identifierForVendor can be nil if called when a device has restarted but not been unlocked yet
if let identifierForVendor = UIDevice.current.identifierForVendor {
cachedUuid = identifierForVendor.uuidString.replacingOccurrences(of: "-", with: "")
wrapper.setObject(cachedUuid, forKey: kSecValueData as String)
}
#else
let identifierForVendor = NetworkPacket.getMacUUID()
cachedUuid = identifierForVendor.replacingOccurrences(of: "-", with: "")
wrapper.setObject(cachedUuid, forKey: kSecValueData as String)
#endif
}
let logger = OSLog(subsystem: NSStringFromClass(self), category: "UUIDManager")
os_log("Get UUID %{mask.hash}@", log: logger, type: .info, cachedUuid ?? "")
@@ -127,7 +133,12 @@ class KdeConnectSettings: NSObject, ObservableObject {
"savePhotosToPhotosLibrary": !DeviceType.isMac,
"saveVideosToPhotosLibrary": !DeviceType.isMac,
])
self.deviceName = UserDefaults.standard.string(forKey: "deviceName") ?? UIDevice.current.name
#if !os(macOS)
let fallbackName = UIDevice.current.name
#else
let fallbackName = Host.current().localizedName ?? "Unknown Hostname"
#endif
self.deviceName = UserDefaults.standard.string(forKey: "deviceName") ?? fallbackName
self.chosenTheme = UserDefaults.standard.string(forKey: "chosenTheme").flatMap(ColorScheme.init)
self.directIPs = UserDefaults.standard.stringArray(forKey: "directIPs") ?? []
self.disableUdpBroadcastDiscovery = UserDefaults.standard.bool(forKey: "disableUdpBroadcastDiscovery")

View File

@@ -0,0 +1,103 @@
//
// AppDelegate.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/13.
//
#if os(macOS)
import Foundation
import SwiftUI
import UserNotifications
class AppDelegate: NSObject, NSApplicationDelegate {
private var menu: NSMenu? = nil
private var safe: Bool = true
private var allowedMenus: [String] = ["KDE Connect", "Devices"]
private var needMenuUpdate: Bool = false {
didSet {
if self.needMenuUpdate == true {
if safe {
safe = false
self.menu?.items.removeAll(where: { !self.allowedMenus.contains($0.title) })
safe = true
}
self.needMenuUpdate = false
}
}
}
func requestMenuUpdate() {
if menu?.items.count != self.allowedMenus.count {
self.needMenuUpdate = true
}
}
func applicationWillUpdate(_ notification: Notification) {
if let menu = NSApplication.shared.mainMenu {
self.menu = menu
self.requestMenuUpdate()
}
}
func applicationWillFinishLaunching(_ notification: Notification) {
UNUserNotificationCenter.current().delegate = self
}
func applicationDidFinishLaunching(_ notification: Notification) {
UNUserNotificationCenter.current().delegate = self
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
let deviceId = userInfo["DEVICE_ID"] as? String ?? nil
switch response.actionIdentifier {
case "PAIR_ACCEPT_ACTION":
backgroundService.pairDevice(deviceId)
case "PAIR_DECLINE_ACTION":
backgroundService.unpairDevice(deviceId)
case "FMD_FOUND_ACTION":
MainView.updateFindMyPhoneTimer(isRunning: false)
default:
break
}
completionHandler()
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let userInfo = notification.request.content.userInfo
let deviceId = userInfo["DEVICE_ID"] as? String ?? nil
switch notification.request.identifier {
case "PAIR_ACCEPT_ACTION":
backgroundService.pairDevice(deviceId)
case "PAIR_DECLINE_ACTION":
backgroundService.unpairDevice(deviceId)
case "FMD_FOUND_ACTION":
MainView.updateFindMyPhoneTimer(isRunning: false)
default:
break
}
completionHandler([.list, .banner, .sound, .badge])
}
}
#endif

View File

@@ -0,0 +1,84 @@
//
// InternalBattery.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/12.
//
// https://stackoverflow.com/questions/57145091/swift-macos-batterylevel-property
#if os(macOS)
public class InternalBattery {
public var name: String?
public var timeToFull: Int?
public var timeToEmpty: Int?
public var manufacturer: String?
public var manufactureDate: Date?
public var currentCapacity: Int?
public var maxCapacity: Int?
public var designCapacity: Int?
public var cycleCount: Int?
public var designCycleCount: Int?
public var acPowered: Bool?
public var isCharging: Bool?
public var isCharged: Bool?
public var amperage: Int?
public var voltage: Double?
public var watts: Double?
public var temperature: Double?
public var charge: Double? {
get {
if let current = self.currentCapacity,
let max = self.maxCapacity {
return (Double(current) / Double(max)) * 100.0
}
return nil
}
}
public var health: Double? {
get {
if let design = self.designCapacity,
let current = self.maxCapacity {
return (Double(current) / Double(design)) * 100.0
}
return nil
}
}
public var timeLeft: String {
get {
if let isCharging = self.isCharging {
if let minutes = isCharging ? self.timeToFull : self.timeToEmpty {
if minutes <= 0 {
return "-"
}
return String(format: "%.2d:%.2d", minutes / 60, minutes % 60)
}
}
return "-"
}
}
public var timeRemaining: Int? {
get {
if let isCharging = self.isCharging {
return isCharging ? self.timeToFull : self.timeToEmpty
}
return nil
}
}
}
#endif

View File

@@ -0,0 +1,183 @@
//
// InternalFinder.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/12.
//
// https://stackoverflow.com/questions/57145091/swift-macos-batterylevel-property
#if os(macOS)
import Foundation
import IOKit.ps
public class InternalFinder {
private var serviceInternal: io_connect_t = 0 // io_object_t
private var internalChecked: Bool = false
private var hasInternalBattery: Bool = false
public init() { }
public var batteryPresent: Bool {
get {
if !self.internalChecked {
let snapshot = IOPSCopyPowerSourcesInfo().takeRetainedValue()
let sources = IOPSCopyPowerSourcesList(snapshot).takeRetainedValue() as Array
self.hasInternalBattery = sources.count > 0
self.internalChecked = true
}
return self.hasInternalBattery
}
}
fileprivate func open() {
self.serviceInternal = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery"))
}
fileprivate func close() {
IOServiceClose(self.serviceInternal)
IOObjectRelease(self.serviceInternal)
self.serviceInternal = 0
}
public func getInternalBattery() -> InternalBattery? {
self.open()
if self.serviceInternal == 0 {
return nil
}
let battery = self.getBatteryData()
self.close()
return battery
}
fileprivate func getBatteryData() -> InternalBattery {
let battery = InternalBattery()
let snapshot = IOPSCopyPowerSourcesInfo().takeRetainedValue()
let sources = IOPSCopyPowerSourcesList(snapshot).takeRetainedValue() as Array
for ps in sources {
// Fetch the information for a given power source out of our snapshot
let info = IOPSGetPowerSourceDescription(snapshot, ps).takeUnretainedValue() as! Dictionary<String, Any>
// Pull out the name and capacity
battery.name = info[kIOPSNameKey] as? String
battery.timeToEmpty = info[kIOPSTimeToEmptyKey] as? Int
battery.timeToFull = info[kIOPSTimeToFullChargeKey] as? Int
}
// Capacities
battery.currentCapacity = self.getIntValue("CurrentCapacity" as CFString)
battery.maxCapacity = self.getIntValue("MaxCapacity" as CFString)
battery.designCapacity = self.getIntValue("DesignCapacity" as CFString)
// Battery Cycles
battery.cycleCount = self.getIntValue("CycleCount" as CFString)
battery.designCycleCount = self.getIntValue("DesignCycleCount9C" as CFString)
// Plug
battery.acPowered = self.getBoolValue("ExternalConnected" as CFString)
battery.isCharging = self.getBoolValue("IsCharging" as CFString)
battery.isCharged = self.getBoolValue("FullyCharged" as CFString)
// Power
battery.amperage = self.getIntValue("Amperage" as CFString)
battery.voltage = self.getVoltage()
// Various
battery.temperature = self.getTemperature()
// Manufaction
battery.manufacturer = self.getStringValue("Manufacturer" as CFString)
battery.manufactureDate = self.getManufactureDate()
if let amperage = battery.amperage,
let volts = battery.voltage, let isCharging = battery.isCharging {
let factor: CGFloat = isCharging ? 1 : -1
let watts: CGFloat = (CGFloat(amperage) * CGFloat(volts)) / 1000.0 * factor
battery.watts = Double(watts)
}
return battery
}
fileprivate func getIntValue(_ identifier: CFString) -> Int? {
if let value = IORegistryEntryCreateCFProperty(self.serviceInternal, identifier, kCFAllocatorDefault, 0) {
return value.takeRetainedValue() as? Int
}
return nil
}
fileprivate func getStringValue(_ identifier: CFString) -> String? {
if let value = IORegistryEntryCreateCFProperty(self.serviceInternal, identifier, kCFAllocatorDefault, 0) {
return value.takeRetainedValue() as? String
}
return nil
}
fileprivate func getBoolValue(_ forIdentifier: CFString) -> Bool? {
if let value = IORegistryEntryCreateCFProperty(self.serviceInternal, forIdentifier, kCFAllocatorDefault, 0) {
return value.takeRetainedValue() as? Bool
}
return nil
}
fileprivate func getTemperature() -> Double? {
if let value = IORegistryEntryCreateCFProperty(self.serviceInternal, "Temperature" as CFString, kCFAllocatorDefault, 0) {
return value.takeRetainedValue() as! Double / 100.0
}
return nil
}
fileprivate func getDoubleValue(_ identifier: CFString) -> Double? {
if let value = IORegistryEntryCreateCFProperty(self.serviceInternal, identifier, kCFAllocatorDefault, 0) {
return value.takeRetainedValue() as? Double
}
return nil
}
fileprivate func getVoltage() -> Double? {
if let value = getDoubleValue("Voltage" as CFString) {
return value / 1000.0
}
return nil
}
fileprivate func getManufactureDate() -> Date? {
if let value = IORegistryEntryCreateCFProperty(self.serviceInternal, "ManufactureDate" as CFString, kCFAllocatorDefault, 0) {
let date = value.takeRetainedValue() as! Int
let day = date & 31
let month = (date >> 5) & 15
let year = ((date >> 9) & 127) + 1980
var components = DateComponents()
components.calendar = Calendar.current
components.day = day
components.month = month
components.year = year
return components.date
}
return nil
}
}
#endif

View File

@@ -0,0 +1,83 @@
//
// InternalBatteryMonitor.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2023/08/29.
//
#if os(macOS)
import Foundation
// https://stackoverflow.com/questions/51275093/is-there-a-battery-level-did-change-notification-equivalent-for-kiopscurrentcapa
import Cocoa
import IOKit
// Swift doesn't support nested protocol(?!)
protocol BatteryInfoObserverProtocol: AnyObject {
func batteryInfo(didChange info: BatteryInfo)
}
class BatteryInfo {
typealias ObserverProtocol = BatteryInfoObserverProtocol
struct Observation {
weak var observer: ObserverProtocol?
}
static let shared = BatteryInfo()
private init() {}
private var notificationSource: CFRunLoopSource?
var observers = [ObjectIdentifier: Observation]()
private func startNotificationSource() {
if notificationSource != nil {
stopNotificationSource()
}
notificationSource = IOPSNotificationCreateRunLoopSource({ _ in
BatteryInfo.shared.observers.forEach { (_, value) in
value.observer?.batteryInfo(didChange: BatteryInfo.shared)
}
}, nil).takeRetainedValue() as CFRunLoopSource
CFRunLoopAddSource(CFRunLoopGetCurrent(), notificationSource, .defaultMode)
}
private func stopNotificationSource() {
guard let loop = notificationSource else { return }
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), loop, .defaultMode)
}
func addObserver(_ observer: ObserverProtocol) {
if observers.count == 0 {
startNotificationSource()
}
observers[ObjectIdentifier(observer)] = Observation(observer: observer)
}
func removeObserver(_ observer: ObserverProtocol) {
observers.removeValue(forKey: ObjectIdentifier(observer))
if observers.count == 0 {
stopNotificationSource()
}
}
// Functions for retrieving different properties in the battery description...
}
class BatteryObserver: BatteryInfo.ObserverProtocol {
var batteryInfoClosure: (_ info: BatteryInfo) -> ()
func batteryInfo(didChange info: BatteryInfo) {
self.batteryInfoClosure(info)
}
init(_ callback: @escaping (_ info: BatteryInfo) -> ()) {
self.batteryInfoClosure = callback
BatteryInfo.shared.addObserver(self)
}
deinit {
BatteryInfo.shared.removeObserver(self)
}
}
#endif

View File

@@ -0,0 +1,49 @@
//
// NotificationManager.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/13.
//
#if os(macOS)
import Foundation
import UserNotifications
class NotificationManager: ObservableObject {
var categories: Set<UNNotificationCategory>
init() {
let acceptAction = UNNotificationAction(identifier: "PAIR_ACCEPT_ACTION", title: "Accept", options: [])
let declineAction = UNNotificationAction(identifier: "PAIR_DECLINE_ACTION", title: "Decline", options: [])
let foundAction = UNNotificationAction(identifier: "FMD_FOUND_ACTION", title: "Found", options: [])
let normalCategory = UNNotificationCategory(identifier: "NORMAL", actions: [], intentIdentifiers: [])
let pairRequestCategory = UNNotificationCategory(identifier: "PAIR_REQUEST", actions: [ acceptAction, declineAction ], intentIdentifiers: [], options: .customDismissAction)
let findMyDeviceCategory = UNNotificationCategory(identifier: "FIND_MY_DEVICE", actions: [ foundAction ], intentIdentifiers: [], options: .customDismissAction)
categories = [ normalCategory, pairRequestCategory, findMyDeviceCategory ]
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.setNotificationCategories(categories)
}
func pairRequestPost(title: String, body: String, deviceId: String) {
post(title: title, body: body, userInfo: [ "DEVICE_ID": deviceId ], categoryIdentifier: "PAIR_REQUEST")
}
func post(title: String, body: String, userInfo: [AnyHashable: Any] = [:], categoryIdentifier: String = "NORMAL", interruptionLevel: UNNotificationInterruptionLevel = .timeSensitive) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.userInfo = userInfo
content.categoryIdentifier = categoryIdentifier
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
let uuidString = UUID().uuidString
let request = UNNotificationRequest(identifier: uuidString, content: content, trigger: trigger)
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.add(request)
}
}
#endif

View File

@@ -12,6 +12,8 @@
// Created by Lucas Wang on 2021-09-06.
//
#if !os(macOS)
import UIKit
import SwiftUI
@@ -69,3 +71,5 @@ struct TwoFingerTapView: UIViewRepresentable {
}
}
}
#endif

View File

@@ -12,6 +12,8 @@
// Created by Apollo Zhu on 4/24/22.
//
#if !os(macOS)
import SwiftUI
import Introspect
@@ -109,3 +111,5 @@ fileprivate struct Focuser<Value: Hashable>: ViewModifier {
}
}
}
#endif

View File

@@ -5,6 +5,8 @@
// Created by Ruixuan Tu on 2022-01-20.
//
#if !os(macOS)
import UIKit
import SwiftUI
@@ -46,3 +48,5 @@ struct iOS14CompatibleTextView_Previews: PreviewProvider {
iOS14CompatibleTextView(NSAttributedString(string: "Preview"))
}
}
#endif

View File

@@ -12,6 +12,8 @@
// Created by Lucas Wang on 2021-09-03.
//
#if !os(macOS)
import SwiftUI
struct ConfigureDeviceByIPView: View {
@@ -82,3 +84,5 @@ struct ConfigureDeviceByIPView: View {
// ConfigureDeviceByIPView()
// }
// }
#endif

View File

@@ -12,6 +12,8 @@
// Created by Lucas Wang on 2021-06-17.
//
#if !os(macOS)
import SwiftUI
import UniformTypeIdentifiers
import MediaPicker
@@ -229,3 +231,5 @@ struct DevicesDetailView_Previews: PreviewProvider {
}
}
#endif
#endif

View File

@@ -12,6 +12,8 @@
// Created by Lucas Wang on 2021-06-17.
//
#if !os(macOS)
import SwiftUI
import Combine
import AVFoundation
@@ -382,3 +384,5 @@ struct DevicesView_Previews: PreviewProvider {
}
}
}
#endif

View File

@@ -11,6 +11,8 @@
// Created by Apollo Zhu on 11/3/22.
//
#if !os(macOS)
import SwiftUI
import OrderedCollections
import struct CocoaAsyncSocket.GCDAsyncSocketError
@@ -269,3 +271,5 @@ struct FileTransferStatusSection_Previews: PreviewProvider {
}
}
}
#endif

View File

@@ -0,0 +1,219 @@
//
// DeviceIcon.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/11.
//
#if os(macOS)
import SwiftUI
import MediaPicker
struct DeviceItemView: View {
let deviceId: String
let parent: DevicesView?
@Binding var deviceName: String
let emoji: String
let connState: DevicesView.ConnectionState
let mockBatteryLevel: Int?
@State var backgroundColor: Color
@Environment(\.colorScheme) var colorScheme
init(deviceId: String, parent: DevicesView? = nil, deviceName: Binding<String>, emoji: String, connState: DevicesView.ConnectionState, mockBatteryLevel: Int? = nil) {
self.deviceId = deviceId
self.parent = parent
self._deviceName = deviceName
self.emoji = emoji
self.connState = connState
self.mockBatteryLevel = mockBatteryLevel
switch (connState) {
case .connected:
self._backgroundColor = State(initialValue: .green)
case .saved:
self._backgroundColor = State(initialValue: .gray)
case .visible:
self._backgroundColor = State(initialValue: .cyan)
case .local:
self._backgroundColor = State(initialValue: .cyan)
}
}
func getBackgroundColor(_ connState: DevicesView.ConnectionState) -> Color {
switch (connState) {
case .connected:
return .green
case .saved:
return .gray
case .visible:
return .cyan
case .local:
return .cyan
}
}
func isPluginAvailable(_ plugin: NetworkPacket.`Type`) -> Bool {
if let pluginsEnableStatus = backgroundService.devices[deviceId]?.pluginsEnableStatus {
if pluginsEnableStatus[plugin] != nil {
return (backgroundService.devices[deviceId]?.isPaired() ?? false) && (backgroundService.devices[deviceId]?.isReachable() ?? false)
}
return false
}
return false
}
func isPaired() -> Bool {
backgroundService.devices[self.deviceId]?.isPaired() ?? false
}
func isReachable() -> Bool {
backgroundService.devices[self.deviceId]?.isReachable() ?? false
}
@State private var showingPhotosPicker: Bool = false
@State private var showingFilePicker: Bool = false
@State var chosenFileURLs: [URL] = []
var body: some View {
VStack {
ZStack {
Circle()
.fill(parent?.clickedDeviceId == self.deviceId ? Color.accentColor : self.backgroundColor)
if self.connState == .connected && self.isPluginAvailable(.batteryRequest) {
BatteryStatus(device: backgroundService._devices[self.deviceId]!) { battery in
Circle()
.trim(from: 0, to: CGFloat(battery.remoteChargeLevel) / 100)
.rotation(.degrees(-90))
.stroke(battery.statusColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.brightness(colorScheme == .light ? -0.1 : 0.1)
Text(String(battery.remoteChargeLevel) + "%")
.frame(maxWidth: 64, maxHeight: 64, alignment: .top)
.padding(.top, 2)
.font(.system(.footnote, design: .rounded).weight(.light))
.foregroundColor(.black)
}.onAppear {
(backgroundService._devices[self.deviceId]!._plugins[.batteryRequest] as! Battery)
.sendBatteryStatusOut()
}
} else if self.mockBatteryLevel != nil {
Circle()
.trim(from: 0, to: CGFloat(self.mockBatteryLevel!) / 100)
.rotation(.degrees(-90))
.stroke(.blue, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.brightness(colorScheme == .light ? -0.1 : 0.1)
Text(String(self.mockBatteryLevel!) + "%")
.frame(maxWidth: 64, maxHeight: 64, alignment: .top)
.padding(.top, 2)
.font(.system(.footnote, design: .rounded).weight(.light))
.foregroundColor(.black)
}
Text(emoji)
.font(.system(size: 32))
.shadow(radius: 1)
}
.frame(width: 64, height: 64)
HStack {
Text(deviceName)
.multilineTextAlignment(.center)
.foregroundColor(parent?.clickedDeviceId == self.deviceId ? .white : Color.primary)
.padding(.horizontal, 8)
}.background(RoundedRectangle(cornerRadius: 8)
.fill(parent?.clickedDeviceId == self.deviceId ? .accentColor : Color.blue.opacity(0)))
}.onChange(of: self.connState) { newValue in
withAnimation {
self.backgroundColor = getBackgroundColor(newValue)
}
}.onTapGesture {
parent?.clickedDeviceId = self.deviceId
}.onDrop(of: [.fileURL], isTargeted: nil) { providers in
// Ref: https://stackoverflow.com/questions/60831260/swiftui-drag-and-drop-files
if isPluginAvailable(.share) {
var droppedFileURLs: [URL] = []
providers.forEach { provider in
provider.loadDataRepresentation(forTypeIdentifier: "public.file-url", completionHandler: { (data, error) in
if let data = data, let path = NSString(data: data, encoding: 4), let url = URL(string: path as String) {
droppedFileURLs.append(url)
print("File drppped: ", url)
}
})
}
while droppedFileURLs.count != providers.count {
continue // block thread until all providers are proceeded
}
(backgroundService._devices[self.deviceId]!._plugins[.share] as! Share).prepAndInitFileSend(fileURLs: droppedFileURLs)
return true
} else {
self.backgroundColor = .red
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation {
self.backgroundColor = getBackgroundColor(self.connState)
}
}
return false
}
}.contextMenu {
if parent?.clickedDeviceId == self.deviceId {
if self.connState == .connected || self.connState == .saved {
Button("Unpair") {
if self.isPaired() {
backgroundService.unpairDevice(self.deviceId)
}
}
if self.isReachable() {
if self.isPluginAvailable(.ping) {
Button("Ping") {
(backgroundService.devices[self.deviceId]!.plugins[.ping] as! Ping).sendPing()
}
}
if self.isPluginAvailable(.clipboard) {
Button("Push Local Clipboard") {
(backgroundService.devices[self.deviceId]!.plugins[.clipboard] as! Clipboard).sendClipboardContentOut()
}
}
if self.isPluginAvailable(.share) {
// TODO: fix media sharing
// Button("Send Photos and Videos") {
// showingPhotosPicker = true
// }
Button("Send Files") {
showingFilePicker = true
}
}
} else if self.connState == .connected {
Button("Plugins if reachable") {}.disabled(true)
}
} else {
Button("Pair") {
backgroundService.pairDevice(self.deviceId)
}
}
}
}.mediaImporter(isPresented: $showingPhotosPicker, allowedMediaTypes: .all, allowsMultipleSelection: true) { result in
if case .success(let chosenMediaURLs) = result, !chosenMediaURLs.isEmpty {
(backgroundService._devices[self.deviceId]!._plugins[.share] as! Share).prepAndInitFileSend(fileURLs: chosenMediaURLs)
} else {
print("Media Picker Result: \(result)")
}
}.fileImporter(isPresented: $showingFilePicker, allowedContentTypes: allUTTypes, allowsMultipleSelection: true) { result in
do {
chosenFileURLs = try result.get()
} catch {
print("Document Picker Error")
}
if (chosenFileURLs.count > 0) {
(backgroundService._devices[self.deviceId]!._plugins[.share] as! Share).prepAndInitFileSend(fileURLs: chosenFileURLs)
}
}
}
}
struct DeviceIcon_Previews: PreviewProvider {
static var previews: some View {
DeviceItemView(deviceId: "0", parent: nil, deviceName: .constant("TURX's MacBook Pro"), emoji: "\u{1F5A5}", connState: .local)
}
}
#endif

View File

@@ -0,0 +1,154 @@
//
// ContentView.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/11.
//
#if os(macOS)
import SwiftUI
import Combine
struct DevicesView: View {
var connectedDevicesIds: [String] {
viewModel.connectedDevices.keys.sorted()
}
var visibleDevicesIds: [String] {
viewModel.visibleDevices.keys.sorted()
}
var savedDevicesIds: [String] {
viewModel.savedDevices.keys.sorted()
}
@ObservedObject var viewModel: ConnectedDevicesViewModel = connectedDevicesViewModel
@State public var clickedDeviceId: String
@State private var counter: Int
enum GenMode {
case normal
case empty
case demo
case hundred
}
let genMode: GenMode
enum ConnectionState {
case connected
case saved
case visible
case local
}
init(genMode: GenMode = .normal) {
self.clickedDeviceId = "-1"
self.counter = 0
self.genMode = genMode
}
static func getEmojiFromDeviceType(_ deviceType: DeviceType) -> String {
switch (deviceType) {
case .desktop:
return "\u{1F5A5}"
case .laptop:
return "\u{1F4BB}"
case .phone:
return "\u{1F4F1}"
case .tablet:
return "\u{1F3AC}"
case .appletv:
return "\u{1F4FA}"
case .unknown:
return "\u{2754}"
@unknown default:
return "\u{2753}"
}
}
func getDeviceIcons() -> [DeviceItemView] {
switch (self.genMode) {
case .empty:
return []
case .demo:
return [
DeviceItemView(deviceId: "1", parent: self, deviceName: .constant("My iPhone"), emoji: DevicesView.getEmojiFromDeviceType(.phone), connState: .connected, mockBatteryLevel: 67),
DeviceItemView(deviceId: "2", parent: self, deviceName: .constant("My iMac"), emoji: DevicesView.getEmojiFromDeviceType(.desktop), connState: .connected),
DeviceItemView(deviceId: "3", parent: self, deviceName: .constant("My MacBook"), emoji: DevicesView.getEmojiFromDeviceType(.laptop), connState: .saved),
DeviceItemView(deviceId: "4", parent: self, deviceName: .constant("My iPad"), emoji: DevicesView.getEmojiFromDeviceType(.tablet), connState: .visible),
DeviceItemView(deviceId: "5", parent:self, deviceName: .constant("My Apple TV"), emoji: DevicesView.getEmojiFromDeviceType(.appletv), connState: .visible),
DeviceItemView(deviceId: "6", deviceName: .constant("Unknown device"), emoji: DevicesView.getEmojiFromDeviceType(.unknown), connState: .visible)
]
case .hundred:
var deviceIcons = [DeviceItemView]()
for demoDeviceId in 1...100 {
deviceIcons.append(DeviceItemView(deviceId: String(demoDeviceId), parent: self, deviceName: .constant(String(demoDeviceId)), emoji: "\u{1F4F1}", connState: .saved))
}
return deviceIcons
case .normal:
var deviceIcons = [DeviceItemView]()
for key in connectedDevicesIds {
deviceIcons.append(DeviceItemView(
deviceId: key,
parent: self,
deviceName: .constant(viewModel.connectedDevices[key] ?? "Unknown device"),
emoji: DevicesView.getEmojiFromDeviceType(backgroundService._devices[key]?._deviceInfo.type ?? .unknown),
connState: .connected
))
}
for key in savedDevicesIds {
deviceIcons.append(DeviceItemView(
deviceId: key,
parent: self,
deviceName: .constant(viewModel.savedDevices[key] ?? "Unknown device"),
emoji: DevicesView.getEmojiFromDeviceType(backgroundService._devices[key]?._deviceInfo.type ?? .unknown),
connState: .saved
))
}
for key in visibleDevicesIds {
deviceIcons.append(DeviceItemView(
deviceId: key,
parent: self,
deviceName: .constant(viewModel.visibleDevices[key] ?? "Unknown device"),
emoji: DevicesView.getEmojiFromDeviceType(backgroundService._devices[key]?._deviceInfo.type ?? .unknown),
connState: .visible
))
}
return deviceIcons
}
}
var body: some View {
if getDeviceIcons().isEmpty {
VStack {
Spacer()
Text("No device discovered in the current network.")
.foregroundColor(.secondary)
Spacer()
}
} else {
ScrollView(.vertical) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 128))]) {
ForEach(getDeviceIcons(), id: \.deviceId) {deviceIcon in
deviceIcon
.padding(.all)
}
}
}
.padding(.all)
.onTapGesture {
self.clickedDeviceId = "-1"
}
}
}
}
struct DevicesView_Previews: PreviewProvider {
static var previews: some View {
DevicesView(genMode: .demo)
}
}
#endif

View File

@@ -5,6 +5,8 @@
// Created by Apollo Zhu on 4/29/23.
//
#if !os(macOS)
import SwiftUI
struct FileTransferStatusOverview: View {
@@ -143,3 +145,5 @@ struct FileTransferStatusOverview_Previews: PreviewProvider {
.environmentObject(connectedDevicesViewModel)
}
}
#endif

View File

@@ -11,6 +11,8 @@
// Created by Apollo Zhu on 4/12/23.
//
#if !os(macOS)
import SwiftUI
import Photos
@@ -191,3 +193,5 @@ struct FilesTab_Previews: PreviewProvider {
}
}
#endif
#endif

View File

@@ -11,6 +11,8 @@
// Created by Apollo Zhu on 4/12/23.
//
#if !os(macOS)
import SwiftUI
struct OpenReceivedDocumentsFolderButton: View {
@@ -72,3 +74,5 @@ struct OpenDocumentsFolderInFilesButton_Previews: PreviewProvider {
OpenReceivedDocumentsFolderButton()
}
}
#endif

View File

@@ -12,6 +12,8 @@
// Created by Apollo Zhu on 2/24/22.
//
#if !os(macOS)
import SwiftUI
enum AppIcon: RawRepresentable, CaseIterable {
@@ -109,3 +111,5 @@ struct AppIconPicker_Previews: PreviewProvider {
}
}
}
#endif

View File

@@ -0,0 +1,57 @@
//
// AdvancedSettingsView.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/12.
//
import SwiftUI
struct AdvancedSettingsView: View {
var body: some View {
VStack {
Spacer()
Button {
backgroundService.stopDiscovery()
CertificateService.shared.deleteAllItemsFromKeychain()
UserDefaults.standard.removeObject(forKey: "savedDevices")
exit(0)
} label: {
HStack {
Image(systemName: "delete.right")
VStack(alignment: .leading) {
Text("Erase saved devices cache")
.font(.headline)
Text("If there are phantom devices or something just doesn't behave correctly, erasing all saved devices data might fix it. Requires the app to be fully restarted.")
.font(.caption)
}
}
.foregroundColor(.red)
}.buttonStyle(.plain)
Spacer()
Button {
backgroundService.stopDiscovery()
CertificateService.shared.deleteHostCertificateFromKeychain()
exit(0)
} label: {
HStack {
Image(systemName: "delete.right")
VStack(alignment: .leading) {
Text("Delete host certificate")
.font(.headline)
Text("Delete the host's certificate and re-generate it upon restart. Requires the app to be fully restarted. NOTE: this will make the device unable to connect with previously connected devices. You must unpair this device from the other remote devices and pair them again.")
.font(.caption)
}
}
.foregroundColor(.red)
}.buttonStyle(.plain)
Spacer()
}.padding(.all)
}
}
struct AdvancedSettingsView_Previews: PreviewProvider {
static var previews: some View {
AdvancedSettingsView()
}
}

View File

@@ -0,0 +1,112 @@
//
// AppSettingsView.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/12.
//
#if os(macOS)
import SwiftUI
enum AppIcon: RawRepresentable, CaseIterable {
case `default`
case classic
case roundedRectangle
init?(rawValue: String?) {
switch rawValue {
case "Mac-AppIcon-Classic":
self = .classic
case "Mac-AppIcon-RoundedRectangle":
self = .roundedRectangle
default:
self = .default
}
}
var rawValue: String? {
switch self {
case .default:
return "Mac-AppIcon"
case .classic:
return "Mac-AppIcon-Classic"
case .roundedRectangle:
return "Mac-AppIcon-RoundedRectangle"
}
}
var name: Text {
switch self {
case .default:
return Text("Default")
case .classic:
return Text("Classic")
case .roundedRectangle:
return Text("Rounded Rectangle")
}
}
}
struct AppSettingsView: View {
@EnvironmentObject var settings: KdeConnectSettings
@Binding var chosenTheme: ColorScheme?
private let themes: [ColorScheme?] = [nil, .light, .dark]
@Binding var appIcon: AppIcon
@State private var appIconName: String
init(chosenTheme: Binding<ColorScheme?>, appIcon: Binding<AppIcon>) {
self._chosenTheme = chosenTheme
self._appIcon = appIcon
self.appIconName = appIcon.wrappedValue.rawValue ?? "AppIcon"
}
var body: some View {
VStack {
HStack {
Picker(selection: $chosenTheme, label: Text("App Theme:")) {
ForEach(themes, id: \.self) { theme in
switch theme {
case .light:
Text("Light")
case .dark:
Text("Dark")
default:
Text("Default")
}
}
}
.pickerStyle(RadioGroupPickerStyle())
.horizontalRadioGroupLayout()
Spacer()
}
HStack {
Picker(selection: $appIconName, label: Text("App Icon:")) {
Text("Default").tag("Mac-AppIcon")
Text("Classic").tag("Mac-AppIcon-Classic")
Text("Rounded Rectangle").tag("Mac-AppIcon-RoundedRectangle")
}
.pickerStyle(RadioGroupPickerStyle())
.horizontalRadioGroupLayout()
.onChange(of: appIconName) { iconName in
settings.appIcon = AppIcon(rawValue: (iconName == "Mac-AppIcon" ? nil : iconName))!
NSApplication.shared.applicationIconImage = NSImage(named: appIconName)
}
Spacer()
}
HStack {
Text("Preview: ")
Image(nsImage: NSImage(named: appIconName)!)
Spacer()
}
}.padding(.all)
}
}
struct AppSettingsView_Previews: PreviewProvider {
static var previews: some View {
AppSettingsView(chosenTheme: .constant(nil), appIcon: .constant(AppIcon(rawValue: nil)!))
}
}
#endif

View File

@@ -0,0 +1,44 @@
//
// DeviceSettingsView.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/12.
//
#if os(macOS)
import SwiftUI
struct DeviceSettingsView: View {
@Binding var deviceName: String
// @State private var deviceType: String = "Laptop"
var body: some View {
VStack {
HStack {
Text("Device Name:")
TextField("", text: self.$deviceName)
.onChange(of: self.deviceName) { _ in
MainView.mainViewSingleton?.refreshDiscoveryAndList()
}
}
// HStack {
// Picker(selection: $deviceType, label: Text("Device Type:")) {
// Text("Laptop").tag("Laptop")
// Text("PC").tag("PC")
// }
// .pickerStyle(RadioGroupPickerStyle())
// .horizontalRadioGroupLayout()
// Spacer()
// }
}.padding(.all)
}
}
struct DeviceSettingsView_Previews: PreviewProvider {
static var previews: some View {
DeviceSettingsView(deviceName: .constant("My Laptop"))
}
}
#endif

View File

@@ -0,0 +1,44 @@
//
// PrefPane.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/12.
//
#if os(macOS)
import SwiftUI
struct SettingsView: View {
@ObservedObject private var kdeConnectSettings: KdeConnectSettings = KdeConnectSettings.shared
var body: some View {
TabView {
DeviceSettingsView(deviceName: $kdeConnectSettings.deviceName)
.tabItem {
Label("Device", systemImage: "display")
}
PeerSettingsView(directIPs: $kdeConnectSettings.directIPs)
.tabItem {
Label("Peer", systemImage: "laptopcomputer.and.iphone")
}
AppSettingsView(chosenTheme: $kdeConnectSettings.chosenTheme, appIcon: $kdeConnectSettings.appIcon)
.tabItem {
Label("Application", systemImage: "app")
}
AdvancedSettingsView()
.tabItem {
Label("Advanced", systemImage: "wrench.and.screwdriver")
}
}
.frame(width: 450, height: 250)
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView()
}
}
#endif

View File

@@ -0,0 +1,116 @@
//
// PeerSettingsView.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/12.
//
#if os(macOS)
import SwiftUI
//struct EditButtonStyle: ButtonStyle {
// func makeBody(configuration: Configuration) -> some View {
// HStack {
// configuration.label
// .padding(.horizontal, 4)
// }
// .background(.gray)
// .foregroundColor(.white)
// .border(.black)
// .clipShape(Rectangle())
// }
//}
struct PeerSettingsView: View {
@Binding var directIPs: [String]
@Environment(\.colorScheme) var colorScheme
@State var selectedIndex = -1
@State var editingIndex = -1
var peerList: some View {
ForEach(self.$directIPs.indices, id:\.self) { ind in
HStack {
if self.selectedIndex == ind && self.editingIndex == ind {
TextField("", text: self.$directIPs[ind])
.padding(.horizontal, 4)
.background(.background)
.foregroundColor(.primary)
.onSubmit {
self.editingIndex = -1
}
} else {
Text(self.directIPs[ind])
.padding(.horizontal, 4)
.foregroundColor(self.selectedIndex == ind ? .white : .black)
.onTapGesture {
if self.selectedIndex == ind {
self.editingIndex = ind
} else {
self.selectedIndex = ind
self.editingIndex = -1
}
}
}
Spacer()
}
.background(self.selectedIndex == ind ? (self.colorScheme == .light ? .blue : .orange) : .white)
.frame(alignment: .leading)
}
}
var mainFrame: some View {
HStack {
if self.directIPs.count > 0 {
ScrollView(showsIndicators: true) {
peerList
}.background(.white)
.onTapGesture {
self.selectedIndex = -1
self.editingIndex = -1
}
} else {
VStack {
Rectangle()
.fill(.white)
}
}
}
}
var body: some View {
VStack {
HStack {
Text("Configure Devices by IP:")
Spacer()
}
if colorScheme == .light {
mainFrame
}
else {
mainFrame.colorInvert()
}
HStack {
Button("+") {
self.directIPs.append("IP")
}
Button("-") {
if self.selectedIndex != -1 {
self.directIPs.remove(at: self.selectedIndex)
self.selectedIndex = -1
}
}
Spacer()
}
}
.padding(.all)
}
}
struct PeerSettingsView_Previews: PreviewProvider {
static var previews: some View {
PeerSettingsView(directIPs: .constant([ "127.0.0.1", "192.168.1.1" ]))
}
}
#endif

View File

@@ -5,6 +5,8 @@
// Created by Ruixuan Tu on 2022-01-16.
//
#if !os(macOS)
import UIKit
import SwiftUI
@@ -217,3 +219,5 @@ struct SettingsAboutView_Previews: PreviewProvider {
.environmentObject(KdeConnectSettings.shared)
}
}
#endif

View File

@@ -5,6 +5,8 @@
// Created by Lucas Wang on 2021-09-12.
//
#if !os(macOS)
import SwiftUI
struct SettingsAdvancedView: View {
@@ -134,3 +136,5 @@ struct SettingsAdvancedView: View {
// SettingsAdvancedView()
// }
// }
#endif

View File

@@ -5,6 +5,8 @@
// Created by Lucas Wang on 2021-06-19.
//
#if !os(macOS)
import SwiftUI
struct SettingsChosenThemeView: View {
@@ -28,3 +30,5 @@ struct SettingsChosenThemeView_Previews: PreviewProvider {
SettingsChosenThemeView(chosenTheme: .constant(nil))
}
}
#endif

View File

@@ -5,6 +5,8 @@
// Created by Lucas Wang on 2021-06-17.
//
#if !os(macOS)
import SwiftUI
struct SettingsView: View {
@@ -92,3 +94,5 @@ struct SettingsView: View {
// SettingsView()
// }
// }
#endif

View File

@@ -12,16 +12,39 @@
// Created by Lucas Wang on 2021-06-17.
//
#if !os(macOS)
import UIKit
#else
import UserNotifications
#endif
import SwiftUI
// Intentional naming
// swiftlint:disable:next type_name
@main struct KDE_Connect_App: App {
@ObservedObject var kdeConnectSettingsForTopLevel: KdeConnectSettings = .shared
#if !os(macOS)
@StateObject var alertManager: AlertManager = AlertManager()
#else
@StateObject var notificationManager: NotificationManager = NotificationManager()
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@State var disabledByNotGrantedNotificationPermission: Bool = false
@State var showingHelpWindow: Bool = false
func requestNotification() {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if error != nil {
self.disabledByNotGrantedNotificationPermission = true
} else {
self.disabledByNotGrantedNotificationPermission = false
}
}
}
#endif
var body: some Scene {
#if !os(macOS)
WindowGroup {
MainTabView()
.preferredColorScheme(kdeConnectSettingsForTopLevel.chosenTheme)
@@ -63,5 +86,69 @@ import SwiftUI
message: alertManager.currentAlert.content
)
}
#else
WindowGroup("Connect", id: "connect") {
if !self.disabledByNotGrantedNotificationPermission {
MainView(showingHelpWindow: self.$showingHelpWindow)
.preferredColorScheme(kdeConnectSettingsForTopLevel.chosenTheme)
.onAppear {
NSApplication.shared.applicationIconImage = NSImage(named: (kdeConnectSettingsForTopLevel.appIcon.rawValue ?? "AppIcon"))
requestNotification()
backgroundService.startDiscovery()
requestBatteryStatusAllDevices()
}
.environmentObject(notificationManager)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willUpdateNotification)) { _ in
requestNotification()
}
} else {
AskNotificationView()
.preferredColorScheme(kdeConnectSettingsForTopLevel.chosenTheme)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willUpdateNotification)) { _ in
requestNotification()
}
}
}
.commands {
CommandMenu("Devices") {
if !self.disabledByNotGrantedNotificationPermission {
Button("Refresh Discovery") {
MainView.mainViewSingleton?.refreshDiscoveryAndList()
}
} else {
Label("Refresh Discovery", systemImage: "")
}
Button("Show Received Files in Finder") {
let fileManager = FileManager.default
do {
// see Share plugin
let documentDirectory = try fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
NSWorkspace.shared.open(documentDirectory)
} catch {
print("Error showing received files in Finder \(error)")
}
}
}
}
.windowStyle(.hiddenTitleBar)
WindowGroup("Help", id: "help") {
HelpView(showingHelpWindow: self.$showingHelpWindow)
.preferredColorScheme(kdeConnectSettingsForTopLevel.chosenTheme)
}
.windowStyle(.hiddenTitleBar)
Settings {
if !self.disabledByNotGrantedNotificationPermission {
SettingsView()
.preferredColorScheme(kdeConnectSettingsForTopLevel.chosenTheme)
.environmentObject(kdeConnectSettingsForTopLevel)
} else {
AskNotificationView()
.preferredColorScheme(kdeConnectSettingsForTopLevel.chosenTheme)
.frame(width: 450, height: 250)
}
}
#endif
}
}

View File

@@ -0,0 +1,30 @@
//
// AskNotificationView.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/12.
//
import SwiftUI
struct AskNotificationView: View {
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
Text("Please grant notification permission in order to run KDE Connect.")
.foregroundColor(.secondary)
.padding(.all)
Spacer()
}
Spacer()
}
}
}
struct AskNotificationView_Previews: PreviewProvider {
static var previews: some View {
AskNotificationView()
}
}

View File

@@ -0,0 +1,34 @@
//
// ErrorView.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2023/08/31.
//
import SwiftUI
struct ErrorView: View {
let reason: String
init(_ reason: String) {
self.reason = reason
}
var body: some View {
VStack {
Spacer()
Text("Error Occured").font(.largeTitle)
Spacer()
Text("Reason: " + self.reason)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.pink)
}
}
struct ErrorView_Previews: PreviewProvider {
static var previews: some View {
ErrorView("Preview")
}
}

View File

@@ -0,0 +1,15 @@
There are three states shown as device icon:
- **Connected** (green): paired and reachable,
- **Saved** (gray): paired but not reachable, and
- **Visible** (cyan): unpaired but reachable.
There are some actions you can do with the devices on the main window:
- **Select and right-click** to pair/unpair and interact with connected devices;
- **Drag-and-drop** file to a connected device to send; otherwise the icon will turn red if the device is not reachable.
- **Battery information** will be displayed on icons of eligible devices as an arc:
- **Blue** is for battery consuming and having abundant remaining charge;
- **Yellow** is for battery consuming and having &lt; 40% charge;
- **Red** is for battery consuming and having &lt; 10% charge or reached a threshold; and
- **Green** is for battery charging.

View File

@@ -0,0 +1,76 @@
//
// HelpView.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2023/08/31.
//
#if os(macOS)
import SwiftUI
struct LeadingText: View {
let text: String
init(_ text: String) {
self.text = text
}
var body: some View {
Text(self.text).frame(maxWidth: .infinity, alignment: .leading)
}
}
struct HelpView: View {
@Binding var showingHelpWindow: Bool
@Environment(\.colorScheme) var colorScheme
init(showingHelpWindow: Binding<Bool>) {
self._showingHelpWindow = showingHelpWindow
}
func helpText() -> AttributedString {
let file = Bundle.main.url(forResource: "HelpManual", withExtension: "md")
guard let contents = try? String(contentsOf: file!, encoding: String.Encoding.utf8) else {
return "Help manual not found"
}
guard let markdown = try? AttributedString(
markdown: contents,
options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) else {
return "Help manual parsing failed"
}
return markdown
}
var body: some View {
ScrollView {
VStack(spacing: 20) {
VStack {
Text("Playground").font(.title)
DevicesView(genMode: .demo).background(.brown)
}
HStack {
VStack(spacing: 5) {
Text("Help").font(.title)
Text(helpText()).fixedSize()
}
Spacer()
}
}
.frame(maxWidth: .infinity)
.padding(.all)
}.onDisappear {
self.showingHelpWindow = false
}
}
}
struct HelpView_Previews: PreviewProvider {
static var previews: some View {
HelpView(showingHelpWindow: .constant(true))
}
}
#endif

View File

@@ -0,0 +1,179 @@
//
// ContentView.swift
// KDE Connect
//
// Created by Ruixuan Tu on 2022/05/11.
//
#if os(macOS)
import SwiftUI
import Combine
import AVFoundation
struct MainView: View {
static var mainViewSingleton: MainView?
var deviceView: DevicesView?
@Environment(\.openWindow) private var openWindow
@State static var findMyPhoneTimer = Empty<Date, Never>().eraseToAnyPublisher()
@ObservedObject var selfData = KdeConnectSettings.shared
@State var disabledSingletonConflict: Bool
@Binding var showingHelpWindow: Bool
init(showingHelpWindow: Binding<Bool>) {
self.deviceView = DevicesView()
self._disabledSingletonConflict = State(initialValue: false)
self._showingHelpWindow = showingHelpWindow
}
func helpButton(_ action: @escaping () -> Void) -> some View {
// ref: https://blog.urtti.com/creating-a-macos-help-button-in-swiftui
Button(action: action, label: {
ZStack {
Circle()
.strokeBorder(Color(NSColor.controlShadowColor), lineWidth: 0.5)
.background(Circle().foregroundColor(Color(NSColor.controlColor)))
.shadow(color: Color(NSColor.controlShadowColor).opacity(0.3), radius: 1)
.frame(width: 20, height: 20)
Text("?").font(.system(size: 15, weight: .medium ))
}
})
.buttonStyle(PlainButtonStyle())
}
var body: some View {
if !self.disabledSingletonConflict {
VStack {
deviceView
Divider()
HStack {
Spacer().frame(maxWidth: .infinity)
HStack {
DeviceItemView(deviceId: "0", parent: nil, deviceName: $selfData.deviceName, emoji: DevicesView.getEmojiFromDeviceType(DeviceType.current), connState: .local)
.padding(.all)
}.frame(maxWidth: .infinity)
if !self.showingHelpWindow {
helpButton {
self.showingHelpWindow = true
openWindow(id: "help")
}
.padding(.all)
.frame(maxWidth: .infinity, maxHeight: 128, alignment: .bottomTrailing)
} else {
Spacer().frame(maxWidth: .infinity, maxHeight: 128, alignment: .bottomTrailing)
}
}
}
.refreshable(action: {
refreshDiscoveryAndList()
})
.onAppear {
self.disabledSingletonConflict = MainView.mainViewSingleton != nil
if !self.disabledSingletonConflict {
MainView.mainViewSingleton = self
}
}
.onReceive(NotificationCenter.default.publisher(for: .didReceivePairRequestNotification, object: nil)
.receive(on: RunLoop.main)) { notification in
onPairRequest(fromDeviceWithID: notification.userInfo?["deviceID"] as? String)
}
.onReceive(NotificationCenter.default.publisher(for: .pairRequestTimedOutNotification, object: nil)
.receive(on: RunLoop.main)) { notification in
onPairTimeout(toDeviceWithID: notification.userInfo?["deviceID"] as? String)
}
.onReceive(NotificationCenter.default.publisher(for: .pairRequestSucceedNotification, object: nil)
.receive(on: RunLoop.main)) { notification in
onPairSuccess(withDeviceWithID: notification.userInfo?["deviceID"] as? String)
}
.onReceive(NotificationCenter.default.publisher(for: .pairRequestRejectedNotification, object: nil)
.receive(on: RunLoop.main)) { notification in
onPairRejected(byDeviceWithID: notification.userInfo?["deviceID"] as? String)
}
.onReceive(NotificationCenter.default.publisher(for: .didReceivePingNotification, object: nil)
.receive(on: RunLoop.main)) { _ in
showPingAlert()
}
.onReceive(NotificationCenter.default.publisher(for: .didReceiveFindMyPhoneRequestNotification, object: nil)
.receive(on: RunLoop.main)) { _ in
showFindMyPhoneAlert()
MainView.updateFindMyPhoneTimer(isRunning: true) // TODO: alert sound does not work
}
.onReceive(MainView.findMyPhoneTimer) { _ in
SystemSound.calendarAlert.play()
}
} else {
ErrorView("There cannot be multiple main windows. Please close this window to proceed.")
}
}
@EnvironmentObject var notificationManager: NotificationManager
func currentPairingDeviceName(id: String) -> String? {
backgroundService._devices[id]?._deviceInfo.name
}
func deleteDevice(at offsets: IndexSet) {
offsets
.map { (offset: $0, id: deviceView!.savedDevicesIds[$0]) }
.forEach { device in
// TODO: Update Device.m to indicate nullability
let name = backgroundService._devices[device.id]!._deviceInfo.name
print("Remembered device \(name) removed at index \(device.offset)")
backgroundService.unpairDevice(device.id)
}
}
func onPairRequest(fromDeviceWithID deviceId: String!) {
notificationManager.pairRequestPost(title: "Incoming Pairing Request", body: "\(currentPairingDeviceName(id: deviceId) ?? "Unknown device") wants to pair with this device", deviceId: deviceId)
}
func onPairTimeout(toDeviceWithID deviceId: String!) {
notificationManager.post(title: "Pairing Timed Out", body: "Pairing with \(currentPairingDeviceName(id: deviceId) ?? "Unknown device") failed")
}
func onPairSuccess(withDeviceWithID deviceId: String!) {
notificationManager.post(title: "Pairing Complete", body: "Pairing with \(currentPairingDeviceName(id: deviceId) ?? "Unknown device") succeeded")
}
func onPairRejected(byDeviceWithID deviceId: String!) {
notificationManager.post(title: "Pairing Rejected", body: "Pairing with \(currentPairingDeviceName(id: deviceId) ?? "Unknown device") failed")
}
func showPingAlert() {
SystemSound.smsReceived.play()
notificationManager.post(title: "Ping!", body: "Ping received from a connected device.")
}
func showFindMyPhoneAlert() {
notificationManager.post(title: "Find My Mac Mode", body: "Find My Mac initiated from a remote device", categoryIdentifier: "FIND_MY_DEVICE", interruptionLevel: .critical)
// TODO: notification does not stay
}
static func updateFindMyPhoneTimer(isRunning: Bool) {
if isRunning {
MainView.findMyPhoneTimer = Deferred {
Just(Date())
}
.append(Timer.publish(every: 4, on: .main, in: .common).autoconnect())
.eraseToAnyPublisher()
} else {
MainView.findMyPhoneTimer = Empty<Date, Never>().eraseToAnyPublisher()
}
}
func refreshDiscoveryAndList() {
withAnimation {
backgroundService.refreshDiscovery()
broadcastBatteryStatusAllDevices()
requestBatteryStatusAllDevices()
}
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView(showingHelpWindow: .constant(false))
}
}
#endif

View File

@@ -12,6 +12,9 @@
// Created by Lucas Wang on 2021-06-17.
//
#if !os(macOS)
import SwiftUI
struct MainTabView: View {
@@ -86,3 +89,5 @@ struct TabView_Previews: PreviewProvider {
.environmentObject(connectedDevicesViewModel)
}
}
#endif

View File

@@ -1,6 +1,6 @@
# The Official Repository of KDE Connect iOS
**TL;DR: [Download from the App Store](https://apps.apple.com/app/kde-connect/id1580245991) on an iOS >= 14 device!**
**TL;DR: [Download from the App Store](https://apps.apple.com/app/kde-connect/id1580245991) on an iOS >= 14 or macOS >= 13 device!**
---
@@ -13,9 +13,7 @@ If you would like to talk to the KDE Connect developers & contributors (for ques
## Beta Testing
In addition to the App Store, you can also get the public testing version of KDE Connect iOS
by opening [this TestFlight link](https://testflight.apple.com/join/vxCluwBF).
This version also supports running on Macs with Apple silicon.
In addition to the App Store, you can also get the public testing version of KDE Connect iOS/macOS by opening [this TestFlight link](https://testflight.apple.com/join/vxCluwBF).
## Known Behavior and Problems
@@ -58,7 +56,6 @@ Many tasks only include a high level description and could be easily misinterpre
### Extending to Additional Platforms
- [ ] [Expand to a watchOS](https://community.kde.org/SoK/Ideas/2022#Investigate_Feasibility_of_KDE_Connect_for_watchOS) companion/standalone app?
- [ ] [Expand to macOS](https://community.kde.org/GSoC/2022/Ideas#Project:_Porting_the_KDE_Connect_iOS_app_to_macOS) with catalyst/native SwiftUI?
## History of KDE Connect iOS