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 */; };
|
||||
18729EDE2E8BD68800101194 /* UUIDHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18729EDD2E8BD68800101194 /* UUIDHelper.swift */; };
|
||||
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 */; };
|
||||
187F4B282E04658A00EECC59 /* WidgetPanelStackView.m in Sources */ = {isa = PBXBuildFile; fileRef = 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -471,6 +475,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A76B00052FFCC00100000005 /* ExternalAccessory.framework in Frameworks */,
|
||||
18A0EE272EFB9B5A00BA853A /* SVGKitSwift in Frameworks */,
|
||||
18A0EE252EFB9B5A00BA853A /* SVGKit in Frameworks */,
|
||||
9890CF6B203B7EE1006C4B06 /* libxml2.tbd in Frameworks */,
|
||||
@@ -648,6 +653,7 @@
|
||||
FB290CF019B2C406004C83CF /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A76B00042FFCC00100000004 /* ExternalAccessory.framework */,
|
||||
18780CC62F10D66000B90483 /* StoreKit.framework */,
|
||||
98882A032AF60F5300C5A11C /* libavcodec.a */,
|
||||
98882A052AF60F5300C5A11C /* libavformat.a */,
|
||||
@@ -750,6 +756,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
84351B052C29ACAF00A77989 /* CustomOSC */,
|
||||
A76B00022FFCC00100000002 /* GameSirG8MFiRumbler.swift */,
|
||||
FB89460A19F646E200339C8A /* ControllerSupport.h */,
|
||||
FB89460B19F646E200339C8A /* ControllerSupport.m */,
|
||||
FB89460C19F646E200339C8A /* StreamView.h */,
|
||||
@@ -1243,6 +1250,7 @@
|
||||
FBD3494319FC9C04002D2A60 /* AppAssetManager.m in Sources */,
|
||||
FB6549561A57907E001C8F39 /* DiscoveryWorker.m in Sources */,
|
||||
84351B0E2C29ACCB00A77989 /* LayoutOnScreenControls.m in Sources */,
|
||||
A76B00032FFCC00100000003 /* GameSirG8MFiRumbler.swift in Sources */,
|
||||
FB89462A19F646E200339C8A /* ControllerSupport.m in Sources */,
|
||||
FB9AFD3D1A7E111600872C98 /* AppAssetResponse.m in Sources */,
|
||||
1888C1922E24C33100108730 /* AboutViewController.swift in Sources */,
|
||||
|
||||
@@ -2637,7 +2637,6 @@
|
||||
</state>
|
||||
<connections>
|
||||
<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>
|
||||
</button>
|
||||
<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="video.and.waveform" catalog="system" width="128" height="94"/>
|
||||
<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 name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
<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 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 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>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -315,6 +315,8 @@ final class ControllerNavigator: NSObject {
|
||||
static var settingsReadTipSuppressNextRelease = false
|
||||
|
||||
static var navigationTimer: SafeTimer?
|
||||
private static let navigationInitialRepeatDelay: TimeInterval = 0.23
|
||||
private static let navigationContinuousRepeatInterval: TimeInterval = 0.177
|
||||
private static let controllerDrivenUIKitAnimationWakeToken = ControllerDrivenUIKitAnimationWakeToken()
|
||||
@objc static weak var controllerNavigationHighlightedView: UIView?
|
||||
private static let installAlertControllerNavigationHook: Void = {
|
||||
@@ -520,19 +522,27 @@ final class ControllerNavigator: NSObject {
|
||||
|
||||
*/
|
||||
private static func navigateContinuously(forward: Bool) {
|
||||
navigationTimer?.clean()
|
||||
navigationTimer = SafeTimer(interval: 0.23) {
|
||||
uiNavigationDelegate?.navigateByController(forward: forward)
|
||||
}
|
||||
navigationTimer?.restart()
|
||||
navigationTimer?.clean()
|
||||
uiNavigationDelegate?.navigateByController(forward: forward)
|
||||
navigationTimer = SafeTimer(
|
||||
interval: navigationContinuousRepeatInterval,
|
||||
delay: navigationInitialRepeatDelay
|
||||
) {
|
||||
uiNavigationDelegate?.navigateByController(forward: forward)
|
||||
}
|
||||
navigationTimer?.restart(minimumRunCount: 0)
|
||||
}
|
||||
|
||||
private static func navigateContinuously(downward: Bool) {
|
||||
navigationTimer?.clean()
|
||||
navigationTimer = SafeTimer(interval: 0.23) {
|
||||
uiNavigationDelegate?.navigateByController(downward: downward)
|
||||
}
|
||||
navigationTimer?.restart()
|
||||
navigationTimer?.clean()
|
||||
uiNavigationDelegate?.navigateByController(downward: downward)
|
||||
navigationTimer = SafeTimer(
|
||||
interval: navigationContinuousRepeatInterval,
|
||||
delay: navigationInitialRepeatDelay
|
||||
) {
|
||||
uiNavigationDelegate?.navigateByController(downward: downward)
|
||||
}
|
||||
navigationTimer?.restart(minimumRunCount: 0)
|
||||
}
|
||||
|
||||
@objc static func start() {
|
||||
@@ -768,7 +778,7 @@ final class ControllerNavigator: NSObject {
|
||||
radialMenuButtonPressed = false
|
||||
radialMenuDelayTimer?.clean()
|
||||
stickReleasedInRadialMenu = false
|
||||
if radialMenuView == nil, uiNavigationDelegate is StreamFrameViewController {
|
||||
if radialMenuView == nil, let vc = uiNavigationDelegate as? StreamFrameViewController, vc.hasNoPresentedVC {
|
||||
ControllerSupport.sharedInstance()?.sendNavigationButtonPress()
|
||||
return
|
||||
}
|
||||
@@ -1227,6 +1237,12 @@ extension SettingsViewController: ControllerUINavigationDelegate {
|
||||
highlightControllerNavigationView(target)
|
||||
return
|
||||
}
|
||||
|
||||
if let identifer = identifer,
|
||||
let target = nearestSelectableControllerNavigationTarget(to: identifer, selectableTargets: selectableTargets) {
|
||||
highlightControllerNavigationView(target)
|
||||
return
|
||||
}
|
||||
|
||||
highlightControllerNavigationView(selectableTargets[0])
|
||||
}
|
||||
@@ -1249,6 +1265,14 @@ extension SettingsViewController: ControllerUINavigationDelegate {
|
||||
highlightControllerNavigationView(target)
|
||||
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])
|
||||
}
|
||||
@@ -1269,6 +1293,31 @@ extension SettingsViewController: ControllerUINavigationDelegate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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? {
|
||||
if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem,
|
||||
@@ -1569,6 +1618,44 @@ extension SettingsViewController: ControllerUINavigationDelegate {
|
||||
|
||||
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 {
|
||||
guard let identifier = sectionView.identifier else {
|
||||
@@ -1604,7 +1691,6 @@ extension SettingsViewController: ControllerUINavigationDelegate {
|
||||
if skippingDisabledStacks, hasDisabledArrangedSubview(in: stackView) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [stackView]
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
//
|
||||
|
||||
#import "ControllerSupport.h"
|
||||
#import "OnScreenControls.h"
|
||||
#import "VoidController.h"
|
||||
#import "VoidLink-Swift.h"
|
||||
#import "OnScreenControls.h"
|
||||
|
||||
#import "DataManager.h"
|
||||
#include "Limelight.h"
|
||||
@@ -118,6 +118,9 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
|
||||
TemporarySettings* tempSettings;
|
||||
OSCProfile* oscProfile;
|
||||
OSCProfilesManager* oscProfileMan;
|
||||
#if !TARGET_OS_TV
|
||||
GameSirG8MFiRumbler *_gameSirG8MFiRumbler;
|
||||
#endif
|
||||
|
||||
#define EMULATING_SELECT 0x1
|
||||
#define EMULATING_SPECIAL 0x2
|
||||
@@ -155,6 +158,20 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
|
||||
|
||||
#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
|
||||
{
|
||||
VoidController* voidController = [_voidControllers objectForKey:[NSNumber numberWithInteger:controllerNumber]];
|
||||
@@ -173,23 +190,32 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
|
||||
// No connected controller for this player
|
||||
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:
|
||||
switch (preference) {
|
||||
case HapticEngineAuto:
|
||||
// if controller has no haptic profile, it already falled bakc to device engine
|
||||
[voidController.lowFreqMotor setMotorAmplitude:lowFreqMotor];
|
||||
[voidController.highFreqMotor setMotorAmplitude:highFreqMotor];
|
||||
[self applyPhysicalControllerRumble:voidController lowFreqMotor:lowFreqMotor highFreqMotor:highFreqMotor];
|
||||
break;
|
||||
case RumbleDevice:
|
||||
[self applyPhysicalControllerRumble:voidController lowFreqMotor:0 highFreqMotor:0];
|
||||
[_oscController.lowFreqMotor setMotorAmplitude:lowFreqMotor];
|
||||
[_oscController.highFreqMotor setMotorAmplitude:highFreqMotor];
|
||||
break;
|
||||
case LeftRightSwapped:
|
||||
[voidController.lowFreqMotor setMotorAmplitude:highFreqMotor];
|
||||
[voidController.highFreqMotor setMotorAmplitude:lowFreqMotor];
|
||||
[self applyPhysicalControllerRumble:voidController lowFreqMotor:highFreqMotor highFreqMotor:lowFreqMotor];
|
||||
break;
|
||||
case RumbleOff:
|
||||
[self applyPhysicalControllerRumble:voidController lowFreqMotor:0 highFreqMotor:0];
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -305,7 +331,7 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
|
||||
deviceAccelSample.x += voidController.motionManager.deviceMotion.gravity.x;
|
||||
deviceAccelSample.y += voidController.motionManager.deviceMotion.gravity.y;
|
||||
deviceAccelSample.z += voidController.motionManager.deviceMotion.gravity.z;
|
||||
NSLog(@"sending device accel %f", CACurrentMediaTime());
|
||||
// NSLog(@"sending device accel %f", CACurrentMediaTime());
|
||||
|
||||
UIInterfaceOrientation interfaceOrientation = [self currentInterfaceOrientation];
|
||||
CMAcceleration mappedDeviceAccelSample = {};
|
||||
@@ -460,10 +486,10 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
|
||||
block:^(NSTimer *timer) {
|
||||
GCAcceleration lastAccelSample = voidController.lastAccelSample;
|
||||
GCAcceleration accelSample = voidController.gamepad.motion.acceleration;
|
||||
|
||||
if (memcmp(&accelSample, &lastAccelSample, sizeof(accelSample)) == 0) {
|
||||
return;
|
||||
}
|
||||
// NSLog(@"sending controller accel %f", CACurrentMediaTime());
|
||||
|
||||
voidController.lastAccelSample = accelSample;
|
||||
|
||||
@@ -1319,7 +1345,8 @@ double rc_expo(double x, double expo) {
|
||||
|
||||
|
||||
- (bool)useMotionHandler{
|
||||
return true;
|
||||
return tempSettings.gyroMode.intValue == GyroModeOff
|
||||
|| tempSettings.gyroMode.intValue == AlwaysDevice;
|
||||
}
|
||||
|
||||
- (void)switchMotionControlOnOffByControllerButton{
|
||||
@@ -1747,6 +1774,11 @@ double rc_expo(double x, double expo) {
|
||||
voidController.motionTypes = [[NSMutableSet alloc] init];
|
||||
voidController.supportedEmulationFlags = EMULATING_SPECIAL | EMULATING_SELECT;
|
||||
voidController.gamepad = controller;
|
||||
#if !TARGET_OS_TV
|
||||
voidController.hardware = [_gameSirG8MFiRumbler isTargetController:controller] ? ControllerHardwareG8PlusMFi : ControllerHardwareGeneric;
|
||||
#else
|
||||
voidController.hardware = ControllerHardwareGeneric;
|
||||
#endif
|
||||
voidController.hasAccelerometer = NO;
|
||||
voidController.hasGyroscope = NO;
|
||||
|
||||
@@ -2005,6 +2037,9 @@ double rc_expo(double x, double expo) {
|
||||
_voidControllers = [[NSMutableDictionary alloc] init];
|
||||
[ControllerUtil.activeStreamingGCControllers removeAllObjects];
|
||||
_controllerNumbers = 0;
|
||||
#if !TARGET_OS_TV
|
||||
_gameSirG8MFiRumbler = [[GameSirG8MFiRumbler alloc] init];
|
||||
#endif
|
||||
|
||||
_captureMouse = (streamConfig.localMousePointerMode == 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]];
|
||||
if (voidController) {
|
||||
#if !TARGET_OS_TV
|
||||
if ([self->_gameSirG8MFiRumbler isTargetController:controller]) {
|
||||
[self->_gameSirG8MFiRumbler stopAndClose];
|
||||
}
|
||||
#endif
|
||||
[self stopTimerForController:voidController];
|
||||
|
||||
// Stop haptics on this controller
|
||||
@@ -2267,6 +2307,9 @@ double rc_expo(double x, double expo) {
|
||||
-(void) cleanup
|
||||
{
|
||||
[ControllerUtil stopAllDualSenseHaptics];
|
||||
#if !TARGET_OS_TV
|
||||
[_gameSirG8MFiRumbler invalidate];
|
||||
#endif
|
||||
|
||||
if (VLSharedControllerSupport == self) {
|
||||
VLSharedControllerSupport = nil;
|
||||
|
||||
@@ -267,6 +267,11 @@ import UIKit
|
||||
case xboxAndPs = 0x00 // LI_CTYPE_UNKNOWN
|
||||
}
|
||||
|
||||
@objc enum ControllerHardware: UInt8 {
|
||||
case generic
|
||||
case g8PlusMFi
|
||||
}
|
||||
|
||||
@objc protocol ControllerUtilDelegate: AnyObject {
|
||||
func isStreaming() -> 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)
|
||||
@objc(restoreMagnifierStreamViewWithOffset:scale:)
|
||||
func restoreMagnifierStreamView(offset: CGPoint, scale: CGFloat)
|
||||
func startStreamViewInteractionTimer()
|
||||
}
|
||||
|
||||
@objc enum WidgetTypeEnum: UInt8 {
|
||||
@@ -3630,6 +3631,7 @@ import ObjectiveC.runtime
|
||||
}
|
||||
|
||||
@objc private func vl_autoDock_touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
self.functionalWidgetDelegate?.startStreamViewInteractionTimer()
|
||||
guard autoDockEnabled else {
|
||||
OnScreenWidgetView.autoDockOriginalTouchesBeganIMP?(self, #selector(OnScreenWidgetView.touchesBegan(_:with:)), touches, event)
|
||||
return
|
||||
@@ -3784,6 +3786,8 @@ import ObjectiveC.runtime
|
||||
|
||||
autoDockIdleDuration = storedAutoDockIdleDuration
|
||||
|
||||
self.functionalWidgetDelegate?.startStreamViewInteractionTimer()
|
||||
|
||||
if !folded {
|
||||
restartAutoDockCountdown()
|
||||
return
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
- (void)setAllowSingleTouchEnabled:(BOOL)enabled;
|
||||
- (void)toggleTouchDisabled:(bool)disabled;
|
||||
|
||||
- (void)startInteractionTimer;
|
||||
|
||||
#if !TARGET_OS_TV
|
||||
- (void) updateCursorLocation:(CGPoint)location isMouse:(BOOL)isMouse;
|
||||
#endif
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
#import "HapticContext.h"
|
||||
#include <stdint.h>
|
||||
|
||||
@import GameController;
|
||||
@import CoreHaptics;
|
||||
@@ -14,6 +15,8 @@
|
||||
@import CoreMotion;
|
||||
#endif
|
||||
|
||||
typedef NS_ENUM(uint8_t, ControllerHardware);
|
||||
|
||||
@interface VoidController : NSObject
|
||||
|
||||
typedef struct {
|
||||
@@ -69,6 +72,7 @@ typedef NS_ENUM(NSInteger, ControllerDeviceBatteryState) {
|
||||
@property(nonatomic, strong) NSMutableSet* _Nullable motionTypes;
|
||||
@property(nonatomic, assign) uint16_t reportRateHz;
|
||||
@property(nonatomic, assign) uint16_t controllerNumber;
|
||||
@property(nonatomic, assign) ControllerHardware hardware;
|
||||
@property(nonatomic, assign) BOOL hasAccelerometer;
|
||||
@property(nonatomic, assign) BOOL hasGyroscope;
|
||||
|
||||
|
||||
@@ -116,6 +116,10 @@ VoidLink uses microphone to stream your voice to PC host. Microphone redirection
|
||||
<false/>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<true/>
|
||||
<key>UISupportedExternalAccessoryProtocols</key>
|
||||
<array>
|
||||
<string>com.xiaoji.M2boot</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<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" : {
|
||||
"extractionState" : "manual",
|
||||
"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" : {
|
||||
"extractionState" : "manual",
|
||||
"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" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -20172,4 +20288,4 @@
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
}
|
||||
}
|
||||
@@ -638,6 +638,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 {
|
||||
LocalizationHelper.localizedString(forKey: "Gamepad Overlay")
|
||||
|
||||
@@ -192,6 +192,10 @@ public extension UISegmentedControl {
|
||||
}
|
||||
|
||||
private func storePreviousSelectedSegmentIndex(_ value: Int) {
|
||||
guard canStorePreviousSelectedSegmentIndex(value) else {
|
||||
return
|
||||
}
|
||||
|
||||
objc_setAssociatedObject(
|
||||
self,
|
||||
&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) {
|
||||
objc_setAssociatedObject(
|
||||
self,
|
||||
|
||||
+28
@@ -208,6 +208,7 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
|
||||
private var latestTouchLocation: CGPoint = .zero
|
||||
private var vibrationGenerator = UIImpactFeedbackGenerator(style: .medium)
|
||||
private var widgetSizeTransition: WidgetSizeTransition = .keepWidgetSize
|
||||
private var hasSavedSinceLastWidgetReload = false
|
||||
|
||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
let dataMan = DataManager()
|
||||
@@ -390,6 +391,7 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
|
||||
}
|
||||
|
||||
self.loadWidgets(from: oscProfile)
|
||||
self.hasSavedSinceLastWidgetReload = false
|
||||
|
||||
if !self.quickSwitchEnabled {
|
||||
self.originalTrackPointEnabled = OnScreenWidgetView.trackPointEnabled
|
||||
@@ -1158,6 +1160,31 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
|
||||
}
|
||||
|
||||
@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()
|
||||
dismiss(animated: true) {
|
||||
OnScreenWidgetView.trackPointEnabled = self.originalTrackPointEnabled
|
||||
@@ -1573,6 +1600,7 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
|
||||
clearSickInput()
|
||||
OSCProfilesManager.setLayoutViewBounds(view.bounds)
|
||||
let success = profilesManager.updateSelectedProfile(layoutOSC.oscButtonLayerPool)
|
||||
hasSavedSinceLastWidgetReload = success
|
||||
guard sender != nil else { return }
|
||||
let message = success
|
||||
? 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
|
||||
BOOL shouldEnableFrameQueueSettings = !_settingsExpandedInStreamView && (self.settingsViewController.renderingBackendSelector.selectedSegmentIndex != RENDER_METAL);
|
||||
// [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){
|
||||
[self.settingsViewController.framePacingModeSelector setEnabled:_settingsViewController.framerateSelector.selectedSegmentIndex != _settingsViewController.framerateSelector.numberOfSegments-1
|
||||
@@ -2813,7 +2818,8 @@ static NSMutableSet* hostList;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
switch (item) {
|
||||
case RadialMenuItemSettings:
|
||||
[[self revealViewController] revealToggleAnimated:YES];
|
||||
if(self.isStreaming && !self.settingsViewExpanded) [self->streamFrameViewController expandSettingsView];
|
||||
else [[self revealViewController] revealToggleAnimated:YES];
|
||||
break;
|
||||
case RadialMenuItemAllSettings:
|
||||
[self.revealViewController allSettingSelected];
|
||||
|
||||
@@ -2347,6 +2347,8 @@ BOOL isCustomResolution(int resolutionSelected) {
|
||||
[self.dualSenseTransientSlider addTarget:self action:@selector(dualSenseTransientSliderMoved:) forControlEvents:UIControlEventValueChanged];
|
||||
|
||||
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 addTarget:self action:@selector(gyroSensitivitySliderMoved:) forControlEvents:(UIControlEventValueChanged)]; // Update label display when slider is being moved.
|
||||
[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
|
||||
}
|
||||
|
||||
- (void)emulatedGyroModeChanged:(UISegmentedControl* )sender {
|
||||
if(sender.selectedSegmentIndex == GyroModeOff) return;
|
||||
[GenericUtils handleEmulatedGyroModeTipIn:self];
|
||||
}
|
||||
|
||||
- (void) localVolumeSliderMoved:(UISlider* )sender {
|
||||
[self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display
|
||||
if(_mainFrameViewController.settingsExpandedInStreamView) [Connection setVolume:sender.value/100];
|
||||
|
||||
@@ -2044,6 +2044,7 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
|
||||
}
|
||||
|
||||
- (void)gamepadPresenceChanged {
|
||||
if(PublicUtils.iOS26Available) return;
|
||||
#if !TARGET_OS_TV
|
||||
if (@available(iOS 11.0, *)) {
|
||||
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
|
||||
@@ -2079,6 +2080,7 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
|
||||
// also discard our edges deferring system gestures unless
|
||||
// we willingly give up home bar hiding preference.
|
||||
_userIsInteracting = YES;
|
||||
if(PublicUtils.iOS26Available) return;
|
||||
#if !TARGET_OS_TV
|
||||
if (@available(iOS 11.0, *)) {
|
||||
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
|
||||
@@ -2089,6 +2091,7 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
|
||||
- (void)userInteractionEnded {
|
||||
// Enable home bar hiding again if conditions allow
|
||||
_userIsInteracting = NO;
|
||||
if(PublicUtils.iOS26Available) return;
|
||||
#if !TARGET_OS_TV
|
||||
if (@available(iOS 11.0, *)) {
|
||||
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
|
||||
@@ -2370,6 +2373,9 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
|
||||
});
|
||||
}
|
||||
|
||||
- (void)startStreamViewInteractionTimer {
|
||||
[_streamView startInteractionTimer];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (VLSharedStreamFrameViewController == self) {
|
||||
|
||||
Reference in new issue
Block a user