feat(apple): iOS/iPadOS client — touch, pointer lock, shared SwiftUI shell
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
The whole client now runs on iPadOS/iOS from the same sources, first-lit live in the
iPad simulator against the real host at 1280x720@60 (60 fps on the HUD, capture state
machine active, mic permission flow shown).
- PunktfunkCore.xcframework grows iOS device + universal-simulator slices
(BUILD_IOS=1; rustup targets aarch64-apple-ios{,-sim} + x86_64-apple-ios).
- The decode pump is extracted into a shared StreamPump (identical IDR re-gate logic on
both platforms); the iOS StreamView (StreamViewIOS.swift) has the same name/signature
as the macOS one, so ContentView & co. are byte-identical across platforms — hosted
in a UIViewController for prefersPointerLocked (the iPadOS cursor capture; see README
note 9 for the UIHostingController forwarding caveat).
- Touch is always forwarded: per-finger wire ids, coordinates mapped through the
aspect-fit letterbox into LIVE host-mode pixels (surface == host mode, identity
rescale host-side; follows mid-stream requestMode switches).
- InputCapture is cross-platform: GC works the same on iPadOS, ⌘⎋ is detected from the
HID stream there; stale-⌘ tracking after focus loss fixed on both platforms
(releaseAll now drops the modifier/latch state — a ⌘ released in another app
otherwise hijacked Esc forever).
- SessionAudio: AVAudioSession on iOS (.playAndRecord + .defaultToSpeaker — without it
iPhones route host audio to the EARPIECE; deactivated with
notifyOthersOnDeactivation on stop so interrupted background audio resumes); HAL
device pinning + the Settings pickers stay macOS-only.
- New Punktfunk-iOS app target (shared synchronized sources, generated Info.plist with
mic + local-network usage descriptions — QUIC to a LAN host trips local network
privacy on real devices — scene manifest + indirect input events for Stage Manager /
external displays), shared scheme, macOS min-window frames gated off iOS.
For the iPad-on-an-external-screen idea: with multiple scenes + indirect input enabled,
Stage Manager iPads can drag the punktfunk window onto the external display and drive
the PC with keyboard/mouse/touch. Known gaps (README note 9): the pointer-lock
preference isn't consulted through UIHostingController (relative mouse works, the local
cursor just stays visible) and AVAudioSession interruptions don't auto-restart audio.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,10 +8,12 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
AA0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = AA0000000000000000000006 /* PunktfunkKit */; };
|
AA0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = AA0000000000000000000006 /* PunktfunkKit */; };
|
||||||
|
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
BB0000000000000000000001 /* Punktfunk-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -36,6 +38,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
BB0000000000000000000004 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
BB0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -52,6 +62,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AA0000000000000000000001 /* Punktfunk.app */,
|
AA0000000000000000000001 /* Punktfunk.app */,
|
||||||
|
BB0000000000000000000001 /* Punktfunk-iOS.app */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -83,6 +94,30 @@
|
|||||||
productReference = AA0000000000000000000001 /* Punktfunk.app */;
|
productReference = AA0000000000000000000001 /* Punktfunk.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
BB0000000000000000000009 /* Punktfunk-iOS */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = BB000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk-iOS" */;
|
||||||
|
buildPhases = (
|
||||||
|
BB000000000000000000000B /* Sources */,
|
||||||
|
BB0000000000000000000004 /* Frameworks */,
|
||||||
|
BB000000000000000000000C /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
AA0000000000000000000002 /* App */,
|
||||||
|
AA0000000000000000000003 /* Sources/PunktfunkClient */,
|
||||||
|
);
|
||||||
|
name = "Punktfunk-iOS";
|
||||||
|
packageProductDependencies = (
|
||||||
|
BB0000000000000000000006 /* PunktfunkKit */,
|
||||||
|
);
|
||||||
|
productName = "Punktfunk-iOS";
|
||||||
|
productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -114,6 +149,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
AA0000000000000000000009 /* Punktfunk */,
|
AA0000000000000000000009 /* Punktfunk */,
|
||||||
|
BB0000000000000000000009 /* Punktfunk-iOS */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -126,6 +162,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
BB000000000000000000000C /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -136,6 +179,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
BB000000000000000000000B /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -257,6 +307,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
|
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -284,6 +335,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
|
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -298,9 +350,84 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
BB0000000000000000000012 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.ios;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
BB0000000000000000000013 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.ios;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
|
BB000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk-iOS" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
BB0000000000000000000012 /* Debug */,
|
||||||
|
BB0000000000000000000013 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
AA000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk" */ = {
|
AA000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
@@ -333,6 +460,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = PunktfunkKit;
|
productName = PunktfunkKit;
|
||||||
};
|
};
|
||||||
|
BB0000000000000000000006 /* PunktfunkKit */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = PunktfunkKit;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = AA000000000000000000000D /* Project object */;
|
rootObject = AA000000000000000000000D /* Project object */;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2650"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BB0000000000000000000009"
|
||||||
|
BuildableName = "Punktfunk-iOS.app"
|
||||||
|
BlueprintName = "Punktfunk-iOS"
|
||||||
|
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BB0000000000000000000009"
|
||||||
|
BuildableName = "Punktfunk-iOS.app"
|
||||||
|
BlueprintName = "Punktfunk-iOS"
|
||||||
|
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BB0000000000000000000009"
|
||||||
|
BuildableName = "Punktfunk-iOS.app"
|
||||||
|
BlueprintName = "Punktfunk-iOS"
|
||||||
|
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
+26
-3
@@ -97,7 +97,10 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
|||||||
- **Tests from Xcode**: the package tests run with `swift test`; to get them on ⌘U, add
|
- **Tests from Xcode**: the package tests run with `swift test`; to get them on ⌘U, add
|
||||||
`PunktfunkKitTests` once via Edit Scheme → Test → + (Xcode persists it into the shared
|
`PunktfunkKitTests` once via Edit Scheme → Test → + (Xcode persists it into the shared
|
||||||
scheme — a hand-written package-test reference doesn't resolve headlessly).
|
scheme — a hand-written package-test reference doesn't resolve headlessly).
|
||||||
- `xcodebuild -project Punktfunk.xcodeproj -scheme Punktfunk build` works headlessly.
|
- `xcodebuild -project Punktfunk.xcodeproj -scheme Punktfunk build` works headlessly;
|
||||||
|
same for `-scheme Punktfunk-iOS -destination 'generic/platform=iOS Simulator'` (run it
|
||||||
|
in a simulator via `xcrun simctl install/launch` — `SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…`
|
||||||
|
passes the dev autoconnect env through).
|
||||||
|
|
||||||
## Notes for whoever picks this up next
|
## Notes for whoever picks this up next
|
||||||
|
|
||||||
@@ -170,8 +173,28 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
|||||||
while the app has focus, and focus loss also auto-releases everything held. One live capture per process (the GC
|
while the app has focus, and focus loss also auto-releases everything held. One live capture per process (the GC
|
||||||
mouse/keyboard singletons have a single handler slot — ownership is tracked so a stale
|
mouse/keyboard singletons have a single handler slot — ownership is tracked so a stale
|
||||||
capture's stop() can't clobber a newer one).
|
capture's stop() can't clobber a newer one).
|
||||||
9. **iOS**: same package (`BUILD_IOS=1` for the xcframework slice); `StreamView` needs the
|
9. **iOS/iPadOS — ported and first-lit** (iPad simulator ↔ the real host, 60 fps).
|
||||||
`UIViewRepresentable` twin and touch→input mapping.
|
`BUILD_IOS=1 bash scripts/build-xcframework.sh` builds device + universal-simulator
|
||||||
|
slices; the Xcode project has a second target, **Punktfunk-iOS**, sharing the same
|
||||||
|
synchronized sources. The iOS `StreamView` (StreamViewIOS.swift — same name/signature
|
||||||
|
as the macOS one, so the SwiftUI shell is identical) hosts the shared `StreamPump` in
|
||||||
|
a view controller for `prefersPointerLocked`: with a hardware mouse/trackpad that is
|
||||||
|
the iPadOS cursor capture (system honors it fullscreen-and-frontmost; in Stage
|
||||||
|
Manager it degrades to both-cursors forwarding). Touch is always forwarded — every
|
||||||
|
finger gets a wire touch id and coordinates map through the aspect-fit letterbox
|
||||||
|
into host-mode pixels (surface == host mode, so the host rescale is the identity).
|
||||||
|
`InputCapture` is cross-platform (GC works the same on iPadOS; ⌘⎋ is detected from
|
||||||
|
the HID stream there); audio routes via `AVAudioSession` (the Settings device
|
||||||
|
pickers are macOS-only). For the iPad-with-external-display setup: the target
|
||||||
|
enables multiple scenes + indirect input events — on Stage Manager iPads, drag the
|
||||||
|
punktfunk window onto the external screen and the stream runs there with full
|
||||||
|
keyboard/mouse/touch. Known gaps: `prefersPointerLocked` is declared on the stream
|
||||||
|
view controller but UIHostingController doesn't forward the preference from
|
||||||
|
representable children, so the system cursor stays visible (relative-mouse
|
||||||
|
forwarding works regardless — fixing it means putting the controller into the UIKit
|
||||||
|
presentation chain, e.g. a full-screen UIKit presentation on session start); and
|
||||||
|
AVAudioSession interruptions (calls, Siri) don't auto-restart the audio engines yet
|
||||||
|
(reconnect recovers).
|
||||||
|
|
||||||
## Known limitations of the current host (relevant to client UX)
|
## Known limitations of the current host (relevant to client UX)
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ struct AddHostSheet: View {
|
|||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
|
#if os(macOS)
|
||||||
.frame(width: 380)
|
.frame(width: 380)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
// the only way into hosts running --require-pairing. Once pinned, reconnects are silent
|
// the only way into hosts running --require-pairing. Once pinned, reconnects are silent
|
||||||
// and a changed host identity refuses to connect.
|
// and a changed host identity refuses to connect.
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
|
#endif
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -21,6 +23,9 @@ struct ContentView: View {
|
|||||||
@AppStorage("punktfunk.compositor") private var compositor = 0
|
@AppStorage("punktfunk.compositor") private var compositor = 0
|
||||||
@State private var showAddHost = false
|
@State private var showAddHost = false
|
||||||
@State private var pairingTarget: StoredHost?
|
@State private var pairingTarget: StoredHost?
|
||||||
|
#if os(iOS)
|
||||||
|
@State private var showSettings = false
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -70,7 +75,9 @@ struct ContentView: View {
|
|||||||
trustCard(fp)
|
trustCard(fp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(macOS)
|
||||||
.frame(minWidth: 640, minHeight: 360)
|
.frame(minWidth: 640, minHeight: 360)
|
||||||
|
#endif
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,17 +113,38 @@ struct ContentView: View {
|
|||||||
.help("Add a host")
|
.help("Add a host")
|
||||||
}
|
}
|
||||||
ToolbarItem {
|
ToolbarItem {
|
||||||
|
#if os(macOS)
|
||||||
SettingsLink {
|
SettingsLink {
|
||||||
Label("Settings", systemImage: "gearshape")
|
Label("Settings", systemImage: "gearshape")
|
||||||
}
|
}
|
||||||
.help("Stream mode and settings")
|
.help("Stream mode and settings")
|
||||||
|
#else
|
||||||
|
Button {
|
||||||
|
showSettings = true
|
||||||
|
} label: {
|
||||||
|
Label("Settings", systemImage: "gearshape")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(macOS)
|
||||||
.frame(minWidth: 480, minHeight: 360)
|
.frame(minWidth: 480, minHeight: 360)
|
||||||
|
#endif
|
||||||
.sheet(isPresented: $showAddHost) {
|
.sheet(isPresented: $showAddHost) {
|
||||||
AddHostSheet { store.add($0) }
|
AddHostSheet { store.add($0) }
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.sheet(isPresented: $showSettings) {
|
||||||
|
NavigationStack {
|
||||||
|
SettingsView()
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.toolbar {
|
||||||
|
Button("Done") { showSettings = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.alert(
|
.alert(
|
||||||
"Connection failed",
|
"Connection failed",
|
||||||
isPresented: Binding(
|
isPresented: Binding(
|
||||||
@@ -239,7 +267,11 @@ struct ContentView: View {
|
|||||||
model.rejectTrust()
|
model.rejectTrust()
|
||||||
pairingTarget = host
|
pairingTarget = host
|
||||||
}
|
}
|
||||||
|
#if os(macOS)
|
||||||
.buttonStyle(.link)
|
.buttonStyle(.link)
|
||||||
|
#else
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
#endif
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
}
|
}
|
||||||
.padding(28)
|
.padding(28)
|
||||||
@@ -287,11 +319,20 @@ struct ContentView: View {
|
|||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
||||||
// (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again).
|
// (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again).
|
||||||
|
#if os(macOS)
|
||||||
Text(model.mouseCaptured
|
Text(model.mouseCaptured
|
||||||
? "⌘⎋ releases the mouse"
|
? "⌘⎋ releases the mouse"
|
||||||
: "Click the stream to capture input")
|
: "Click the stream to capture input")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.opacity(0.8)
|
.opacity(0.8)
|
||||||
|
#else
|
||||||
|
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
||||||
|
Text(model.mouseCaptured
|
||||||
|
? "⌘⎋ releases keyboard & mouse"
|
||||||
|
: "⌘⎋ captures keyboard & mouse")
|
||||||
|
.font(.caption2)
|
||||||
|
.opacity(0.8)
|
||||||
|
#endif
|
||||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.keyboardShortcut("d", modifiers: .command)
|
.keyboardShortcut("d", modifiers: .command)
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ struct PairSheet: View {
|
|||||||
let onPaired: (Data) -> Void
|
let onPaired: (Data) -> Void
|
||||||
|
|
||||||
@State private var pin = ""
|
@State private var pin = ""
|
||||||
|
#if os(macOS)
|
||||||
@State private var clientName = Host.current().localizedName ?? "Mac"
|
@State private var clientName = Host.current().localizedName ?? "Mac"
|
||||||
|
#else
|
||||||
|
@State private var clientName = UIDevice.current.name
|
||||||
|
#endif
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var errorText: String?
|
@State private var errorText: String?
|
||||||
@State private var token = CeremonyToken()
|
@State private var token = CeremonyToken()
|
||||||
@@ -77,8 +81,10 @@ struct PairSheet: View {
|
|||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
|
#if os(macOS)
|
||||||
.frame(width: 400)
|
.frame(width: 400)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
#endif
|
||||||
.interactiveDismissDisabled(busy)
|
.interactiveDismissDisabled(busy)
|
||||||
.onDisappear { token.cancelled = true } // any other dismissal path
|
.onDisappear { token.cancelled = true } // any other dismissal path
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
// PunktfunkClient — the macOS client app (also runs unbundled via swift run).
|
// PunktfunkClient — the macOS client app (also runs unbundled via swift run).
|
||||||
// Hosts grid → trust-on-first-use → StreamView (AVSampleBufferDisplayLayer HEVC) + input.
|
// Hosts grid → trust-on-first-use → StreamView (AVSampleBufferDisplayLayer HEVC) + input.
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
|
#endif
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct PunktfunkClientApp: App {
|
struct PunktfunkClientApp: App {
|
||||||
|
#if os(macOS)
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup("punktfunk") {
|
WindowGroup("punktfunk") {
|
||||||
ContentView()
|
ContentView()
|
||||||
}
|
}
|
||||||
|
#if os(macOS)
|
||||||
Settings {
|
Settings {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
// `swift run` launches an unbundled binary; promote it to a regular app so the
|
// `swift run` launches an unbundled binary; promote it to a regular app so the
|
||||||
@@ -30,3 +37,4 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
// virtual output at exactly this size/refresh — there is no scaling anywhere in the
|
// virtual output at exactly this size/refresh — there is no scaling anywhere in the
|
||||||
// pipeline.
|
// pipeline.
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
|
#endif
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -11,11 +13,13 @@ struct SettingsView: View {
|
|||||||
@AppStorage("punktfunk.height") private var height = 1080
|
@AppStorage("punktfunk.height") private var height = 1080
|
||||||
@AppStorage("punktfunk.hz") private var hz = 60
|
@AppStorage("punktfunk.hz") private var hz = 60
|
||||||
@AppStorage("punktfunk.compositor") private var compositor = 0
|
@AppStorage("punktfunk.compositor") private var compositor = 0
|
||||||
|
@AppStorage("punktfunk.micEnabled") private var micEnabled = true
|
||||||
|
#if os(macOS)
|
||||||
@AppStorage("punktfunk.speakerUID") private var speakerUID = ""
|
@AppStorage("punktfunk.speakerUID") private var speakerUID = ""
|
||||||
@AppStorage("punktfunk.micUID") private var micUID = ""
|
@AppStorage("punktfunk.micUID") private var micUID = ""
|
||||||
@AppStorage("punktfunk.micEnabled") private var micEnabled = true
|
|
||||||
@State private var outputDevices: [AudioDevice] = []
|
@State private var outputDevices: [AudioDevice] = []
|
||||||
@State private var inputDevices: [AudioDevice] = []
|
@State private var inputDevices: [AudioDevice] = []
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -39,6 +43,7 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
|
#if os(macOS)
|
||||||
Picker("Speaker", selection: $speakerUID) {
|
Picker("Speaker", selection: $speakerUID) {
|
||||||
Text("System default").tag("")
|
Text("System default").tag("")
|
||||||
ForEach(outputDevices) { device in
|
ForEach(outputDevices) { device in
|
||||||
@@ -49,7 +54,9 @@ struct SettingsView: View {
|
|||||||
Text("Unavailable device").tag(speakerUID)
|
Text("Unavailable device").tag(speakerUID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||||
|
#if os(macOS)
|
||||||
Picker("Microphone", selection: $micUID) {
|
Picker("Microphone", selection: $micUID) {
|
||||||
Text("System default").tag("")
|
Text("System default").tag("")
|
||||||
ForEach(inputDevices) { device in
|
ForEach(inputDevices) { device in
|
||||||
@@ -61,6 +68,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(!micEnabled)
|
.disabled(!micEnabled)
|
||||||
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
Text("Audio")
|
Text("Audio")
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -89,19 +97,29 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
|
#if os(macOS)
|
||||||
.frame(width: 380)
|
.frame(width: 380)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
outputDevices = AudioDevices.outputs()
|
outputDevices = AudioDevices.outputs()
|
||||||
inputDevices = AudioDevices.inputs()
|
inputDevices = AudioDevices.inputs()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fillFromMainScreen() {
|
private func fillFromMainScreen() {
|
||||||
|
#if os(macOS)
|
||||||
guard let screen = NSScreen.main else { return }
|
guard let screen = NSScreen.main else { return }
|
||||||
let scale = screen.backingScaleFactor
|
let scale = screen.backingScaleFactor
|
||||||
width = Int(screen.frame.width * scale)
|
width = Int(screen.frame.width * scale)
|
||||||
height = Int(screen.frame.height * scale)
|
height = Int(screen.frame.height * scale)
|
||||||
hz = screen.maximumFramesPerSecond
|
hz = screen.maximumFramesPerSecond
|
||||||
|
#else
|
||||||
|
// nativeBounds is portrait-oriented pixels — streams are landscape.
|
||||||
|
let bounds = UIScreen.main.nativeBounds
|
||||||
|
width = Int(max(bounds.width, bounds.height))
|
||||||
|
height = Int(min(bounds.width, bounds.height))
|
||||||
|
hz = UIScreen.main.maximumFramesPerSecond
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
|
#endif
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
import Foundation
|
import Foundation
|
||||||
import GameController
|
import GameController
|
||||||
import PunktfunkCore
|
import PunktfunkCore
|
||||||
@@ -36,7 +40,9 @@ public final class InputCapture {
|
|||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
private var mice: [GCMouse] = []
|
private var mice: [GCMouse] = []
|
||||||
private var keyboards: [GCKeyboard] = []
|
private var keyboards: [GCKeyboard] = []
|
||||||
|
#if os(macOS)
|
||||||
private var keyEventMonitor: Any?
|
private var keyEventMonitor: Any?
|
||||||
|
#endif
|
||||||
|
|
||||||
// Main-queue-only state (see header comment).
|
// Main-queue-only state (see header comment).
|
||||||
private var residualX: Float = 0
|
private var residualX: Float = 0
|
||||||
@@ -53,6 +59,9 @@ public final class InputCapture {
|
|||||||
/// reaches GCKeyboard, racing the NSEvent monitor — latched here so it can't type
|
/// reaches GCKeyboard, racing the NSEvent monitor — latched here so it can't type
|
||||||
/// an Escape into the host in either toggle direction.
|
/// an Escape into the host in either toggle direction.
|
||||||
private var suppressedVK: UInt32?
|
private var suppressedVK: UInt32?
|
||||||
|
/// Physical ⌘ keys currently held (tracked even while released — the ⌘⎋ toggle and
|
||||||
|
/// its Esc suppression need it in both states).
|
||||||
|
private var cmdKeysDown: Set<UInt32> = []
|
||||||
|
|
||||||
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
||||||
/// locally; while false the user is interacting with the local UI (dragging the
|
/// locally; while false the user is interacting with the local UI (dragging the
|
||||||
@@ -119,8 +128,13 @@ public final class InputCapture {
|
|||||||
if let k = n.object as? GCKeyboard { self?.attach(keyboard: k) }
|
if let k = n.object as? GCKeyboard { self?.attach(keyboard: k) }
|
||||||
})
|
})
|
||||||
// Focus loss: GC stops delivering, so release everything still held host-side.
|
// Focus loss: GC stops delivering, so release everything still held host-side.
|
||||||
|
#if os(macOS)
|
||||||
|
let resignActive = NSApplication.didResignActiveNotification
|
||||||
|
#else
|
||||||
|
let resignActive = UIApplication.willResignActiveNotification
|
||||||
|
#endif
|
||||||
observers.append(NotificationCenter.default.addObserver(
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
forName: NSApplication.didResignActiveNotification, object: nil, queue: .main
|
forName: resignActive, object: nil, queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.releaseAll()
|
self?.releaseAll()
|
||||||
})
|
})
|
||||||
@@ -128,6 +142,8 @@ public final class InputCapture {
|
|||||||
// that one combo is intercepted: swallowing keys wholesale at the monitor level
|
// that one combo is intercepted: swallowing keys wholesale at the monitor level
|
||||||
// risks starving GC's own delivery, so the no-beep behavior lives in
|
// risks starving GC's own delivery, so the no-beep behavior lives in
|
||||||
// StreamLayerView (first responder consumes keyDown/keyUp while captured).
|
// StreamLayerView (first responder consumes keyDown/keyUp while captured).
|
||||||
|
// (On iOS there is no NSEvent monitor — the GC key handler detects the combo.)
|
||||||
|
#if os(macOS)
|
||||||
keyEventMonitor = NSEvent.addLocalMonitorForEvents(
|
keyEventMonitor = NSEvent.addLocalMonitorForEvents(
|
||||||
matching: [.keyDown]
|
matching: [.keyDown]
|
||||||
) { [weak self] event in
|
) { [weak self] event in
|
||||||
@@ -140,16 +156,19 @@ public final class InputCapture {
|
|||||||
}
|
}
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
public func stop() {
|
public func stop() {
|
||||||
releaseAll()
|
releaseAll()
|
||||||
observers.forEach(NotificationCenter.default.removeObserver(_:))
|
observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
observers.removeAll()
|
observers.removeAll()
|
||||||
|
#if os(macOS)
|
||||||
if let monitor = keyEventMonitor {
|
if let monitor = keyEventMonitor {
|
||||||
NSEvent.removeMonitor(monitor)
|
NSEvent.removeMonitor(monitor)
|
||||||
keyEventMonitor = nil
|
keyEventMonitor = nil
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
// Don't clobber the handlers if a newer capture has taken the global devices.
|
// Don't clobber the handlers if a newer capture has taken the global devices.
|
||||||
if Self.activeCapture === self || Self.activeCapture == nil {
|
if Self.activeCapture === self || Self.activeCapture == nil {
|
||||||
for mouse in mice {
|
for mouse in mice {
|
||||||
@@ -172,8 +191,12 @@ public final class InputCapture {
|
|||||||
|
|
||||||
deinit { stop() }
|
deinit { stop() }
|
||||||
|
|
||||||
/// Send release events for everything currently held, and drop the motion residuals.
|
/// Send release events for everything currently held, and drop the motion residuals
|
||||||
|
/// and modifier/latch tracking (GC delivers nothing while inactive, so a ⌘ released
|
||||||
|
/// in another app would otherwise stay "held" here forever — hijacking Esc).
|
||||||
private func releaseAll() {
|
private func releaseAll() {
|
||||||
|
cmdKeysDown.removeAll()
|
||||||
|
suppressedVK = nil
|
||||||
for vk in pressedVKs {
|
for vk in pressedVKs {
|
||||||
connection.send(.key(vk, down: false))
|
connection.send(.key(vk, down: false))
|
||||||
}
|
}
|
||||||
@@ -264,16 +287,32 @@ public final class InputCapture {
|
|||||||
keyboards.append(keyboard)
|
keyboards.append(keyboard)
|
||||||
keyboard.keyboardInput?.keyChangedHandler = { [weak self] _, _, keyCode, pressed in
|
keyboard.keyboardInput?.keyChangedHandler = { [weak self] _, _, keyCode, pressed in
|
||||||
guard let self, let vk = Self.hidToVK[keyCode.rawValue] else { return }
|
guard let self, let vk = Self.hidToVK[keyCode.rawValue] else { return }
|
||||||
|
if vk == 0x5B || vk == 0x5C { // physical ⌘ state, tracked in both states
|
||||||
|
if pressed {
|
||||||
|
self.cmdKeysDown.insert(vk)
|
||||||
|
} else {
|
||||||
|
self.cmdKeysDown.remove(vk)
|
||||||
|
}
|
||||||
|
}
|
||||||
// The ⌘⎋ toggle's Esc — checked before the forwarding gate, because in the
|
// The ⌘⎋ toggle's Esc — checked before the forwarding gate, because in the
|
||||||
// engage direction forwarding is already true when this fires.
|
// engage direction forwarding is already true when this fires.
|
||||||
if vk == self.suppressedVK {
|
if vk == self.suppressedVK {
|
||||||
if !pressed { self.suppressedVK = nil }
|
if !pressed { self.suppressedVK = nil }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
// No NSEvent monitor here — the toggle combo is detected from the HID
|
||||||
|
// stream itself.
|
||||||
|
if pressed, vk == 0x1B, !self.cmdKeysDown.isEmpty {
|
||||||
|
self.suppressedVK = 0x1B
|
||||||
|
self.onToggleCapture?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#endif
|
||||||
guard self.forwarding else { return }
|
guard self.forwarding else { return }
|
||||||
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
|
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
|
||||||
// monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved).
|
// monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved).
|
||||||
if vk == 0x1B, self.pressedVKs.contains(0x5B) || self.pressedVKs.contains(0x5C) {
|
if vk == 0x1B, !self.cmdKeysDown.isEmpty {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if pressed {
|
if pressed {
|
||||||
@@ -325,4 +364,3 @@ public final class InputCapture {
|
|||||||
return m
|
return m
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
// AVAudioEngine ties input+output to one aggregate clock, separate engines keep
|
// AVAudioEngine ties input+output to one aggregate clock, separate engines keep
|
||||||
// arbitrary mic/speaker combinations trivial.
|
// arbitrary mic/speaker combinations trivial.
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -140,9 +139,29 @@ public final class SessionAudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system
|
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system
|
||||||
/// default device. Main thread (engine setup); returns after the engines start —
|
/// default device; on iOS the UIDs are ignored entirely (routes are
|
||||||
/// the mic may start slightly later if the permission prompt is pending.
|
/// AVAudioSession-managed). Main thread (engine setup); returns after the engines
|
||||||
|
/// start — the mic may start slightly later if the permission prompt is pending.
|
||||||
public func start(speakerUID: String, micUID: String, micEnabled: Bool) {
|
public func start(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||||
|
#if os(iOS)
|
||||||
|
// Route + policy live in the session, not per-engine: stereo playback, mic
|
||||||
|
// capture when enabled, Bluetooth allowed. Failure is non-fatal (defaults).
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
if micEnabled {
|
||||||
|
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone
|
||||||
|
// EARPIECE; only affects the built-in route (headphones/BT still win).
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord, mode: .default,
|
||||||
|
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
||||||
|
} else {
|
||||||
|
try session.setCategory(.playback, mode: .default)
|
||||||
|
}
|
||||||
|
try session.setActive(true)
|
||||||
|
} catch {
|
||||||
|
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
startPlayback(speakerUID: speakerUID)
|
startPlayback(speakerUID: speakerUID)
|
||||||
guard micEnabled else { return }
|
guard micEnabled else { return }
|
||||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||||
@@ -180,6 +199,16 @@ public final class SessionAudio {
|
|||||||
if wasDraining {
|
if wasDraining {
|
||||||
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
|
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
// Release the session so audio we interrupted (Music, podcasts) gets its
|
||||||
|
// resume cue.
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(
|
||||||
|
false, options: .notifyOthersOnDeactivation)
|
||||||
|
} catch {
|
||||||
|
log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Playback (host → speaker)
|
// MARK: - Playback (host → speaker)
|
||||||
@@ -190,6 +219,7 @@ public final class SessionAudio {
|
|||||||
let ring = AudioRing(capacity: 96_000, prefill: 1920)
|
let ring = AudioRing(capacity: 96_000, prefill: 1920)
|
||||||
|
|
||||||
let engine = AVAudioEngine()
|
let engine = AVAudioEngine()
|
||||||
|
#if os(macOS)
|
||||||
if !speakerUID.isEmpty {
|
if !speakerUID.isEmpty {
|
||||||
if let dev = AudioDevices.deviceID(forUID: speakerUID),
|
if let dev = AudioDevices.deviceID(forUID: speakerUID),
|
||||||
let unit = engine.outputNode.audioUnit {
|
let unit = engine.outputNode.audioUnit {
|
||||||
@@ -200,6 +230,7 @@ public final class SessionAudio {
|
|||||||
log.warning("speaker \(speakerUID) not present — using default")
|
log.warning("speaker \(speakerUID) not present — using default")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring.
|
// Engine-native deinterleaved float; the render block deinterleaves from the ring.
|
||||||
guard let format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 2)
|
guard let format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 2)
|
||||||
@@ -282,6 +313,7 @@ public final class SessionAudio {
|
|||||||
private func startCapture(micUID: String) {
|
private func startCapture(micUID: String) {
|
||||||
let engine = AVAudioEngine()
|
let engine = AVAudioEngine()
|
||||||
let input = engine.inputNode
|
let input = engine.inputNode
|
||||||
|
#if os(macOS)
|
||||||
if !micUID.isEmpty {
|
if !micUID.isEmpty {
|
||||||
if let dev = AudioDevices.deviceID(forUID: micUID), let unit = input.audioUnit {
|
if let dev = AudioDevices.deviceID(forUID: micUID), let unit = input.audioUnit {
|
||||||
if !Self.setDevice(dev, on: unit) {
|
if !Self.setDevice(dev, on: unit) {
|
||||||
@@ -291,6 +323,7 @@ public final class SessionAudio {
|
|||||||
log.warning("microphone \(micUID) not present — using default")
|
log.warning("microphone \(micUID) not present — using default")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
let inFormat = input.outputFormat(forBus: 0)
|
let inFormat = input.outputFormat(forBus: 0)
|
||||||
guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else {
|
guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else {
|
||||||
@@ -376,11 +409,12 @@ public final class SessionAudio {
|
|||||||
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
private static func setDevice(_ id: AudioDeviceID, on unit: AudioUnit) -> Bool {
|
private static func setDevice(_ id: AudioDeviceID, on unit: AudioUnit) -> Bool {
|
||||||
var dev = id
|
var dev = id
|
||||||
return AudioUnitSetProperty(
|
return AudioUnitSetProperty(
|
||||||
unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0,
|
unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0,
|
||||||
&dev, UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr
|
&dev, UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// The platform-independent heart of the presenters: one thread pulling AUs from the
|
||||||
|
// connection into an AVSampleBufferDisplayLayer, with the format description refreshed
|
||||||
|
// on every IDR (the host opens with an IDR carrying in-band parameter sets; recovery
|
||||||
|
// keyframes re-send them — there is no out-of-band extradata, ever). Shared by the
|
||||||
|
// macOS StreamLayerView and the iOS/iPadOS stream view.
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Cancellation handle owned by exactly one pump thread — a restart hands the old pump
|
||||||
|
/// its own token, so it can never be revived by a newer start().
|
||||||
|
private final class PumpToken: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var live = true
|
||||||
|
var isLive: Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return live
|
||||||
|
}
|
||||||
|
func cancel() {
|
||||||
|
lock.lock()
|
||||||
|
live = false
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pump per instance; create a fresh StreamPump per start (cancel is permanent).
|
||||||
|
final class StreamPump {
|
||||||
|
private let token = PumpToken()
|
||||||
|
|
||||||
|
/// Pump thread: pull AUs, wrap, enqueue. Non-IDR AUs before the first format
|
||||||
|
/// description are dropped. `onFrame`/`onSessionEnd` fire on the pump thread.
|
||||||
|
func start(
|
||||||
|
connection: PunktfunkConnection,
|
||||||
|
layer: AVSampleBufferDisplayLayer,
|
||||||
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
|
onSessionEnd: (@Sendable () -> Void)?
|
||||||
|
) {
|
||||||
|
let token = token
|
||||||
|
layer.flush() // drop any frames a previous connection left queued
|
||||||
|
|
||||||
|
let thread = Thread {
|
||||||
|
var format: CMVideoFormatDescription?
|
||||||
|
while token.isLive {
|
||||||
|
do {
|
||||||
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
|
onFrame?(au)
|
||||||
|
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||||
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
|
}
|
||||||
|
if layer.status == .failed {
|
||||||
|
// Decode wedged: flush and re-gate on the next in-band parameter
|
||||||
|
// sets — resuming with a delta frame can't recover. (A
|
||||||
|
// request-IDR channel on punktfunk/1 is a host-side TODO; with the
|
||||||
|
// host's infinite GOP this may otherwise stay black until the
|
||||||
|
// next recovery keyframe.)
|
||||||
|
layer.flush()
|
||||||
|
format = AnnexB.formatDescription(fromIDR: au.data)
|
||||||
|
}
|
||||||
|
guard let f = format,
|
||||||
|
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
||||||
|
token.isLive // don't enqueue a stale frame after a restart
|
||||||
|
else { continue }
|
||||||
|
layer.enqueue(sample)
|
||||||
|
} catch {
|
||||||
|
if token.isLive {
|
||||||
|
onSessionEnd?()
|
||||||
|
}
|
||||||
|
break // session closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thread.name = "punktfunk-pump"
|
||||||
|
thread.qualityOfService = .userInteractive
|
||||||
|
thread.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop pumping (≤ one poll timeout). Does not close the connection.
|
||||||
|
func stop() {
|
||||||
|
token.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { token.cancel() }
|
||||||
|
}
|
||||||
@@ -99,25 +99,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public final class StreamLayerView: NSView {
|
public final class StreamLayerView: NSView {
|
||||||
/// Cancellation handle owned by exactly one pump thread — a restart hands the old pump
|
|
||||||
/// its own token, so it can never be revived by a newer start().
|
|
||||||
private final class PumpToken: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var live = true
|
|
||||||
var isLive: Bool {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return live
|
|
||||||
}
|
|
||||||
func cancel() {
|
|
||||||
lock.lock()
|
|
||||||
live = false
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||||
private var token: PumpToken?
|
private var pump: StreamPump?
|
||||||
public private(set) var connection: PunktfunkConnection?
|
public private(set) var connection: PunktfunkConnection?
|
||||||
private let cursorCapture = CursorCapture()
|
private let cursorCapture = CursorCapture()
|
||||||
private var inputCapture: InputCapture?
|
private var inputCapture: InputCapture?
|
||||||
@@ -261,7 +244,7 @@ public final class StreamLayerView: NSView {
|
|||||||
// A click is explicit intent AND may arrive mid-activation (acceptsFirstMouse:
|
// A click is explicit intent AND may arrive mid-activation (acceptsFirstMouse:
|
||||||
// NSApp.isActive / isKeyWindow are still false for the click coming in from
|
// NSApp.isActive / isKeyWindow are still false for the click coming in from
|
||||||
// another app) — only the auto-engage paths require already-held key status.
|
// another app) — only the auto-engage paths require already-held key status.
|
||||||
guard captureEnabled, !captured, token != nil, window != nil,
|
guard captureEnabled, !captured, pump != nil, window != nil,
|
||||||
fromClick || (NSApp.isActive && window?.isKeyWindow == true)
|
fromClick || (NSApp.isActive && window?.isKeyWindow == true)
|
||||||
else { return }
|
else { return }
|
||||||
cursorCapture.capture(in: self)
|
cursorCapture.capture(in: self)
|
||||||
@@ -297,11 +280,7 @@ public final class StreamLayerView: NSView {
|
|||||||
onSessionEnd: (@Sendable () -> Void)? = nil
|
onSessionEnd: (@Sendable () -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
stop()
|
stop()
|
||||||
let token = PumpToken()
|
|
||||||
self.token = token
|
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
let layer = displayLayer
|
|
||||||
layer.flush() // drop any frames a previous connection left queued
|
|
||||||
|
|
||||||
// The view owns the session's input capture: handlers attach now, but nothing is
|
// The view owns the session's input capture: handlers attach now, but nothing is
|
||||||
// forwarded until capture engages (captureEnabled + auto-engage or a click).
|
// forwarded until capture engages (captureEnabled + auto-engage or a click).
|
||||||
@@ -324,40 +303,11 @@ public final class StreamLayerView: NSView {
|
|||||||
capture.start()
|
capture.start()
|
||||||
inputCapture = capture
|
inputCapture = capture
|
||||||
|
|
||||||
let thread = Thread {
|
let pump = StreamPump()
|
||||||
var format: CMVideoFormatDescription?
|
pump.start(
|
||||||
while token.isLive {
|
connection: connection, layer: displayLayer,
|
||||||
do {
|
onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
self.pump = pump
|
||||||
onFrame?(au)
|
|
||||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
|
||||||
}
|
|
||||||
if layer.status == .failed {
|
|
||||||
// Decode wedged: flush and re-gate on the next in-band parameter
|
|
||||||
// sets — resuming with a delta frame can't recover. (A
|
|
||||||
// request-IDR channel on punktfunk/1 is a host-side TODO; with the
|
|
||||||
// host's infinite GOP this may otherwise stay black until the
|
|
||||||
// next recovery keyframe.)
|
|
||||||
layer.flush()
|
|
||||||
format = AnnexB.formatDescription(fromIDR: au.data)
|
|
||||||
}
|
|
||||||
guard let f = format,
|
|
||||||
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
|
||||||
token.isLive // don't enqueue a stale frame after a restart
|
|
||||||
else { continue }
|
|
||||||
layer.enqueue(sample)
|
|
||||||
} catch {
|
|
||||||
if token.isLive {
|
|
||||||
onSessionEnd?()
|
|
||||||
}
|
|
||||||
break // session closed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
thread.name = "punktfunk-pump"
|
|
||||||
thread.qualityOfService = .userInteractive
|
|
||||||
thread.start()
|
|
||||||
requestAutoCapture() // entering a session is the deliberate "capture me" moment
|
requestAutoCapture() // entering a session is the deliberate "capture me" moment
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,15 +317,15 @@ public final class StreamLayerView: NSView {
|
|||||||
releaseCapture()
|
releaseCapture()
|
||||||
inputCapture?.stop()
|
inputCapture?.stop()
|
||||||
inputCapture = nil
|
inputCapture = nil
|
||||||
token?.cancel()
|
pump?.stop()
|
||||||
token = nil
|
pump = nil
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
appObservers.forEach(NotificationCenter.default.removeObserver(_:))
|
appObservers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
windowObservers.forEach(NotificationCenter.default.removeObserver(_:))
|
windowObservers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
token?.cancel()
|
pump?.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
// iOS/iPadOS presenter: the same AVSampleBufferDisplayLayer + StreamPump as macOS,
|
||||||
|
// hosted in a UIViewController so the scene can pointer-lock (the iPadOS equivalent of
|
||||||
|
// the Mac's cursor capture — with a hardware mouse/trackpad the system cursor is hidden
|
||||||
|
// and GCMouse's raw deltas drive the host cursor alone; the system only honors the lock
|
||||||
|
// fullscreen-and-frontmost, so in Stage Manager it degrades to Mac-style "both cursors
|
||||||
|
// visible" forwarding).
|
||||||
|
//
|
||||||
|
// Touch is the primary input and is always forwarded (touching the video IS explicit
|
||||||
|
// intent): every finger maps to a wire touch id, coordinates are mapped through the
|
||||||
|
// aspect-fit letterbox into host-mode pixels, so surface == host mode and the host's
|
||||||
|
// rescale is the identity. Hardware keyboard/mouse forwarding shares InputCapture with
|
||||||
|
// macOS — auto-engaged when streaming starts, ⌘⎋ toggles (detected from the HID stream;
|
||||||
|
// there is no NSEvent monitor here).
|
||||||
|
//
|
||||||
|
// The public type is named StreamView like its macOS twin (each is platform-gated), so
|
||||||
|
// the SwiftUI app layer is identical on both platforms.
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import AVFoundation
|
||||||
|
import GameController
|
||||||
|
import PunktfunkCore
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public struct StreamView: UIViewControllerRepresentable {
|
||||||
|
private let connection: PunktfunkConnection
|
||||||
|
private let captureEnabled: Bool
|
||||||
|
private let onCaptureChange: ((Bool) -> Void)?
|
||||||
|
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||||
|
private let onSessionEnd: (@Sendable () -> Void)?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
connection: PunktfunkConnection,
|
||||||
|
captureEnabled: Bool = true,
|
||||||
|
onCaptureChange: ((Bool) -> Void)? = nil,
|
||||||
|
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||||
|
onSessionEnd: (@Sendable () -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.connection = connection
|
||||||
|
self.captureEnabled = captureEnabled
|
||||||
|
self.onCaptureChange = onCaptureChange
|
||||||
|
self.onFrame = onFrame
|
||||||
|
self.onSessionEnd = onSessionEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makeUIViewController(context: Context) -> StreamViewController {
|
||||||
|
let controller = StreamViewController()
|
||||||
|
controller.onCaptureChange = onCaptureChange
|
||||||
|
controller.captureEnabled = captureEnabled
|
||||||
|
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUIViewController(_ controller: StreamViewController, context: Context) {
|
||||||
|
controller.onCaptureChange = onCaptureChange
|
||||||
|
controller.captureEnabled = captureEnabled
|
||||||
|
if controller.connection !== connection {
|
||||||
|
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func dismantleUIViewController(
|
||||||
|
_ controller: StreamViewController, coordinator: ()
|
||||||
|
) {
|
||||||
|
controller.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class StreamViewController: UIViewController {
|
||||||
|
public private(set) var connection: PunktfunkConnection?
|
||||||
|
private var pump: StreamPump?
|
||||||
|
private var inputCapture: InputCapture?
|
||||||
|
private var captured = false
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
|
var onCaptureChange: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
var captureEnabled = true {
|
||||||
|
didSet {
|
||||||
|
guard captureEnabled != oldValue else { return }
|
||||||
|
setCaptured(captureEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var streamView: StreamLayerUIView {
|
||||||
|
// swiftlint:disable:next force_cast
|
||||||
|
view as! StreamLayerUIView
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func loadView() {
|
||||||
|
view = StreamLayerUIView()
|
||||||
|
}
|
||||||
|
|
||||||
|
public override var prefersPointerLocked: Bool { captured }
|
||||||
|
public override var prefersHomeIndicatorAutoHidden: Bool { true }
|
||||||
|
|
||||||
|
func start(
|
||||||
|
connection: PunktfunkConnection,
|
||||||
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
|
onSessionEnd: (@Sendable () -> Void)?
|
||||||
|
) {
|
||||||
|
stop()
|
||||||
|
self.connection = connection
|
||||||
|
loadViewIfNeeded()
|
||||||
|
// Read the LIVE mode per touch batch — an accepted requestMode() mid-stream
|
||||||
|
// changes the letterbox, and touches must follow it.
|
||||||
|
streamView.currentHostMode = { [weak connection] in
|
||||||
|
guard let connection else { return .zero }
|
||||||
|
let mode = connection.currentMode()
|
||||||
|
return CGSize(width: Double(mode.width), height: Double(mode.height))
|
||||||
|
}
|
||||||
|
streamView.onTouchEvent = { [weak connection] event in
|
||||||
|
connection?.send(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
let capture = InputCapture(connection: connection)
|
||||||
|
capture.onToggleCapture = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.setCaptured(!self.captured)
|
||||||
|
}
|
||||||
|
capture.onPreempted = { [weak self] in
|
||||||
|
self?.setCaptured(false)
|
||||||
|
}
|
||||||
|
capture.start()
|
||||||
|
inputCapture = capture
|
||||||
|
|
||||||
|
let pump = StreamPump()
|
||||||
|
pump.start(
|
||||||
|
connection: connection, layer: streamView.displayLayer,
|
||||||
|
onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
|
self.pump = pump
|
||||||
|
|
||||||
|
// GC only delivers while active; everything held is flushed by InputCapture's
|
||||||
|
// own resign observer — here we just mirror the capture state for the HUD and
|
||||||
|
// the pointer lock.
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: UIApplication.willResignActiveNotification, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.setCaptured(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
if captureEnabled {
|
||||||
|
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
setCaptured(false)
|
||||||
|
observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
|
observers.removeAll()
|
||||||
|
inputCapture?.stop()
|
||||||
|
inputCapture = nil
|
||||||
|
pump?.stop()
|
||||||
|
pump = nil
|
||||||
|
connection = nil
|
||||||
|
streamView.onTouchEvent = nil
|
||||||
|
streamView.currentHostMode = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setCaptured(_ on: Bool) {
|
||||||
|
if on {
|
||||||
|
guard captureEnabled, !captured, pump != nil else { return }
|
||||||
|
inputCapture?.setForwarding(true)
|
||||||
|
captured = true
|
||||||
|
} else {
|
||||||
|
guard captured else { return }
|
||||||
|
inputCapture?.setForwarding(false)
|
||||||
|
captured = false
|
||||||
|
}
|
||||||
|
setNeedsUpdateOfPrefersPointerLocked()
|
||||||
|
let onCaptureChange = onCaptureChange
|
||||||
|
let captured = captured
|
||||||
|
DispatchQueue.main.async { onCaptureChange?(captured) }
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
|
pump?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The layer-backed video surface + touch source. Touches are mapped through the
|
||||||
|
/// aspect-fit letterbox into host-mode pixels (surface == host mode, so the host-side
|
||||||
|
/// rescale is the identity); touches outside the video area are clamped onto its edge.
|
||||||
|
final class StreamLayerUIView: UIView {
|
||||||
|
override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self }
|
||||||
|
var displayLayer: AVSampleBufferDisplayLayer {
|
||||||
|
// swiftlint:disable:next force_cast
|
||||||
|
layer as! AVSampleBufferDisplayLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the LIVE negotiated mode in pixels (the touch coordinate space).
|
||||||
|
var currentHostMode: (() -> CGSize)?
|
||||||
|
var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
|
||||||
|
|
||||||
|
/// Wire touch ids per active UITouch; ids are reused after the touch ends.
|
||||||
|
private var touchIDs: [ObjectIdentifier: UInt32] = [:]
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
displayLayer.videoGravity = .resizeAspect
|
||||||
|
isMultipleTouchEnabled = true
|
||||||
|
backgroundColor = .black
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("not used") }
|
||||||
|
|
||||||
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
forward(touches, kind: .down)
|
||||||
|
}
|
||||||
|
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
forward(touches, kind: .move)
|
||||||
|
}
|
||||||
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
forward(touches, kind: .up)
|
||||||
|
}
|
||||||
|
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
forward(touches, kind: .up)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TouchKind { case down, move, up }
|
||||||
|
|
||||||
|
private func forward(_ touches: Set<UITouch>, kind: TouchKind) {
|
||||||
|
guard let hostMode = currentHostMode?(),
|
||||||
|
hostMode.width > 0, hostMode.height > 0, onTouchEvent != nil
|
||||||
|
else { return }
|
||||||
|
let video = AVMakeRect(aspectRatio: hostMode, insideRect: bounds)
|
||||||
|
guard video.width > 0, video.height > 0 else { return }
|
||||||
|
for touch in touches {
|
||||||
|
let key = ObjectIdentifier(touch)
|
||||||
|
let id: UInt32
|
||||||
|
switch kind {
|
||||||
|
case .down:
|
||||||
|
id = nextFreeID()
|
||||||
|
touchIDs[key] = id
|
||||||
|
case .move, .up:
|
||||||
|
guard let known = touchIDs[key] else { continue }
|
||||||
|
id = known
|
||||||
|
}
|
||||||
|
if kind == .up {
|
||||||
|
touchIDs.removeValue(forKey: key)
|
||||||
|
onTouchEvent?(.touchUp(id: id))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let p = touch.location(in: self)
|
||||||
|
let x = Int32(((p.x - video.minX) / video.width * hostMode.width)
|
||||||
|
.rounded().clamped(to: 0...(hostMode.width - 1)))
|
||||||
|
let y = Int32(((p.y - video.minY) / video.height * hostMode.height)
|
||||||
|
.rounded().clamped(to: 0...(hostMode.height - 1)))
|
||||||
|
let w = UInt32(hostMode.width)
|
||||||
|
let h = UInt32(hostMode.height)
|
||||||
|
onTouchEvent?(
|
||||||
|
kind == .down
|
||||||
|
? .touchDown(id: id, x: x, y: y, surfaceWidth: w, surfaceHeight: h)
|
||||||
|
: .touchMove(id: id, x: x, y: y, surfaceWidth: w, surfaceHeight: h))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nextFreeID() -> UInt32 {
|
||||||
|
var id: UInt32 = 0
|
||||||
|
while touchIDs.values.contains(id) { id += 1 }
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CGFloat {
|
||||||
|
fileprivate func clamped(to range: ClosedRange<CGFloat>) -> CGFloat {
|
||||||
|
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
TARGETS_MAC=(aarch64-apple-darwin x86_64-apple-darwin)
|
TARGETS_MAC=(aarch64-apple-darwin x86_64-apple-darwin)
|
||||||
BUILD_IOS="${BUILD_IOS:-0}" # BUILD_IOS=1 adds an iOS slice (requires the ios target installed)
|
BUILD_IOS="${BUILD_IOS:-0}" # BUILD_IOS=1 adds iOS device + simulator slices (rustup targets aarch64-apple-ios{,-sim})
|
||||||
|
|
||||||
# Deployment targets must match Package.swift's platforms, or every consumer link emits
|
# Deployment targets must match Package.swift's platforms, or every consumer link emits
|
||||||
# "object file was built for newer macOS version" warnings.
|
# "object file was built for newer macOS version" warnings.
|
||||||
@@ -20,6 +20,8 @@ for t in "${TARGETS_MAC[@]}"; do
|
|||||||
done
|
done
|
||||||
if [[ "$BUILD_IOS" == "1" ]]; then
|
if [[ "$BUILD_IOS" == "1" ]]; then
|
||||||
IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo build --release -p punktfunk-core --features quic --target aarch64-apple-ios
|
IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo build --release -p punktfunk-core --features quic --target aarch64-apple-ios
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo build --release -p punktfunk-core --features quic --target aarch64-apple-ios-sim
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo build --release -p punktfunk-core --features quic --target x86_64-apple-ios
|
||||||
fi
|
fi
|
||||||
|
|
||||||
STAGE="$(mktemp -d)"
|
STAGE="$(mktemp -d)"
|
||||||
@@ -48,7 +50,14 @@ EOF
|
|||||||
|
|
||||||
ARGS=(-library "$STAGE/macos/libpunktfunk_core.a" -headers "$STAGE/include")
|
ARGS=(-library "$STAGE/macos/libpunktfunk_core.a" -headers "$STAGE/include")
|
||||||
if [[ "$BUILD_IOS" == "1" ]]; then
|
if [[ "$BUILD_IOS" == "1" ]]; then
|
||||||
|
# Universal simulator lib (arm64 Macs run arm64 sims, but generic builds link x86_64 too).
|
||||||
|
mkdir -p "$STAGE/iossim"
|
||||||
|
lipo -create \
|
||||||
|
target/aarch64-apple-ios-sim/release/libpunktfunk_core.a \
|
||||||
|
target/x86_64-apple-ios/release/libpunktfunk_core.a \
|
||||||
|
-output "$STAGE/iossim/libpunktfunk_core.a"
|
||||||
ARGS+=(-library target/aarch64-apple-ios/release/libpunktfunk_core.a -headers "$STAGE/include")
|
ARGS+=(-library target/aarch64-apple-ios/release/libpunktfunk_core.a -headers "$STAGE/include")
|
||||||
|
ARGS+=(-library "$STAGE/iossim/libpunktfunk_core.a" -headers "$STAGE/include")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Cargo does NOT fingerprint MACOSX_DEPLOYMENT_TARGET — units cached from a build without
|
# Cargo does NOT fingerprint MACOSX_DEPLOYMENT_TARGET — units cached from a build without
|
||||||
|
|||||||
Reference in New Issue
Block a user