This commit is contained in:
rustdesk
2025-07-07 16:54:45 +08:00
parent dd7a124334
commit 458090b737
18 changed files with 1920 additions and 47 deletions

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>RustDesk Screen Broadcast</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.broadcast-services-upload</string>
<key>NSExtensionPrincipalClass</key>
<string>SampleHandler</string>
<key>RPBroadcastProcessMode</key>
<string>RPBroadcastProcessModeSampleBuffer</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,5 @@
#import <ReplayKit/ReplayKit.h>
@interface SampleHandler : RPBroadcastSampleHandler
@end

View File

@@ -0,0 +1,122 @@
#import "SampleHandler.h"
#import <os/log.h>
@interface SampleHandler ()
@property (nonatomic, strong) dispatch_queue_t videoQueue;
@property (nonatomic, assign) CFMessagePortRef messagePort;
@property (nonatomic, assign) BOOL isConnected;
@end
@implementation SampleHandler
- (instancetype)init {
self = [super init];
if (self) {
_videoQueue = dispatch_queue_create("com.rustdesk.broadcast.video", DISPATCH_QUEUE_SERIAL);
_isConnected = NO;
}
return self;
}
- (void)broadcastStartedWithSetupInfo:(NSDictionary<NSString *,NSObject *> *)setupInfo {
// Create message port to communicate with main app
NSString *portName = @"com.rustdesk.screencast.port";
self.messagePort = CFMessagePortCreateRemote(kCFAllocatorDefault, (__bridge CFStringRef)portName);
if (self.messagePort) {
self.isConnected = YES;
os_log_info(OS_LOG_DEFAULT, "Connected to main app via message port");
} else {
os_log_error(OS_LOG_DEFAULT, "Failed to connect to main app");
[self finishBroadcastWithError:[NSError errorWithDomain:@"com.rustdesk.broadcast"
code:1
userInfo:@{NSLocalizedDescriptionKey: @"Failed to connect to main app"}]];
}
}
- (void)broadcastPaused {
// Handle pause
}
- (void)broadcastResumed {
// Handle resume
}
- (void)broadcastFinished {
if (self.messagePort) {
CFRelease(self.messagePort);
self.messagePort = NULL;
}
self.isConnected = NO;
}
- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType {
if (!self.isConnected || !self.messagePort) {
return;
}
switch (sampleBufferType) {
case RPSampleBufferTypeVideo:
dispatch_async(self.videoQueue, ^{
[self processVideoSampleBuffer:sampleBuffer];
});
break;
case RPSampleBufferTypeAudioApp:
case RPSampleBufferTypeAudioMic:
// Handle audio if needed
break;
default:
break;
}
}
- (void)processVideoSampleBuffer:(CMSampleBufferRef)sampleBuffer {
CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
if (!imageBuffer) {
return;
}
CVPixelBufferLockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly);
size_t width = CVPixelBufferGetWidth(imageBuffer);
size_t height = CVPixelBufferGetHeight(imageBuffer);
size_t bytesPerRow = CVPixelBufferGetBytesPerRow(imageBuffer);
void *baseAddress = CVPixelBufferGetBaseAddress(imageBuffer);
if (baseAddress) {
// Create a header with frame info
struct FrameHeader {
uint32_t width;
uint32_t height;
uint32_t dataSize;
} header = {
.width = (uint32_t)width,
.height = (uint32_t)height,
.dataSize = (uint32_t)(width * height * 4) // Always RGBA format
};
// Send header first
CFDataRef headerData = CFDataCreate(kCFAllocatorDefault, (const UInt8 *)&header, sizeof(header));
if (headerData) {
SInt32 result = CFMessagePortSendRequest(self.messagePort, 1, headerData, 1.0, 0.0, NULL, NULL);
CFRelease(headerData);
if (result == kCFMessagePortSuccess) {
// Send frame data
CFDataRef frameData = CFDataCreate(kCFAllocatorDefault, (const UInt8 *)baseAddress, header.dataSize);
if (frameData) {
CFMessagePortSendRequest(self.messagePort, 2, frameData, 1.0, 0.0, NULL, NULL);
CFRelease(frameData);
}
}
}
}
CVPixelBufferUnlockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly);
}
@end

View File

@@ -70,6 +70,8 @@
<string>This app needs camera access to scan QR codes</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs photo library access to get QR codes from image</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access for screen recording with audio</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>

View File

@@ -29,9 +29,9 @@ class HomePageState extends State<HomePage> {
int get selectedIndex => _selectedIndex;
final List<PageShape> _pages = [];
int _chatPageTabIndex = -1;
bool get isChatPageCurrentTab => isAndroid
bool get isChatPageCurrentTab => (isAndroid || isIOS)
? _selectedIndex == _chatPageTabIndex
: false; // change this when ios have chat page
: false;
void refreshPages() {
setState(() {
@@ -52,7 +52,7 @@ class HomePageState extends State<HomePage> {
appBarActions: [],
));
}
if (isAndroid && !bind.isOutgoingOnly()) {
if ((isAndroid || isIOS) && !bind.isOutgoingOnly()) {
_chatPageTabIndex = _pages.length;
_pages.addAll([ChatPage(type: ChatPageType.mobileMain), ServerPage()]);
}

View File

@@ -181,7 +181,11 @@ class _ServerPageState extends State<ServerPage> {
_updateTimer = periodic_immediate(const Duration(seconds: 3), () async {
await gFFI.serverModel.fetchID();
});
gFFI.serverModel.checkAndroidPermission();
if (isAndroid) {
gFFI.serverModel.checkAndroidPermission();
} else if (isIOS) {
gFFI.serverModel.checkIOSPermission();
}
}
@override
@@ -240,7 +244,7 @@ class ServiceNotRunningNotification extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate("android_start_service_tip"),
Text(translate(isAndroid ? "android_start_service_tip" : "Start screen sharing service"),
style:
const TextStyle(fontSize: 12, color: MyTheme.darkGray))
.marginOnly(bottom: 8),
@@ -575,7 +579,7 @@ class _PermissionCheckerState extends State<PermissionChecker> {
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
final hasAudioPermission = androidVersion >= 30;
final hasAudioPermission = isIOS || androidVersion >= 30;
return PaddingCard(
title: translate("Permissions"),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
@@ -599,10 +603,11 @@ class _PermissionCheckerState extends State<PermissionChecker> {
: serverModel.toggleService),
PermissionRow(translate("Input Control"), serverModel.inputOk,
serverModel.toggleInput),
PermissionRow(translate("Transfer file"), serverModel.fileOk,
serverModel.toggleFile),
if (!isIOS)
PermissionRow(translate("Transfer file"), serverModel.fileOk,
serverModel.toggleFile),
hasAudioPermission
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
? PermissionRow(translate(isIOS ? "Microphone" : "Audio Capture"), serverModel.audioOk,
serverModel.toggleAudio)
: Row(children: [
Icon(Icons.info_outline).marginOnly(right: 15),
@@ -612,8 +617,19 @@ class _PermissionCheckerState extends State<PermissionChecker> {
style: const TextStyle(color: MyTheme.darkGray),
))
]),
PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
serverModel.toggleClipboard),
if (!isIOS)
PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
serverModel.toggleClipboard),
if (isIOS) ...[
Row(children: [
Icon(Icons.info_outline, size: 16).marginOnly(right: 8),
Expanded(
child: Text(
translate("File transfer and clipboard sync are not available during iOS screen sharing"),
style: const TextStyle(fontSize: 12, color: MyTheme.darkGray),
))
]).marginOnly(top: 8),
],
]));
}
}

View File

