13 Commits
Author SHA1 Message Date
hermes-agent 6e340b6d6a Read app version from built .ipa instead of hardcoding
Build VoidLink IPA / Build .ipa and publish source (push) Successful in 4m3s
The built app reports CFBundleShortVersionString 0.0.1 (MARKETING_VERSION),
but metadata hardcoded 1.0, so SideStore rejected the version mismatch.
Now read the version from the built app's Info.plist so it always matches.
2026-08-28 22:58:03 +00:00
hermes-agent ec12492ea7 Build Release config so .ipa bundle id matches source metadata
Build VoidLink IPA / Build .ipa and publish source (push) Successful in 4m1s
Debug config produces com.voidlink.tf.debug10.iOS, which mismatches the
com.voidlink.tf.iOS declared in VoidLink.json, so SideStore rejects the
install. Release produces the matching com.voidlink.tf.iOS.
2026-08-28 17:09:19 +00:00
hermes-agent 13ccbb3866 Add auto-deploy CI: build unsigned .ipa and publish to SideStore source
Build VoidLink IPA / Build .ipa and publish source (push) Successful in 2m58s
Builds VoidLink via xcodebuild (no signing) on the macOS runner, packages
the .app into an .ipa, and uploads .ipa + metadata + icon to perlis so it
can be installed from the SideStore source at apps.bueso.eu.
2026-08-28 11:06:46 +00:00
True Zhuanjia 20d6e221a4 minor fixes 2026-08-26 00:25:56 +08:00
True Zhuanjia 2bfddf255a widget interaction fixes 2026-08-25 23:20:24 +08:00
True Zhuanjia 0e8903d233 add tip for saving changes before exiting widget layout editor 2026-08-25 18:20:48 +08:00
True Zhuanjia 78f385d732 settingsVC ui fix 2026-08-25 16:10:19 +08:00
True Zhuanjia bd306ba07f gameSirG8MFi rumbler ready 2026-08-25 00:30:31 +08:00
TrueZhuanJia ececc03d46 Merge pull request #214 from chrisnch
Priority: rumble support
2026-08-24 22:25:06 +08:00
chrisnch 6202a53153 Fix GameSir rumble session teardown threading 2026-08-22 08:13:56 +01:00
chrisnch 84b6b22f75 Add native rumble support for GameSir G8+ MFi 2026-08-22 07:33:00 +01:00
True Zhuanjia f8388658d8 controller navigation fixes 2026-08-22 01:08:44 +08:00
True Zhuanjia 3c36964b5c controller Navigation improvements 2026-08-21 22:08:41 +08:00
19 changed files with 934 additions and 29 deletions

No files matched your search

+91
View File
@@ -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/"
+8
View File
@@ -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 */,
+4 -5
View File
@@ -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>
+98 -12
View File
@@ -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]
}
+51 -8
View File
@@ -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;
+5
View File
@@ -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
+456
View File
@@ -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
}
}
}
+4
View File
@@ -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
+2
View File
@@ -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
+4
View File
@@ -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;
+4
View File
@@ -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>
+117 -1
View File
@@ -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"
}
}
+26
View File
@@ -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")
+14
View File
@@ -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,
@@ -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) {
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB