Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e340b6d6a | ||
|
|
ec12492ea7 | ||
|
|
13ccbb3866 | ||
|
|
20d6e221a4 | ||
|
|
2bfddf255a | ||
|
|
0e8903d233 | ||
|
|
78f385d732 | ||
|
|
bd306ba07f | ||
|
|
ececc03d46 | ||
|
|
6202a53153 | ||
|
|
84b6b22f75 | ||
|
|
f8388658d8 | ||
|
|
3c36964b5c |
No files matched your search
@@ -0,0 +1,91 @@
|
|||||||
|
name: Build VoidLink IPA
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- Integration
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build .ipa and publish source
|
||||||
|
runs-on: macos
|
||||||
|
env:
|
||||||
|
# Per-app: change APP_NAME for each app repo. Used everywhere so the
|
||||||
|
# perlis dir name and file names always match (no hardcoded drift).
|
||||||
|
APP_NAME: VoidLink
|
||||||
|
# Point xcodebuild at full Xcode without sudo (sudo hangs in CI).
|
||||||
|
# DEVELOPER_DIR overrides the active developer directory for all steps.
|
||||||
|
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Checkout submodules
|
||||||
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
|
- name: Select Xcode
|
||||||
|
run: xcodebuild -version
|
||||||
|
|
||||||
|
- name: Build unsigned .ipa
|
||||||
|
run: |
|
||||||
|
# Build the app (no signing — SideStore signs on-device).
|
||||||
|
# VoidLink's scheme is auto-generated from the VoidLink target.
|
||||||
|
# Release config → bundle id com.voidlink.tf.iOS (Debug uses
|
||||||
|
# com.voidlink.tf.debug10.iOS), so the .ipa matches the metadata.
|
||||||
|
xcodebuild \
|
||||||
|
-scheme VoidLink \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-configuration Release \
|
||||||
|
archive \
|
||||||
|
-archivePath $PWD/build/VoidLink.xcarchive \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
CODE_SIGNING_REQUIRED="NO" \
|
||||||
|
CODE_SIGN_ENTITLEMENTS="" \
|
||||||
|
CODE_SIGNING_ALLOWED="NO"
|
||||||
|
|
||||||
|
# Package the .app bundle into an .ipa (Payload/App.app structure)
|
||||||
|
mkdir -p build/IPA/Payload
|
||||||
|
cp -R build/VoidLink.xcarchive/Products/Applications/VoidLink.app build/IPA/Payload/VoidLink.app
|
||||||
|
cd build/IPA
|
||||||
|
zip -r ../../$APP_NAME.ipa Payload
|
||||||
|
cd ../..
|
||||||
|
ls -la $APP_NAME.ipa
|
||||||
|
|
||||||
|
- name: Generate app metadata
|
||||||
|
run: |
|
||||||
|
# Read the version from the built app so it always matches the .ipa
|
||||||
|
# (no hardcoded drift). CFBundleShortVersionString = MARKETING_VERSION.
|
||||||
|
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" \
|
||||||
|
build/VoidLink.xcarchive/Products/Applications/VoidLink.app/Info.plist)
|
||||||
|
SIZE=$(stat -f%z $APP_NAME.ipa)
|
||||||
|
DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
# Per-app metadata file. The perlis aggregator folds this into the
|
||||||
|
# root source.json at https://apps.bueso.eu/source.json.
|
||||||
|
cat > $APP_NAME.json <<EOF
|
||||||
|
{
|
||||||
|
"name": "VoidLink",
|
||||||
|
"bundleIdentifier": "com.voidlink.tf.iOS",
|
||||||
|
"developerName": "Luis",
|
||||||
|
"localizedDescription": "VoidLink (Moonlight fork) - stream from your PC. Built via Gitea Actions.",
|
||||||
|
"iconURL": "https://apps.bueso.eu/$APP_NAME/icon.png",
|
||||||
|
"downloadURL": "https://apps.bueso.eu/$APP_NAME/$APP_NAME.ipa",
|
||||||
|
"version": "$VERSION",
|
||||||
|
"versionDate": "$DATE",
|
||||||
|
"size": $SIZE,
|
||||||
|
"permissions": []
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
cat $APP_NAME.json
|
||||||
|
|
||||||
|
- name: Upload to source host (perlis)
|
||||||
|
run: |
|
||||||
|
# Upload .ipa + per-app metadata + icon to perlis via SSH.
|
||||||
|
# PERLIS_SSH_HOST is the Tailscale MagicDNS name (perlis.bueso.loc),
|
||||||
|
# reachable from the iMac runner over the Tailscale mesh.
|
||||||
|
ssh -o StrictHostKeyChecking=no \
|
||||||
|
"${{ secrets.PERLIS_SSH_USER }}@${{ secrets.PERLIS_SSH_HOST }}" \
|
||||||
|
"mkdir -p /mnt/appdata/ios-apps/$APP_NAME"
|
||||||
|
scp -o StrictHostKeyChecking=no \
|
||||||
|
$APP_NAME.ipa $APP_NAME.json icon.png \
|
||||||
|
"${{ secrets.PERLIS_SSH_USER }}@${{ secrets.PERLIS_SSH_HOST }}:/mnt/appdata/ios-apps/$APP_NAME/"
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
1850C82A2E667E4000845308 /* MicHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850C8292E667E4000845308 /* MicHandler.swift */; };
|
1850C82A2E667E4000845308 /* MicHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850C8292E667E4000845308 /* MicHandler.swift */; };
|
||||||
18729EDE2E8BD68800101194 /* UUIDHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18729EDD2E8BD68800101194 /* UUIDHelper.swift */; };
|
18729EDE2E8BD68800101194 /* UUIDHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18729EDD2E8BD68800101194 /* UUIDHelper.swift */; };
|
||||||
18780CC72F10D66000B90483 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 18780CC62F10D66000B90483 /* StoreKit.framework */; };
|
18780CC72F10D66000B90483 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 18780CC62F10D66000B90483 /* StoreKit.framework */; };
|
||||||
|
A76B00032FFCC00100000003 /* GameSirG8MFiRumbler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B00022FFCC00100000002 /* GameSirG8MFiRumbler.swift */; };
|
||||||
|
A76B00052FFCC00100000005 /* ExternalAccessory.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A76B00042FFCC00100000004 /* ExternalAccessory.framework */; };
|
||||||
18780CCA2F10D6CC00B90483 /* IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18780CC92F10D6CC00B90483 /* IAPManager.swift */; };
|
18780CCA2F10D6CC00B90483 /* IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18780CC92F10D6CC00B90483 /* IAPManager.swift */; };
|
||||||
187F4B282E04658A00EECC59 /* WidgetPanelStackView.m in Sources */ = {isa = PBXBuildFile; fileRef = 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */; };
|
187F4B282E04658A00EECC59 /* WidgetPanelStackView.m in Sources */ = {isa = PBXBuildFile; fileRef = 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */; };
|
||||||
1888C1922E24C33100108730 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1912E24C33100108730 /* AboutViewController.swift */; };
|
1888C1922E24C33100108730 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1912E24C33100108730 /* AboutViewController.swift */; };
|
||||||
@@ -200,6 +202,8 @@
|
|||||||
1850C8292E667E4000845308 /* MicHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicHandler.swift; sourceTree = "<group>"; };
|
1850C8292E667E4000845308 /* MicHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicHandler.swift; sourceTree = "<group>"; };
|
||||||
18729EDD2E8BD68800101194 /* UUIDHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDHelper.swift; sourceTree = "<group>"; };
|
18729EDD2E8BD68800101194 /* UUIDHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDHelper.swift; sourceTree = "<group>"; };
|
||||||
18780CC62F10D66000B90483 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
|
18780CC62F10D66000B90483 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
|
||||||
|
A76B00022FFCC00100000002 /* GameSirG8MFiRumbler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameSirG8MFiRumbler.swift; sourceTree = "<group>"; };
|
||||||
|
A76B00042FFCC00100000004 /* ExternalAccessory.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ExternalAccessory.framework; path = System/Library/Frameworks/ExternalAccessory.framework; sourceTree = SDKROOT; };
|
||||||
18780CC92F10D6CC00B90483 /* IAPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPManager.swift; sourceTree = "<group>"; };
|
18780CC92F10D6CC00B90483 /* IAPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPManager.swift; sourceTree = "<group>"; };
|
||||||
187F4B232E04633100EECC59 /* WidgetPanelStackView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WidgetPanelStackView.h; sourceTree = "<group>"; };
|
187F4B232E04633100EECC59 /* WidgetPanelStackView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WidgetPanelStackView.h; sourceTree = "<group>"; };
|
||||||
187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WidgetPanelStackView.m; sourceTree = "<group>"; };
|
187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WidgetPanelStackView.m; sourceTree = "<group>"; };
|
||||||
@@ -471,6 +475,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
A76B00052FFCC00100000005 /* ExternalAccessory.framework in Frameworks */,
|
||||||
18A0EE272EFB9B5A00BA853A /* SVGKitSwift in Frameworks */,
|
18A0EE272EFB9B5A00BA853A /* SVGKitSwift in Frameworks */,
|
||||||
18A0EE252EFB9B5A00BA853A /* SVGKit in Frameworks */,
|
18A0EE252EFB9B5A00BA853A /* SVGKit in Frameworks */,
|
||||||
9890CF6B203B7EE1006C4B06 /* libxml2.tbd in Frameworks */,
|
9890CF6B203B7EE1006C4B06 /* libxml2.tbd in Frameworks */,
|
||||||
@@ -648,6 +653,7 @@
|
|||||||
FB290CF019B2C406004C83CF /* Frameworks */ = {
|
FB290CF019B2C406004C83CF /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A76B00042FFCC00100000004 /* ExternalAccessory.framework */,
|
||||||
18780CC62F10D66000B90483 /* StoreKit.framework */,
|
18780CC62F10D66000B90483 /* StoreKit.framework */,
|
||||||
98882A032AF60F5300C5A11C /* libavcodec.a */,
|
98882A032AF60F5300C5A11C /* libavcodec.a */,
|
||||||
98882A052AF60F5300C5A11C /* libavformat.a */,
|
98882A052AF60F5300C5A11C /* libavformat.a */,
|
||||||
@@ -750,6 +756,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
84351B052C29ACAF00A77989 /* CustomOSC */,
|
84351B052C29ACAF00A77989 /* CustomOSC */,
|
||||||
|
A76B00022FFCC00100000002 /* GameSirG8MFiRumbler.swift */,
|
||||||
FB89460A19F646E200339C8A /* ControllerSupport.h */,
|
FB89460A19F646E200339C8A /* ControllerSupport.h */,
|
||||||
FB89460B19F646E200339C8A /* ControllerSupport.m */,
|
FB89460B19F646E200339C8A /* ControllerSupport.m */,
|
||||||
FB89460C19F646E200339C8A /* StreamView.h */,
|
FB89460C19F646E200339C8A /* StreamView.h */,
|
||||||
@@ -1243,6 +1250,7 @@
|
|||||||
FBD3494319FC9C04002D2A60 /* AppAssetManager.m in Sources */,
|
FBD3494319FC9C04002D2A60 /* AppAssetManager.m in Sources */,
|
||||||
FB6549561A57907E001C8F39 /* DiscoveryWorker.m in Sources */,
|
FB6549561A57907E001C8F39 /* DiscoveryWorker.m in Sources */,
|
||||||
84351B0E2C29ACCB00A77989 /* LayoutOnScreenControls.m in Sources */,
|
84351B0E2C29ACCB00A77989 /* LayoutOnScreenControls.m in Sources */,
|
||||||
|
A76B00032FFCC00100000003 /* GameSirG8MFiRumbler.swift in Sources */,
|
||||||
FB89462A19F646E200339C8A /* ControllerSupport.m in Sources */,
|
FB89462A19F646E200339C8A /* ControllerSupport.m in Sources */,
|
||||||
FB9AFD3D1A7E111600872C98 /* AppAssetResponse.m in Sources */,
|
FB9AFD3D1A7E111600872C98 /* AppAssetResponse.m in Sources */,
|
||||||
1888C1922E24C33100108730 /* AboutViewController.swift in Sources */,
|
1888C1922E24C33100108730 /* AboutViewController.swift in Sources */,
|
||||||
|
|||||||
@@ -2637,7 +2637,6 @@
|
|||||||
</state>
|
</state>
|
||||||
<connections>
|
<connections>
|
||||||
<action selector="closeTapped:" destination="xSZ-i2-Jts" eventType="touchUpInside" id="sc6-Mq-rbQ"/>
|
<action selector="closeTapped:" destination="xSZ-i2-Jts" eventType="touchUpInside" id="sc6-Mq-rbQ"/>
|
||||||
<action selector="undoTapped:" destination="xSZ-i2-Jts" eventType="touchUpInside" id="d4b-0R-vJU"/>
|
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
<button autoresizesSubviews="NO" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="F7X-l4-cl3" userLabel="Undo">
|
<button autoresizesSubviews="NO" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="F7X-l4-cl3" userLabel="Undo">
|
||||||
@@ -3977,19 +3976,19 @@
|
|||||||
<image name="tray.full.fill" catalog="system" width="128" height="88"/>
|
<image name="tray.full.fill" catalog="system" width="128" height="88"/>
|
||||||
<image name="video.and.waveform" catalog="system" width="128" height="94"/>
|
<image name="video.and.waveform" catalog="system" width="128" height="94"/>
|
||||||
<systemColor name="linkColor">
|
<systemColor name="linkColor">
|
||||||
<color red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
<systemColor name="systemBackgroundColor">
|
<systemColor name="systemBackgroundColor">
|
||||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
<systemColor name="systemBrownColor">
|
<systemColor name="systemBrownColor">
|
||||||
<color red="0.63529411759999999" green="0.51764705879999995" blue="0.36862745099999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.67450980392156867" green="0.49803921568627452" blue="0.36862745098039218" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
<systemColor name="systemGray3Color">
|
<systemColor name="systemGray3Color">
|
||||||
<color red="0.78039215689999997" green="0.78039215689999997" blue="0.80000000000000004" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.7803921568627451" green="0.7803921568627451" blue="0.80000000000000004" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
<systemColor name="systemTealColor">
|
<systemColor name="systemTealColor">
|
||||||
<color red="0.18823529410000001" green="0.69019607839999997" blue="0.78039215689999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.0" green="0.76470588235294112" blue="0.81568627450980391" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
@@ -315,6 +315,8 @@ final class ControllerNavigator: NSObject {
|
|||||||
static var settingsReadTipSuppressNextRelease = false
|
static var settingsReadTipSuppressNextRelease = false
|
||||||
|
|
||||||
static var navigationTimer: SafeTimer?
|
static var navigationTimer: SafeTimer?
|
||||||
|
private static let navigationInitialRepeatDelay: TimeInterval = 0.23
|
||||||
|
private static let navigationContinuousRepeatInterval: TimeInterval = 0.177
|
||||||
private static let controllerDrivenUIKitAnimationWakeToken = ControllerDrivenUIKitAnimationWakeToken()
|
private static let controllerDrivenUIKitAnimationWakeToken = ControllerDrivenUIKitAnimationWakeToken()
|
||||||
@objc static weak var controllerNavigationHighlightedView: UIView?
|
@objc static weak var controllerNavigationHighlightedView: UIView?
|
||||||
private static let installAlertControllerNavigationHook: Void = {
|
private static let installAlertControllerNavigationHook: Void = {
|
||||||
@@ -520,19 +522,27 @@ final class ControllerNavigator: NSObject {
|
|||||||
|
|
||||||
*/
|
*/
|
||||||
private static func navigateContinuously(forward: Bool) {
|
private static func navigateContinuously(forward: Bool) {
|
||||||
navigationTimer?.clean()
|
navigationTimer?.clean()
|
||||||
navigationTimer = SafeTimer(interval: 0.23) {
|
uiNavigationDelegate?.navigateByController(forward: forward)
|
||||||
uiNavigationDelegate?.navigateByController(forward: forward)
|
navigationTimer = SafeTimer(
|
||||||
}
|
interval: navigationContinuousRepeatInterval,
|
||||||
navigationTimer?.restart()
|
delay: navigationInitialRepeatDelay
|
||||||
|
) {
|
||||||
|
uiNavigationDelegate?.navigateByController(forward: forward)
|
||||||
|
}
|
||||||
|
navigationTimer?.restart(minimumRunCount: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func navigateContinuously(downward: Bool) {
|
private static func navigateContinuously(downward: Bool) {
|
||||||
navigationTimer?.clean()
|
navigationTimer?.clean()
|
||||||
navigationTimer = SafeTimer(interval: 0.23) {
|
uiNavigationDelegate?.navigateByController(downward: downward)
|
||||||
uiNavigationDelegate?.navigateByController(downward: downward)
|
navigationTimer = SafeTimer(
|
||||||
}
|
interval: navigationContinuousRepeatInterval,
|
||||||
navigationTimer?.restart()
|
delay: navigationInitialRepeatDelay
|
||||||
|
) {
|
||||||
|
uiNavigationDelegate?.navigateByController(downward: downward)
|
||||||
|
}
|
||||||
|
navigationTimer?.restart(minimumRunCount: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc static func start() {
|
@objc static func start() {
|
||||||
@@ -768,7 +778,7 @@ final class ControllerNavigator: NSObject {
|
|||||||
radialMenuButtonPressed = false
|
radialMenuButtonPressed = false
|
||||||
radialMenuDelayTimer?.clean()
|
radialMenuDelayTimer?.clean()
|
||||||
stickReleasedInRadialMenu = false
|
stickReleasedInRadialMenu = false
|
||||||
if radialMenuView == nil, uiNavigationDelegate is StreamFrameViewController {
|
if radialMenuView == nil, let vc = uiNavigationDelegate as? StreamFrameViewController, vc.hasNoPresentedVC {
|
||||||
ControllerSupport.sharedInstance()?.sendNavigationButtonPress()
|
ControllerSupport.sharedInstance()?.sendNavigationButtonPress()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1228,6 +1238,12 @@ extension SettingsViewController: ControllerUINavigationDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let identifer = identifer,
|
||||||
|
let target = nearestSelectableControllerNavigationTarget(to: identifer, selectableTargets: selectableTargets) {
|
||||||
|
highlightControllerNavigationView(target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
highlightControllerNavigationView(selectableTargets[0])
|
highlightControllerNavigationView(selectableTargets[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1250,6 +1266,14 @@ extension SettingsViewController: ControllerUINavigationDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let highlightedView = ControllerNavigator.controllerNavigationHighlightedView,
|
||||||
|
!isControllerNavigationSectionHeader(highlightedView),
|
||||||
|
let highlightedIdentifier = controllerNavigationPersistenceIdentifier(for: highlightedView),
|
||||||
|
let target = nearestSelectableControllerNavigationTarget(to: highlightedIdentifier, selectableTargets: selectableTargets) {
|
||||||
|
highlightControllerNavigationView(target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
highlightControllerNavigationView(selectableTargets[0])
|
highlightControllerNavigationView(selectableTargets[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1270,6 +1294,31 @@ extension SettingsViewController: ControllerUINavigationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func nearestSelectableControllerNavigationTarget(to identifier: String, selectableTargets: [UIView]) -> UIView? {
|
||||||
|
let orderedTargets = controllerNavigationTargetsIncludingUnavailable()
|
||||||
|
guard let persistedIndex = orderedTargets.firstIndex(where: {
|
||||||
|
controllerNavigationPersistenceIdentifier(for: $0) == identifier
|
||||||
|
}) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for distance in 1..<orderedTargets.count {
|
||||||
|
let forwardIndex = persistedIndex + distance
|
||||||
|
if forwardIndex < orderedTargets.count,
|
||||||
|
let target = selectableTargets.first(where: { $0 === orderedTargets[forwardIndex] }) {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
let backwardIndex = persistedIndex - distance
|
||||||
|
if backwardIndex >= 0,
|
||||||
|
let target = selectableTargets.first(where: { $0 === orderedTargets[backwardIndex] }) {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func controllerNavigationPersistenceIdentifier(for view: UIView) -> String? {
|
func controllerNavigationPersistenceIdentifier(for view: UIView) -> String? {
|
||||||
if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem,
|
if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem,
|
||||||
let topLevelStack = topLevelFavoriteSettingStack(containing: view),
|
let topLevelStack = topLevelFavoriteSettingStack(containing: view),
|
||||||
@@ -1570,6 +1619,44 @@ extension SettingsViewController: ControllerUINavigationDelegate {
|
|||||||
return targets
|
return targets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func controllerNavigationTargetsIncludingUnavailable() -> [UIView] {
|
||||||
|
guard let parentStack = parentStack else { return [] }
|
||||||
|
|
||||||
|
if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem {
|
||||||
|
return parentStack.arrangedSubviews.compactMap { $0 as? UIStackView }
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets: [UIView] = []
|
||||||
|
for arrangedSubview in parentStack.arrangedSubviews {
|
||||||
|
guard let sectionView = arrangedSubview as? MenuSectionView else { continue }
|
||||||
|
|
||||||
|
if let headerView = sectionView.headerView {
|
||||||
|
targets.append(headerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !shouldSkipControllerNavigationSection(sectionView) else { continue }
|
||||||
|
guard let rootStackView = sectionView.rootStackView else { continue }
|
||||||
|
|
||||||
|
for arrangedRootSubview in rootStackView.arrangedSubviews {
|
||||||
|
guard let stackView = arrangedRootSubview as? UIStackView else { continue }
|
||||||
|
targets.append(contentsOf: deepestControllerNavigationStacks(from: stackView))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deepestControllerNavigationStacks(from stackView: UIStackView) -> [UIStackView] {
|
||||||
|
let childStackViews = stackView.arrangedSubviews.compactMap { $0 as? UIStackView }
|
||||||
|
guard !childStackViews.isEmpty else {
|
||||||
|
return [stackView]
|
||||||
|
}
|
||||||
|
|
||||||
|
return childStackViews.flatMap {
|
||||||
|
deepestControllerNavigationStacks(from: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func shouldSkipControllerNavigationSection(_ sectionView: MenuSectionView) -> Bool {
|
private func shouldSkipControllerNavigationSection(_ sectionView: MenuSectionView) -> Bool {
|
||||||
guard let identifier = sectionView.identifier else {
|
guard let identifier = sectionView.identifier else {
|
||||||
return false
|
return false
|
||||||
@@ -1604,7 +1691,6 @@ extension SettingsViewController: ControllerUINavigationDelegate {
|
|||||||
if skippingDisabledStacks, hasDisabledArrangedSubview(in: stackView) {
|
if skippingDisabledStacks, hasDisabledArrangedSubview(in: stackView) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return [stackView]
|
return [stackView]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
#import "ControllerSupport.h"
|
#import "ControllerSupport.h"
|
||||||
|
#import "OnScreenControls.h"
|
||||||
#import "VoidController.h"
|
#import "VoidController.h"
|
||||||
#import "VoidLink-Swift.h"
|
#import "VoidLink-Swift.h"
|
||||||
#import "OnScreenControls.h"
|
|
||||||
|
|
||||||
#import "DataManager.h"
|
#import "DataManager.h"
|
||||||
#include "Limelight.h"
|
#include "Limelight.h"
|
||||||
@@ -118,6 +118,9 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
|
|||||||
TemporarySettings* tempSettings;
|
TemporarySettings* tempSettings;
|
||||||
OSCProfile* oscProfile;
|
OSCProfile* oscProfile;
|
||||||
OSCProfilesManager* oscProfileMan;
|
OSCProfilesManager* oscProfileMan;
|
||||||
|
#if !TARGET_OS_TV
|
||||||
|
GameSirG8MFiRumbler *_gameSirG8MFiRumbler;
|
||||||
|
#endif
|
||||||
|
|
||||||
#define EMULATING_SELECT 0x1
|
#define EMULATING_SELECT 0x1
|
||||||
#define EMULATING_SPECIAL 0x2
|
#define EMULATING_SPECIAL 0x2
|
||||||
@@ -155,6 +158,20 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
|
|||||||
|
|
||||||
#define MAX_MAGNITUDE(x, y) (abs(x) > abs(y) ? (x) : (y))
|
#define MAX_MAGNITUDE(x, y) (abs(x) > abs(y) ? (x) : (y))
|
||||||
|
|
||||||
|
-(void) applyPhysicalControllerRumble:(VoidController*)controller lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor
|
||||||
|
{
|
||||||
|
#if !TARGET_OS_TV
|
||||||
|
if (controller.hardware == ControllerHardwareG8PlusMFi) {
|
||||||
|
// NSLog(@"[G8Rumble] route native rumble low=%hu high=%hu", lowFreqMotor, highFreqMotor);
|
||||||
|
[_gameSirG8MFiRumbler setLowFrequencyMotor:lowFreqMotor highFrequencyMotor:highFreqMotor];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[controller.lowFreqMotor setMotorAmplitude:lowFreqMotor];
|
||||||
|
[controller.highFreqMotor setMotorAmplitude:highFreqMotor];
|
||||||
|
}
|
||||||
|
|
||||||
-(void) rumble:(unsigned short)controllerNumber lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor
|
-(void) rumble:(unsigned short)controllerNumber lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor
|
||||||
{
|
{
|
||||||
VoidController* voidController = [_voidControllers objectForKey:[NSNumber numberWithInteger:controllerNumber]];
|
VoidController* voidController = [_voidControllers objectForKey:[NSNumber numberWithInteger:controllerNumber]];
|
||||||
@@ -174,22 +191,31 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
NSLog(@"[G8Rumble] rumble request controller=%hu low=%hu high=%hu preference=%ld vendor=%@",
|
||||||
|
controllerNumber,
|
||||||
|
lowFreqMotor,
|
||||||
|
highFreqMotor,
|
||||||
|
(long)preference,
|
||||||
|
voidController.gamepad.vendorName);
|
||||||
|
*/
|
||||||
|
|
||||||
// physical controller connected:
|
// physical controller connected:
|
||||||
switch (preference) {
|
switch (preference) {
|
||||||
case HapticEngineAuto:
|
case HapticEngineAuto:
|
||||||
// if controller has no haptic profile, it already falled bakc to device engine
|
// if controller has no haptic profile, it already falled bakc to device engine
|
||||||
[voidController.lowFreqMotor setMotorAmplitude:lowFreqMotor];
|
[self applyPhysicalControllerRumble:voidController lowFreqMotor:lowFreqMotor highFreqMotor:highFreqMotor];
|
||||||
[voidController.highFreqMotor setMotorAmplitude:highFreqMotor];
|
|
||||||
break;
|
break;
|
||||||
case RumbleDevice:
|
case RumbleDevice:
|
||||||
|
[self applyPhysicalControllerRumble:voidController lowFreqMotor:0 highFreqMotor:0];
|
||||||
[_oscController.lowFreqMotor setMotorAmplitude:lowFreqMotor];
|
[_oscController.lowFreqMotor setMotorAmplitude:lowFreqMotor];
|
||||||
[_oscController.highFreqMotor setMotorAmplitude:highFreqMotor];
|
[_oscController.highFreqMotor setMotorAmplitude:highFreqMotor];
|
||||||
break;
|
break;
|
||||||
case LeftRightSwapped:
|
case LeftRightSwapped:
|
||||||
[voidController.lowFreqMotor setMotorAmplitude:highFreqMotor];
|
[self applyPhysicalControllerRumble:voidController lowFreqMotor:highFreqMotor highFreqMotor:lowFreqMotor];
|
||||||
[voidController.highFreqMotor setMotorAmplitude:lowFreqMotor];
|
|
||||||
break;
|
break;
|
||||||
case RumbleOff:
|
case RumbleOff:
|
||||||
|
[self applyPhysicalControllerRumble:voidController lowFreqMotor:0 highFreqMotor:0];
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -305,7 +331,7 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
|
|||||||
deviceAccelSample.x += voidController.motionManager.deviceMotion.gravity.x;
|
deviceAccelSample.x += voidController.motionManager.deviceMotion.gravity.x;
|
||||||
deviceAccelSample.y += voidController.motionManager.deviceMotion.gravity.y;
|
deviceAccelSample.y += voidController.motionManager.deviceMotion.gravity.y;
|
||||||
deviceAccelSample.z += voidController.motionManager.deviceMotion.gravity.z;
|
deviceAccelSample.z += voidController.motionManager.deviceMotion.gravity.z;
|
||||||
NSLog(@"sending device accel %f", CACurrentMediaTime());
|
// NSLog(@"sending device accel %f", CACurrentMediaTime());
|
||||||
|
|
||||||
UIInterfaceOrientation interfaceOrientation = [self currentInterfaceOrientation];
|
UIInterfaceOrientation interfaceOrientation = [self currentInterfaceOrientation];
|
||||||
CMAcceleration mappedDeviceAccelSample = {};
|
CMAcceleration mappedDeviceAccelSample = {};
|
||||||
@@ -460,10 +486,10 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
|
|||||||
block:^(NSTimer *timer) {
|
block:^(NSTimer *timer) {
|
||||||
GCAcceleration lastAccelSample = voidController.lastAccelSample;
|
GCAcceleration lastAccelSample = voidController.lastAccelSample;
|
||||||
GCAcceleration accelSample = voidController.gamepad.motion.acceleration;
|
GCAcceleration accelSample = voidController.gamepad.motion.acceleration;
|
||||||
|
|
||||||
if (memcmp(&accelSample, &lastAccelSample, sizeof(accelSample)) == 0) {
|
if (memcmp(&accelSample, &lastAccelSample, sizeof(accelSample)) == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// NSLog(@"sending controller accel %f", CACurrentMediaTime());
|
||||||
|
|
||||||
voidController.lastAccelSample = accelSample;
|
voidController.lastAccelSample = accelSample;
|
||||||
|
|
||||||
@@ -1319,7 +1345,8 @@ double rc_expo(double x, double expo) {
|
|||||||
|
|
||||||
|
|
||||||
- (bool)useMotionHandler{
|
- (bool)useMotionHandler{
|
||||||
return true;
|
return tempSettings.gyroMode.intValue == GyroModeOff
|
||||||
|
|| tempSettings.gyroMode.intValue == AlwaysDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)switchMotionControlOnOffByControllerButton{
|
- (void)switchMotionControlOnOffByControllerButton{
|
||||||
@@ -1747,6 +1774,11 @@ double rc_expo(double x, double expo) {
|
|||||||
voidController.motionTypes = [[NSMutableSet alloc] init];
|
voidController.motionTypes = [[NSMutableSet alloc] init];
|
||||||
voidController.supportedEmulationFlags = EMULATING_SPECIAL | EMULATING_SELECT;
|
voidController.supportedEmulationFlags = EMULATING_SPECIAL | EMULATING_SELECT;
|
||||||
voidController.gamepad = controller;
|
voidController.gamepad = controller;
|
||||||
|
#if !TARGET_OS_TV
|
||||||
|
voidController.hardware = [_gameSirG8MFiRumbler isTargetController:controller] ? ControllerHardwareG8PlusMFi : ControllerHardwareGeneric;
|
||||||
|
#else
|
||||||
|
voidController.hardware = ControllerHardwareGeneric;
|
||||||
|
#endif
|
||||||
voidController.hasAccelerometer = NO;
|
voidController.hasAccelerometer = NO;
|
||||||
voidController.hasGyroscope = NO;
|
voidController.hasGyroscope = NO;
|
||||||
|
|
||||||
@@ -2005,6 +2037,9 @@ double rc_expo(double x, double expo) {
|
|||||||
_voidControllers = [[NSMutableDictionary alloc] init];
|
_voidControllers = [[NSMutableDictionary alloc] init];
|
||||||
[ControllerUtil.activeStreamingGCControllers removeAllObjects];
|
[ControllerUtil.activeStreamingGCControllers removeAllObjects];
|
||||||
_controllerNumbers = 0;
|
_controllerNumbers = 0;
|
||||||
|
#if !TARGET_OS_TV
|
||||||
|
_gameSirG8MFiRumbler = [[GameSirG8MFiRumbler alloc] init];
|
||||||
|
#endif
|
||||||
|
|
||||||
_captureMouse = (streamConfig.localMousePointerMode == 0);
|
_captureMouse = (streamConfig.localMousePointerMode == 0);
|
||||||
if (@available(iOS 14.0, tvOS 14.0, *)) {
|
if (@available(iOS 14.0, tvOS 14.0, *)) {
|
||||||
@@ -2064,6 +2099,11 @@ double rc_expo(double x, double expo) {
|
|||||||
|
|
||||||
VoidController* voidController = [self->_voidControllers objectForKey:[NSNumber numberWithInteger:controller.playerIndex]];
|
VoidController* voidController = [self->_voidControllers objectForKey:[NSNumber numberWithInteger:controller.playerIndex]];
|
||||||
if (voidController) {
|
if (voidController) {
|
||||||
|
#if !TARGET_OS_TV
|
||||||
|
if ([self->_gameSirG8MFiRumbler isTargetController:controller]) {
|
||||||
|
[self->_gameSirG8MFiRumbler stopAndClose];
|
||||||
|
}
|
||||||
|
#endif
|
||||||
[self stopTimerForController:voidController];
|
[self stopTimerForController:voidController];
|
||||||
|
|
||||||
// Stop haptics on this controller
|
// Stop haptics on this controller
|
||||||
@@ -2267,6 +2307,9 @@ double rc_expo(double x, double expo) {
|
|||||||
-(void) cleanup
|
-(void) cleanup
|
||||||
{
|
{
|
||||||
[ControllerUtil stopAllDualSenseHaptics];
|
[ControllerUtil stopAllDualSenseHaptics];
|
||||||
|
#if !TARGET_OS_TV
|
||||||
|
[_gameSirG8MFiRumbler invalidate];
|
||||||
|
#endif
|
||||||
|
|
||||||
if (VLSharedControllerSupport == self) {
|
if (VLSharedControllerSupport == self) {
|
||||||
VLSharedControllerSupport = nil;
|
VLSharedControllerSupport = nil;
|
||||||
|
|||||||
@@ -267,6 +267,11 @@ import UIKit
|
|||||||
case xboxAndPs = 0x00 // LI_CTYPE_UNKNOWN
|
case xboxAndPs = 0x00 // LI_CTYPE_UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc enum ControllerHardware: UInt8 {
|
||||||
|
case generic
|
||||||
|
case g8PlusMFi
|
||||||
|
}
|
||||||
|
|
||||||
@objc protocol ControllerUtilDelegate: AnyObject {
|
@objc protocol ControllerUtilDelegate: AnyObject {
|
||||||
func isStreaming() -> Bool
|
func isStreaming() -> Bool
|
||||||
func isInAppView() -> Bool
|
func isInAppView() -> Bool
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
//
|
||||||
|
// GameSirG8MFiRumble.m -> GameSirG8MFiRumbler.swift
|
||||||
|
// VoidLink
|
||||||
|
//
|
||||||
|
// Created by chrisnch on 2026/08/22
|
||||||
|
// Copyright © 2026 chrisnch@github. All rights reserved.
|
||||||
|
//
|
||||||
|
// Modified by True砖家 on 2026/08/25
|
||||||
|
// Copyright © 2026 True砖家 on Bilibili. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import ExternalAccessory
|
||||||
|
import Foundation
|
||||||
|
import GameController
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@objcMembers
|
||||||
|
final class GameSirG8MFiRumbler: NSObject, StreamDelegate {
|
||||||
|
private static let gameSirG8MFiProtocol = "com.xiaoji.M2boot"
|
||||||
|
private static let gameSirManufacturer = "GameSir"
|
||||||
|
private static let gameSirG8MFiModel = "G8+ MFi"
|
||||||
|
static var repeatInterval: TimeInterval = 0.167253
|
||||||
|
|
||||||
|
private var session: EASession?
|
||||||
|
private var inFlightPacket: Data?
|
||||||
|
private var inFlightOffset = 0
|
||||||
|
private var queuedPacket: Data?
|
||||||
|
private var lastSentPacket: Data?
|
||||||
|
private var repeatTimer: Timer?
|
||||||
|
private var repeatPacket: Data?
|
||||||
|
private var appActive: Bool
|
||||||
|
private var invalidated = false
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
appActive = UIApplication.shared.applicationState == .active
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(applicationWillResignActive(_:)),
|
||||||
|
name: UIApplication.willResignActiveNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(applicationDidBecomeActive(_:)),
|
||||||
|
name: UIApplication.didBecomeActiveNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
assert(Thread.isMainThread || session == nil, "Active GameSir sessions must be closed on the main thread before deallocation")
|
||||||
|
if Thread.isMainThread {
|
||||||
|
stopAndCloseOnMainThread()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canHandleController(_ controller: GCController) -> Bool {
|
||||||
|
return isTargetController(controller) && connectedAccessory() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTargetController(_ controller: GCController) -> Bool {
|
||||||
|
guard let vendorName = controller.vendorName, !vendorName.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return vendorName.range(of: Self.gameSirManufacturer, options: [.caseInsensitive, .diacriticInsensitive]) != nil &&
|
||||||
|
vendorName.range(of: "G8+", options: [.caseInsensitive, .diacriticInsensitive]) != nil &&
|
||||||
|
vendorName.range(of: "MFi", options: [.caseInsensitive, .diacriticInsensitive]) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc(setLowFrequencyMotor:highFrequencyMotor:)
|
||||||
|
func setLowFrequencyMotor(_ lowFrequencyMotor: UInt16, highFrequencyMotor: UInt16) {
|
||||||
|
objc_sync_enter(self)
|
||||||
|
let isInvalidated = invalidated
|
||||||
|
objc_sync_exit(self)
|
||||||
|
if isInvalidated {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let packet = packet(lowFrequencyMotor: lowFrequencyMotor, highFrequencyMotor: highFrequencyMotor)
|
||||||
|
// NSLog("[G8Rumble] queue request low=%hu high=%hu packet=%@", lowFrequencyMotor, highFrequencyMotor, packet as NSData)
|
||||||
|
let shouldRepeat = lowFrequencyMotor != 0 || highFrequencyMotor != 0
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
objc_sync_enter(self)
|
||||||
|
let isInvalidated = self.invalidated
|
||||||
|
objc_sync_exit(self)
|
||||||
|
if isInvalidated {
|
||||||
|
// NSLog("[G8Rumble] drop packet because rumble handler is invalidated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !self.appActive {
|
||||||
|
// NSLog("[G8Rumble] drop packet because app is inactive packet=%@", packet as NSData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldRepeat {
|
||||||
|
self.stopRepeatTimer(clearPacket: false)
|
||||||
|
self.repeatPacket = packet
|
||||||
|
self.startRepeatTimer()
|
||||||
|
} else {
|
||||||
|
self.stopRepeatTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.queuePacket(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAndClose() {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
stopAndCloseOnMainThread()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.stopAndCloseOnMainThread()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidate() {
|
||||||
|
objc_sync_enter(self)
|
||||||
|
invalidated = true
|
||||||
|
objc_sync_exit(self)
|
||||||
|
stopAndClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func applicationWillResignActive(_ notification: Notification) {
|
||||||
|
appActive = false
|
||||||
|
stopAndCloseOnMainThread()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func applicationDidBecomeActive(_ notification: Notification) {
|
||||||
|
appActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectedAccessory() -> EAAccessory? {
|
||||||
|
var sawAccessory = false
|
||||||
|
for accessory in EAAccessoryManager.shared().connectedAccessories {
|
||||||
|
sawAccessory = true
|
||||||
|
/*
|
||||||
|
NSLog(
|
||||||
|
"[G8Rumble] EA accessory name=%@ manufacturer=%@ model=%@ protocols=%@ connected=%d",
|
||||||
|
accessory.name,
|
||||||
|
accessory.manufacturer,
|
||||||
|
accessory.modelNumber,
|
||||||
|
accessory.protocolStrings,
|
||||||
|
accessory.isConnected
|
||||||
|
) */
|
||||||
|
|
||||||
|
guard accessory.isConnected,
|
||||||
|
accessory.protocolStrings.contains(Self.gameSirG8MFiProtocol),
|
||||||
|
!accessory.manufacturer.isEmpty,
|
||||||
|
!accessory.modelNumber.isEmpty,
|
||||||
|
accessory.manufacturer.caseInsensitiveCompare(Self.gameSirManufacturer) == .orderedSame,
|
||||||
|
accessory.modelNumber.caseInsensitiveCompare(Self.gameSirG8MFiModel) == .orderedSame else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog("[G8Rumble] matched GameSir G8+ MFi accessory name=%@", accessory.name)
|
||||||
|
return accessory
|
||||||
|
}
|
||||||
|
|
||||||
|
if sawAccessory {
|
||||||
|
NSLog("[G8Rumble] no matching GameSir G8+ MFi accessory")
|
||||||
|
} else {
|
||||||
|
NSLog("[G8Rumble] no connected EA accessories")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertMotorAmplitude(_ amplitude: UInt16) -> UInt8 {
|
||||||
|
return UInt8((UInt32(amplitude) * 255 + 32767) / 65535)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func packet(lowFrequencyMotor: UInt16, highFrequencyMotor: UInt16) -> Data {
|
||||||
|
return Data([
|
||||||
|
0x04,
|
||||||
|
Self.convertMotorAmplitude(lowFrequencyMotor),
|
||||||
|
0x01,
|
||||||
|
Self.convertMotorAmplitude(highFrequencyMotor),
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSessionIfNeeded() -> Bool {
|
||||||
|
if session != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let accessory = connectedAccessory() else {
|
||||||
|
NSLog("[G8Rumble] cannot open session: accessory not found")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog("[G8Rumble] opening EASession protocol=%@ accessory=%@", Self.gameSirG8MFiProtocol, accessory.name)
|
||||||
|
guard let newSession = EASession(accessory: accessory, forProtocol: Self.gameSirG8MFiProtocol),
|
||||||
|
let inputStream = newSession.inputStream,
|
||||||
|
let outputStream = newSession.outputStream else {
|
||||||
|
NSLog("[G8Rumble] failed to create EASession")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
session = newSession
|
||||||
|
inputStream.delegate = self
|
||||||
|
outputStream.delegate = self
|
||||||
|
inputStream.schedule(in: .main, forMode: .default)
|
||||||
|
outputStream.schedule(in: .main, forMode: .default)
|
||||||
|
inputStream.open()
|
||||||
|
outputStream.open()
|
||||||
|
/*
|
||||||
|
NSLog(
|
||||||
|
"[G8Rumble] EASession opened inputStatus=%ld outputStatus=%ld",
|
||||||
|
inputStream.streamStatus.rawValue,
|
||||||
|
outputStream.streamStatus.rawValue
|
||||||
|
) */
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRepeatTimer() {
|
||||||
|
let timer = Timer(timeInterval: Self.repeatInterval, repeats: true) { [weak self] _ in
|
||||||
|
self?.repeatCurrentPacket()
|
||||||
|
}
|
||||||
|
repeatTimer = timer
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
// NSLog("[G8Rumble] repeat timer started interval=%.3f", Self.repeatInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopRepeatTimer(clearPacket: Bool = true) {
|
||||||
|
guard let timer = repeatTimer else {
|
||||||
|
if clearPacket {
|
||||||
|
repeatPacket = nil
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.invalidate()
|
||||||
|
repeatTimer = nil
|
||||||
|
if clearPacket {
|
||||||
|
repeatPacket = nil
|
||||||
|
}
|
||||||
|
// NSLog("[G8Rumble] repeat timer stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func repeatCurrentPacket() {
|
||||||
|
objc_sync_enter(self)
|
||||||
|
let isInvalidated = invalidated
|
||||||
|
objc_sync_exit(self)
|
||||||
|
if isInvalidated || !appActive {
|
||||||
|
stopRepeatTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let packet = repeatPacket else {
|
||||||
|
stopRepeatTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queuePacket(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func queuePacket(_ packet: Data) {
|
||||||
|
queuedPacket = packet
|
||||||
|
// NSLog("[G8Rumble] queued packet=%@", packet as NSData)
|
||||||
|
|
||||||
|
if openSessionIfNeeded() {
|
||||||
|
flushOutput()
|
||||||
|
} else {
|
||||||
|
// NSLog("[G8Rumble] packet queued but session is not open packet=%@", packet as NSData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flushOutput() {
|
||||||
|
guard let outputStream = session?.outputStream else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard outputStream.streamStatus == .open, outputStream.hasSpaceAvailable else {
|
||||||
|
/*
|
||||||
|
NSLog(
|
||||||
|
"[G8Rumble] flush skipped status=%ld hasSpace=%d queued=%@ inFlight=%@",
|
||||||
|
outputStream.streamStatus.rawValue,
|
||||||
|
outputStream.hasSpaceAvailable,
|
||||||
|
(queuedPacket as NSData?) ?? NSNull(),
|
||||||
|
(inFlightPacket as NSData?) ?? NSNull()
|
||||||
|
) */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
while outputStream.hasSpaceAvailable {
|
||||||
|
if inFlightPacket == nil {
|
||||||
|
guard let packet = queuedPacket else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inFlightPacket = packet
|
||||||
|
queuedPacket = nil
|
||||||
|
inFlightOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let packet = inFlightPacket else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainingLength = packet.count - inFlightOffset
|
||||||
|
let written = packet.withUnsafeBytes { rawBuffer -> Int in
|
||||||
|
guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return outputStream.write(baseAddress.advanced(by: inFlightOffset), maxLength: remainingLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
if written <= 0 {
|
||||||
|
NSLog(
|
||||||
|
"[G8Rumble] write failed packet=%@ offset=%lu remaining=%lu written=%ld status=%ld error=%@",
|
||||||
|
packet as NSData,
|
||||||
|
UInt(inFlightOffset),
|
||||||
|
UInt(remainingLength),
|
||||||
|
written,
|
||||||
|
outputStream.streamStatus.rawValue,
|
||||||
|
outputStream.streamError as NSError? ?? NSNull()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
NSLog(
|
||||||
|
"[G8Rumble] write packet=%@ offset=%lu remaining=%lu written=%ld status=%ld",
|
||||||
|
packet as NSData,
|
||||||
|
UInt(inFlightOffset),
|
||||||
|
UInt(remainingLength),
|
||||||
|
written,
|
||||||
|
outputStream.streamStatus.rawValue
|
||||||
|
) */
|
||||||
|
|
||||||
|
inFlightOffset += written
|
||||||
|
if inFlightOffset == packet.count {
|
||||||
|
// NSLog("[G8Rumble] packet sent %@", packet as NSData)
|
||||||
|
lastSentPacket = packet
|
||||||
|
inFlightPacket = nil
|
||||||
|
inFlightOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeStopPacketBestEffort() {
|
||||||
|
guard let outputStream = session?.outputStream else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard outputStream.streamStatus == .open, outputStream.hasSpaceAvailable else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedPacket = nil
|
||||||
|
flushOutput()
|
||||||
|
if inFlightPacket != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopPacket = packet(lowFrequencyMotor: 0, highFrequencyMotor: 0)
|
||||||
|
var offset = 0
|
||||||
|
while offset < stopPacket.count && outputStream.hasSpaceAvailable {
|
||||||
|
let written = stopPacket.withUnsafeBytes { rawBuffer -> Int in
|
||||||
|
guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return outputStream.write(baseAddress.advanced(by: offset), maxLength: stopPacket.count - offset)
|
||||||
|
}
|
||||||
|
if written <= 0 {
|
||||||
|
NSLog(
|
||||||
|
"[G8Rumble] stop packet write failed offset=%lu written=%ld status=%ld error=%@",
|
||||||
|
UInt(offset),
|
||||||
|
written,
|
||||||
|
outputStream.streamStatus.rawValue,
|
||||||
|
outputStream.streamError as NSError? ?? NSNull()
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += written
|
||||||
|
}
|
||||||
|
if offset == stopPacket.count {
|
||||||
|
lastSentPacket = stopPacket
|
||||||
|
}
|
||||||
|
NSLog("[G8Rumble] stop packet written=%lu/%lu packet=%@", UInt(offset), UInt(stopPacket.count), stopPacket as NSData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeSession() {
|
||||||
|
assert(Thread.isMainThread, "GameSir sessions must be closed on the main thread")
|
||||||
|
stopRepeatTimer()
|
||||||
|
if let session,
|
||||||
|
let inputStream = session.inputStream,
|
||||||
|
let outputStream = session.outputStream {
|
||||||
|
NSLog(
|
||||||
|
"[G8Rumble] closing EASession inputStatus=%ld outputStatus=%ld",
|
||||||
|
inputStream.streamStatus.rawValue,
|
||||||
|
outputStream.streamStatus.rawValue
|
||||||
|
)
|
||||||
|
inputStream.close()
|
||||||
|
outputStream.close()
|
||||||
|
inputStream.remove(from: .main, forMode: .default)
|
||||||
|
outputStream.remove(from: .main, forMode: .default)
|
||||||
|
inputStream.delegate = nil
|
||||||
|
outputStream.delegate = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
session = nil
|
||||||
|
inFlightPacket = nil
|
||||||
|
inFlightOffset = 0
|
||||||
|
queuedPacket = nil
|
||||||
|
lastSentPacket = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopAndCloseOnMainThread() {
|
||||||
|
assert(Thread.isMainThread, "GameSir sessions must be stopped on the main thread")
|
||||||
|
writeStopPacketBestEffort()
|
||||||
|
closeSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stream(_ stream: Stream, handle eventCode: Stream.Event) {
|
||||||
|
guard let session,
|
||||||
|
let inputStream = session.inputStream,
|
||||||
|
let outputStream = session.outputStream else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard stream === inputStream || stream === outputStream else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch eventCode {
|
||||||
|
case .hasBytesAvailable:
|
||||||
|
var buffer = [UInt8](repeating: 0, count: 64)
|
||||||
|
while inputStream.hasBytesAvailable {
|
||||||
|
if inputStream.read(&buffer, maxLength: buffer.count) <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .openCompleted, .hasSpaceAvailable:
|
||||||
|
if stream === outputStream {
|
||||||
|
flushOutput()
|
||||||
|
}
|
||||||
|
case .errorOccurred, .endEncountered:
|
||||||
|
writeStopPacketBestEffort()
|
||||||
|
closeSession()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ import ObjectiveC.runtime
|
|||||||
func resetMagnifierStreamView(animated:Bool)
|
func resetMagnifierStreamView(animated:Bool)
|
||||||
@objc(restoreMagnifierStreamViewWithOffset:scale:)
|
@objc(restoreMagnifierStreamViewWithOffset:scale:)
|
||||||
func restoreMagnifierStreamView(offset: CGPoint, scale: CGFloat)
|
func restoreMagnifierStreamView(offset: CGPoint, scale: CGFloat)
|
||||||
|
func startStreamViewInteractionTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc enum WidgetTypeEnum: UInt8 {
|
@objc enum WidgetTypeEnum: UInt8 {
|
||||||
@@ -3630,6 +3631,7 @@ import ObjectiveC.runtime
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func vl_autoDock_touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
@objc private func vl_autoDock_touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
self.functionalWidgetDelegate?.startStreamViewInteractionTimer()
|
||||||
guard autoDockEnabled else {
|
guard autoDockEnabled else {
|
||||||
OnScreenWidgetView.autoDockOriginalTouchesBeganIMP?(self, #selector(OnScreenWidgetView.touchesBegan(_:with:)), touches, event)
|
OnScreenWidgetView.autoDockOriginalTouchesBeganIMP?(self, #selector(OnScreenWidgetView.touchesBegan(_:with:)), touches, event)
|
||||||
return
|
return
|
||||||
@@ -3784,6 +3786,8 @@ import ObjectiveC.runtime
|
|||||||
|
|
||||||
autoDockIdleDuration = storedAutoDockIdleDuration
|
autoDockIdleDuration = storedAutoDockIdleDuration
|
||||||
|
|
||||||
|
self.functionalWidgetDelegate?.startStreamViewInteractionTimer()
|
||||||
|
|
||||||
if !folded {
|
if !folded {
|
||||||
restartAutoDockCountdown()
|
restartAutoDockCountdown()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -80,6 +80,8 @@
|
|||||||
- (void)setAllowSingleTouchEnabled:(BOOL)enabled;
|
- (void)setAllowSingleTouchEnabled:(BOOL)enabled;
|
||||||
- (void)toggleTouchDisabled:(bool)disabled;
|
- (void)toggleTouchDisabled:(bool)disabled;
|
||||||
|
|
||||||
|
- (void)startInteractionTimer;
|
||||||
|
|
||||||
#if !TARGET_OS_TV
|
#if !TARGET_OS_TV
|
||||||
- (void) updateCursorLocation:(CGPoint)location isMouse:(BOOL)isMouse;
|
- (void) updateCursorLocation:(CGPoint)location isMouse:(BOOL)isMouse;
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
#import "HapticContext.h"
|
#import "HapticContext.h"
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
@import GameController;
|
@import GameController;
|
||||||
@import CoreHaptics;
|
@import CoreHaptics;
|
||||||
@@ -14,6 +15,8 @@
|
|||||||
@import CoreMotion;
|
@import CoreMotion;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
typedef NS_ENUM(uint8_t, ControllerHardware);
|
||||||
|
|
||||||
@interface VoidController : NSObject
|
@interface VoidController : NSObject
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -69,6 +72,7 @@ typedef NS_ENUM(NSInteger, ControllerDeviceBatteryState) {
|
|||||||
@property(nonatomic, strong) NSMutableSet* _Nullable motionTypes;
|
@property(nonatomic, strong) NSMutableSet* _Nullable motionTypes;
|
||||||
@property(nonatomic, assign) uint16_t reportRateHz;
|
@property(nonatomic, assign) uint16_t reportRateHz;
|
||||||
@property(nonatomic, assign) uint16_t controllerNumber;
|
@property(nonatomic, assign) uint16_t controllerNumber;
|
||||||
|
@property(nonatomic, assign) ControllerHardware hardware;
|
||||||
@property(nonatomic, assign) BOOL hasAccelerometer;
|
@property(nonatomic, assign) BOOL hasAccelerometer;
|
||||||
@property(nonatomic, assign) BOOL hasGyroscope;
|
@property(nonatomic, assign) BOOL hasGyroscope;
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ VoidLink uses microphone to stream your voice to PC host. Microphone redirection
|
|||||||
<false/>
|
<false/>
|
||||||
<key>UIStatusBarHidden</key>
|
<key>UIStatusBarHidden</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UISupportedExternalAccessoryProtocols</key>
|
||||||
|
<array>
|
||||||
|
<string>com.xiaoji.M2boot</string>
|
||||||
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
|||||||
@@ -5978,6 +5978,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Exit Directly" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Exit Directly"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "直接退出"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "直接退出"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-HK" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "直接退出"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Cannot select two touchpad controls at the same time" : {
|
"Cannot select two touchpad controls at the same time" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -16411,6 +16440,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Save Changes" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Save Changes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "保存修改"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "儲存修改"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-HK" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "儲存修改"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Save changes before closing?" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Save changes before closing?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "关闭前是否保存修改?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "關閉前是否儲存修改?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-HK" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "關閉前是否儲存修改?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Saving over the 'Default' profile is not allowed" : {
|
"Saving over the 'Default' profile is not allowed" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -20138,6 +20225,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"emulatedGyroModeDisablesBuiltinMotionControlTip" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Turning this option on will disable VoidLink's built-in motion control. If you need the app to convert gyroscope input directly into mouse or controller-stick signals, remember to turn this option off."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "打开该选项,将导致 VoidLink 内置的体感控制功能失效。如果您需要在 App 内部直接将陀螺仪转换为鼠标或手柄摇杆信号,请记得关闭该选项。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "開啟該選項,將導致 VoidLink 內建的體感控制功能失效。如果您需要在 App 內部直接將陀螺儀轉換為滑鼠或手把搖桿訊號,請記得關閉該選項。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-HK" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "開啟該選項,將導致 VoidLink 內置的體感控制功能失效。如果您需要在 App 內部直接將陀螺儀轉換為滑鼠或手掣搖桿訊號,請記得關閉該選項。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"yuv444StackTip" : {
|
"yuv444StackTip" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -639,6 +639,32 @@ import UIKit
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc public static func isFirstEnablingEmulatedGyroMode() -> Bool {
|
||||||
|
let key = "hasEnabledEmulatedGyroMode20260825"
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let launchedBefore = defaults.bool(forKey: key)
|
||||||
|
if !launchedBefore {
|
||||||
|
defaults.set(true, forKey: key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc public static func handleEmulatedGyroModeTip(in vc: UIViewController?) {
|
||||||
|
if isFirstEnablingEmulatedGyroMode() {
|
||||||
|
AlertControllerUtil.showAlert(
|
||||||
|
in: vc,
|
||||||
|
title: LocalizationHelper.localizedString(forKey: "Tips"),
|
||||||
|
message: "\n\(LocalizationHelper.localizedString(forKey: "emulatedGyroModeDisablesBuiltinMotionControlTip"))",
|
||||||
|
withCancel: false,
|
||||||
|
buttonTitle: LocalizationHelper.localizedString(forKey: "This tip won't be shown again"),
|
||||||
|
countdown: 7
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@objc public static func gamepadOverlayFeatureTipTitle() -> String {
|
@objc public static func gamepadOverlayFeatureTipTitle() -> String {
|
||||||
LocalizationHelper.localizedString(forKey: "Gamepad Overlay")
|
LocalizationHelper.localizedString(forKey: "Gamepad Overlay")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,10 @@ public extension UISegmentedControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func storePreviousSelectedSegmentIndex(_ value: Int) {
|
private func storePreviousSelectedSegmentIndex(_ value: Int) {
|
||||||
|
guard canStorePreviousSelectedSegmentIndex(value) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
objc_setAssociatedObject(
|
objc_setAssociatedObject(
|
||||||
self,
|
self,
|
||||||
&previousSelectedSegmentIndexKey,
|
&previousSelectedSegmentIndexKey,
|
||||||
@@ -200,6 +204,16 @@ public extension UISegmentedControl {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func canStorePreviousSelectedSegmentIndex(_ value: Int) -> Bool {
|
||||||
|
guard value != UISegmentedControl.noSegment else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard value >= 0 && value < numberOfSegments else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isEnabledForSegment(at: value)
|
||||||
|
}
|
||||||
|
|
||||||
private func storeLastKnownSelectedSegmentIndex(_ value: Int) {
|
private func storeLastKnownSelectedSegmentIndex(_ value: Int) {
|
||||||
objc_setAssociatedObject(
|
objc_setAssociatedObject(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+28
@@ -208,6 +208,7 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
|
|||||||
private var latestTouchLocation: CGPoint = .zero
|
private var latestTouchLocation: CGPoint = .zero
|
||||||
private var vibrationGenerator = UIImpactFeedbackGenerator(style: .medium)
|
private var vibrationGenerator = UIImpactFeedbackGenerator(style: .medium)
|
||||||
private var widgetSizeTransition: WidgetSizeTransition = .keepWidgetSize
|
private var widgetSizeTransition: WidgetSizeTransition = .keepWidgetSize
|
||||||
|
private var hasSavedSinceLastWidgetReload = false
|
||||||
|
|
||||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||||
let dataMan = DataManager()
|
let dataMan = DataManager()
|
||||||
@@ -390,6 +391,7 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.loadWidgets(from: oscProfile)
|
self.loadWidgets(from: oscProfile)
|
||||||
|
self.hasSavedSinceLastWidgetReload = false
|
||||||
|
|
||||||
if !self.quickSwitchEnabled {
|
if !self.quickSwitchEnabled {
|
||||||
self.originalTrackPointEnabled = OnScreenWidgetView.trackPointEnabled
|
self.originalTrackPointEnabled = OnScreenWidgetView.trackPointEnabled
|
||||||
@@ -1158,6 +1160,31 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
|
|||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func closeTapped(_ sender: Any?) {
|
@IBAction func closeTapped(_ sender: Any?) {
|
||||||
|
guard profilesManager.getSelectedProfile().name != "Default",
|
||||||
|
!hasSavedSinceLastWidgetReload else {
|
||||||
|
dismissLayoutEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertControllerUtil.cancelButtonString = "Exit Directly".localized
|
||||||
|
AlertControllerUtil.showAlert(
|
||||||
|
in: self,
|
||||||
|
title: "Save Changes".localized,
|
||||||
|
message: "Save changes before closing?".localized,
|
||||||
|
withCancel: true,
|
||||||
|
buttonTitle: "Save".localized,
|
||||||
|
countdown: 0,
|
||||||
|
completion: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if !AlertControllerUtil.actionCancelled {
|
||||||
|
self.saveTapped(nil)
|
||||||
|
}
|
||||||
|
self.dismissLayoutEditor()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissLayoutEditor() {
|
||||||
clearSickInput()
|
clearSickInput()
|
||||||
dismiss(animated: true) {
|
dismiss(animated: true) {
|
||||||
OnScreenWidgetView.trackPointEnabled = self.originalTrackPointEnabled
|
OnScreenWidgetView.trackPointEnabled = self.originalTrackPointEnabled
|
||||||
@@ -1573,6 +1600,7 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
|
|||||||
clearSickInput()
|
clearSickInput()
|
||||||
OSCProfilesManager.setLayoutViewBounds(view.bounds)
|
OSCProfilesManager.setLayoutViewBounds(view.bounds)
|
||||||
let success = profilesManager.updateSelectedProfile(layoutOSC.oscButtonLayerPool)
|
let success = profilesManager.updateSelectedProfile(layoutOSC.oscButtonLayerPool)
|
||||||
|
hasSavedSinceLastWidgetReload = success
|
||||||
guard sender != nil else { return }
|
guard sender != nil else { return }
|
||||||
let message = success
|
let message = success
|
||||||
? LocalizationHelper.localizedString(forKey: "profileSaveTip")
|
? LocalizationHelper.localizedString(forKey: "profileSaveTip")
|
||||||
|
|||||||
@@ -1402,8 +1402,13 @@ static NSMutableSet* hostList;
|
|||||||
// Enable frame pacing mode selector only if not in stream view AND not in performance mode
|
// Enable frame pacing mode selector only if not in stream view AND not in performance mode
|
||||||
BOOL shouldEnableFrameQueueSettings = !_settingsExpandedInStreamView && (self.settingsViewController.renderingBackendSelector.selectedSegmentIndex != RENDER_METAL);
|
BOOL shouldEnableFrameQueueSettings = !_settingsExpandedInStreamView && (self.settingsViewController.renderingBackendSelector.selectedSegmentIndex != RENDER_METAL);
|
||||||
// [self.settingsViewController.framePacingModeSelector setEnabled:shouldEnableFrameQueueSettings];
|
// [self.settingsViewController.framePacingModeSelector setEnabled:shouldEnableFrameQueueSettings];
|
||||||
[self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView forSegmentAtIndex:FramePacingModeOff];
|
|
||||||
[self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView forSegmentAtIndex:FramePacingModeLegacy];
|
if(self.settingsViewController.framePacingModeSelector.selectedSegmentIndex != FramePacingModeOff
|
||||||
|
&& self.settingsViewController.framePacingModeSelector.selectedSegmentIndex != FramePacingModeLegacy) {
|
||||||
|
[self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView forSegmentAtIndex:FramePacingModeOff];
|
||||||
|
[self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView forSegmentAtIndex:FramePacingModeLegacy];
|
||||||
|
}
|
||||||
|
else [self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView];
|
||||||
|
|
||||||
if(FrameInterpolator.deviceSupportsInterpolation){
|
if(FrameInterpolator.deviceSupportsInterpolation){
|
||||||
[self.settingsViewController.framePacingModeSelector setEnabled:_settingsViewController.framerateSelector.selectedSegmentIndex != _settingsViewController.framerateSelector.numberOfSegments-1
|
[self.settingsViewController.framePacingModeSelector setEnabled:_settingsViewController.framerateSelector.selectedSegmentIndex != _settingsViewController.framerateSelector.numberOfSegments-1
|
||||||
@@ -2813,7 +2818,8 @@ static NSMutableSet* hostList;
|
|||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
switch (item) {
|
switch (item) {
|
||||||
case RadialMenuItemSettings:
|
case RadialMenuItemSettings:
|
||||||
[[self revealViewController] revealToggleAnimated:YES];
|
if(self.isStreaming && !self.settingsViewExpanded) [self->streamFrameViewController expandSettingsView];
|
||||||
|
else [[self revealViewController] revealToggleAnimated:YES];
|
||||||
break;
|
break;
|
||||||
case RadialMenuItemAllSettings:
|
case RadialMenuItemAllSettings:
|
||||||
[self.revealViewController allSettingSelected];
|
[self.revealViewController allSettingSelected];
|
||||||
|
|||||||
@@ -2347,6 +2347,8 @@ BOOL isCustomResolution(int resolutionSelected) {
|
|||||||
[self.dualSenseTransientSlider addTarget:self action:@selector(dualSenseTransientSliderMoved:) forControlEvents:UIControlEventValueChanged];
|
[self.dualSenseTransientSlider addTarget:self action:@selector(dualSenseTransientSliderMoved:) forControlEvents:UIControlEventValueChanged];
|
||||||
|
|
||||||
self.gyroModeSelector.selectedSegmentIndex = self->tempSettings.gyroMode.intValue;
|
self.gyroModeSelector.selectedSegmentIndex = self->tempSettings.gyroMode.intValue;
|
||||||
|
[self.gyroModeSelector addTarget:self action:@selector(emulatedGyroModeChanged:) forControlEvents:(UIControlEventValueChanged)]; // Update label display when slider is being moved.
|
||||||
|
|
||||||
[self.gyroSensitivitySlider setValue: (uint16_t)(self->tempSettings.gyroSensitivity.floatValue * 100) animated:NO]; // Load old setting.
|
[self.gyroSensitivitySlider setValue: (uint16_t)(self->tempSettings.gyroSensitivity.floatValue * 100) animated:NO]; // Load old setting.
|
||||||
[self.gyroSensitivitySlider addTarget:self action:@selector(gyroSensitivitySliderMoved:) forControlEvents:(UIControlEventValueChanged)]; // Update label display when slider is being moved.
|
[self.gyroSensitivitySlider addTarget:self action:@selector(gyroSensitivitySliderMoved:) forControlEvents:(UIControlEventValueChanged)]; // Update label display when slider is being moved.
|
||||||
[self.gyroSensitivitySlider sendActionsForControlEvents:UIControlEventValueChanged]; // Load old setting.
|
[self.gyroSensitivitySlider sendActionsForControlEvents:UIControlEventValueChanged]; // Load old setting.
|
||||||
@@ -3458,6 +3460,11 @@ BOOL isCustomResolution(int resolutionSelected) {
|
|||||||
[self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display
|
[self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)emulatedGyroModeChanged:(UISegmentedControl* )sender {
|
||||||
|
if(sender.selectedSegmentIndex == GyroModeOff) return;
|
||||||
|
[GenericUtils handleEmulatedGyroModeTipIn:self];
|
||||||
|
}
|
||||||
|
|
||||||
- (void) localVolumeSliderMoved:(UISlider* )sender {
|
- (void) localVolumeSliderMoved:(UISlider* )sender {
|
||||||
[self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display
|
[self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display
|
||||||
if(_mainFrameViewController.settingsExpandedInStreamView) [Connection setVolume:sender.value/100];
|
if(_mainFrameViewController.settingsExpandedInStreamView) [Connection setVolume:sender.value/100];
|
||||||
|
|||||||
@@ -2044,6 +2044,7 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)gamepadPresenceChanged {
|
- (void)gamepadPresenceChanged {
|
||||||
|
if(PublicUtils.iOS26Available) return;
|
||||||
#if !TARGET_OS_TV
|
#if !TARGET_OS_TV
|
||||||
if (@available(iOS 11.0, *)) {
|
if (@available(iOS 11.0, *)) {
|
||||||
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
|
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
|
||||||
@@ -2079,6 +2080,7 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
|
|||||||
// also discard our edges deferring system gestures unless
|
// also discard our edges deferring system gestures unless
|
||||||
// we willingly give up home bar hiding preference.
|
// we willingly give up home bar hiding preference.
|
||||||
_userIsInteracting = YES;
|
_userIsInteracting = YES;
|
||||||
|
if(PublicUtils.iOS26Available) return;
|
||||||
#if !TARGET_OS_TV
|
#if !TARGET_OS_TV
|
||||||
if (@available(iOS 11.0, *)) {
|
if (@available(iOS 11.0, *)) {
|
||||||
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
|
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
|
||||||
@@ -2089,6 +2091,7 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
|
|||||||
- (void)userInteractionEnded {
|
- (void)userInteractionEnded {
|
||||||
// Enable home bar hiding again if conditions allow
|
// Enable home bar hiding again if conditions allow
|
||||||
_userIsInteracting = NO;
|
_userIsInteracting = NO;
|
||||||
|
if(PublicUtils.iOS26Available) return;
|
||||||
#if !TARGET_OS_TV
|
#if !TARGET_OS_TV
|
||||||
if (@available(iOS 11.0, *)) {
|
if (@available(iOS 11.0, *)) {
|
||||||
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
|
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
|
||||||
@@ -2370,6 +2373,9 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)startStreamViewInteractionTimer {
|
||||||
|
[_streamView startInteractionTimer];
|
||||||
|
}
|
||||||
|
|
||||||
- (void)dealloc {
|
- (void)dealloc {
|
||||||
if (VLSharedStreamFrameViewController == self) {
|
if (VLSharedStreamFrameViewController == self) {
|
||||||
|
|||||||
Reference in new issue
Block a user