@@ -602,39 +602,44 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
gFFI.serverModel.androidUpdatekeepScreenOn();
}
enhancementsTiles.add(SettingsTile.switchTile(
initialValue: !_floatingWindowDisabled,
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(translate('Floating window')),
Text('* ${translate('floating_window_tip')}',
style: Theme.of(context).textTheme.bodySmall),
]),
onToggle: bind.mainIsOptionFixed(key: kOptionDisableFloatingWindow)
? null
: onFloatingWindowChanged));
if (isAndroid) {
enhancementsTiles.add(SettingsTile.switchTile(
initialValue: !_floatingWindowDisabled,
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(translate('Floating window')),
Text('* ${translate('floating_window_tip')}',
style: Theme.of(context).textTheme.bodySmall),
]),
onToggle: bind.mainIsOptionFixed(key: kOptionDisableFloatingWindow)
? null
: onFloatingWindowChanged));
}
enhancementsTiles.add(_getPopupDialogRadioEntry(
title: 'Keep screen on',
list: [
_RadioEntry('Never', _keepScreenOnToOption(KeepScreenOn.never)),
_RadioEntry('During controlled',
_keepScreenOnToOption(KeepScreenOn.duringControlled)),
_RadioEntry('During service is on',
_keepScreenOnToOption(KeepScreenOn.serviceOn)),
],
getter: () => _keepScreenOnToOption(_floatingWindowDisabled
? KeepScreenOn.never
: optionToKeepScreenOn(
bind.mainGetLocalOption(key: kOptionKeepScreenOn))),
asyncSetter: isOptionFixed(kOptionKeepScreenOn) || _floatingWindowDisabled
? null
: (value) async {
await bind.mainSetLocalOption(
key: kOptionKeepScreenOn, value: value);
setState(() => _keepScreenOn = optionToKeepScreenOn(value));
gFFI.serverModel.androidUpdatekeepScreenOn();
},
));
if (isAndroid) {
enhancementsTiles.add(_getPopupDialogRadioEntry(
title: 'Keep screen on',
list: [
_RadioEntry('Never', _keepScreenOnToOption(KeepScreenOn.never)),
_RadioEntry('During controlled',
_keepScreenOnToOption(KeepScreenOn.duringControlled)),
_RadioEntry('During service is on',
_keepScreenOnToOption(KeepScreenOn.serviceOn)),
],
getter: () => _keepScreenOnToOption(
_floatingWindowDisabled
? KeepScreenOn.never
: optionToKeepScreenOn(
bind.mainGetLocalOption(key: kOptionKeepScreenOn))),
asyncSetter: isOptionFixed(kOptionKeepScreenOn) || _floatingWindowDisabled
? null
: (value) async {
await bind.mainSetLocalOption(
key: kOptionKeepScreenOn, value: value);
setState(() => _keepScreenOn = optionToKeepScreenOn(value));
gFFI.serverModel.androidUpdatekeepScreenOn();
},
));
}
final disabledSettings = bind.isDisableSettings();
final hideSecuritySettings =
@@ -669,7 +674,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
onPressed: (context) {
showServerSettings(gFFI.dialogManager);
}),
if (!isIOS && !_hideNetwork && !_hideProxy)
if (!_hideNetwork && !_hideProxy)
SettingsTile(
title: Text(translate('Socks5/Http(s) Proxy')),
leading: Icon(Icons.network_ping),
@@ -810,7 +815,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
!outgoingOnly &&
!hideSecuritySettings)
SettingsSection(title: Text('2FA'), tiles: tfaTiles),
if (isAndroid &&
if ((isAndroid || isIOS) &&
!disabledSettings &&
!outgoingOnly &&
!hideSecuritySettings)
@@ -819,7 +824,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
tiles: shareScreenTiles,
),
if (!bind.isIncomingOnly()) defaultDisplaySection(),
if (isAndroid &&
if ((isAndroid || isIOS) &&
!disabledSettings &&
!outgoingOnly &&
!hideSecuritySettings)

View File

@@ -226,6 +226,30 @@ class ServerModel with ChangeNotifier {
notifyListeners();
}
/// Check iOS permissions for screen recording and microphone
checkIOSPermission() async {
// For iOS, we need to check screen recording permission
// This is typically done when user tries to start screen sharing
// microphone - only audio available on iOS
final audioOption = await bind.mainGetOption(key: kOptionEnableAudio);
_audioOk = audioOption != 'N';
// file - Not available on iOS during screen share
_fileOk = false;
bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N");
// clipboard - Not available on iOS during screen share
_clipboardOk = false;
bind.mainSetOption(key: kOptionEnableClipboard, value: "N");
// media/screen recording - will be checked when actually starting
_mediaOk = true;
_inputOk = true;
notifyListeners();
}
updatePasswordModel() async {
var update = false;
final temporaryPassword = await bind.mainGetTemporaryPassword();
@@ -311,6 +335,14 @@ class ServerModel with ChangeNotifier {
_audioOk = !_audioOk;
bind.mainSetOption(
key: kOptionEnableAudio, value: _audioOk ? defaultOptionYes : 'N');
// For iOS, automatically restart the service to apply microphone change
// iOS ReplayKit sets microphoneEnabled when capture starts and cannot be changed dynamically
// Must restart capture with new microphone setting
if (isIOS && _isStart) {
_restartServiceForAudio();
}
notifyListeners();
}
@@ -491,6 +523,25 @@ class ServerModel with ChangeNotifier {
}
}
/// Restart service for iOS audio permission change
/// iOS ReplayKit requires setting microphoneEnabled at capture start time
/// Cannot dynamically enable/disable microphone during active capture session
_restartServiceForAudio() async {
if (!isIOS) return;
// Show a quick toast to inform user
showToast(translate("Restarting service to apply microphone change"));
// Stop the current capture
parent.target?.invokeMethod("stop_service");
// Small delay to ensure clean stop
await Future.delayed(Duration(milliseconds: 500));
// Start with new audio settings
parent.target?.invokeMethod("start_service");
}
changeStatue(String name, bool value) {
debugPrint("changeStatue value $value");
switch (name) {
@@ -785,6 +836,7 @@ class ServerModel with ChangeNotifier {
}
}
void androidUpdatekeepScreenOn() async {
if (!isAndroid) return;
var floatingWindowDisabled =