diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..fb0e827
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,48 @@
+---
+name: Bug report
+about: Follow the troubleshooting guide before reporting a bug
+
+---
+**READ ME FIRST!**
+If you're here because something basic is not working (like gamepad input, video, or similar), it's probably something specific to your setup, so make sure you've gone through the Troubleshooting Guide first: https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting
+
+If you still have trouble with basic functionality after following the guide, join our Discord server where there are many other volunteers who can help (or direct you back here if it looks like a Moonlight bug after all). https://moonlight-stream.org/discord
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**Steps to reproduce**
+Any special steps that are required for the bug to appear.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem. If the issue is related to video glitching or poor quality, please include screenshots.
+
+**Affected games**
+List the games you've tried that exhibit the issue. To see if the issue is game-specific, try streaming Steam Big Picture with Moonlight and see if the issue persists there.
+
+**Other Moonlight clients**
+- Does the issue occur when using Moonlight on PC or Android?
+
+**Moonlight settings (please complete the following information)**
+- Have any settings been adjusted from defaults?
+- If so, which settings have been changed?
+- Does the problem still occur after reverting settings back to default?
+
+**Gamepad-related issues (please complete if problem is gamepad-related)**
+- Do you have any gamepads connected to your host PC directly?
+- If gamepad input is not working, does it work if you use Moonlight's on-screen controls?
+- Does the problem still remain if you stream the desktop and use https://html5gamepad.com to test your gamepad?
+ - Instructions for streaming the desktop can be found here: https://github.com/moonlight-stream/moonlight-docs/wiki/Setup-Guide
+
+**Device details (please complete the following information)**
+ - iOS/tvOS version: [e.g. iOS 14.2]
+ - Device model: [e.g. iPhone 11 Pro]
+
+**Server PC details (please complete the following information)**
+ - OS: [e.g. Windows 10 1809]
+ - GeForce Experience version: [e.g. 3.16.0.140]
+ - Nvidia GPU driver: [e.g. 417.35]
+ - Antivirus and firewall software: [e.g. Windows Defender and Windows Firewall]
+
+**Additional context**
+Anything else you think may be relevant to the issue or special about your specific setup.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..3ba13e0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: false
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..066b2d9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,17 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/auto-comment.yml b/.github/auto-comment.yml
new file mode 100644
index 0000000..e6985d2
--- /dev/null
+++ b/.github/auto-comment.yml
@@ -0,0 +1,4 @@
+issuesOpened: >
+ If this is a question about Moonlight or you need help troubleshooting a streaming problem, please use the help channels on our [Discord server](https://moonlight-stream.org/discord) instead of GitHub issues. There are many more people available on Discord to help you and answer your questions.
+ This issue tracker should only be used for specific bugs or feature requests.
+ Thank you, and happy streaming!
diff --git a/.github/lock.yml b/.github/lock.yml
new file mode 100644
index 0000000..1ba1162
--- /dev/null
+++ b/.github/lock.yml
@@ -0,0 +1,25 @@
+# Configuration for Lock Threads - https://github.com/dessant/lock-threads-app
+
+# Number of days of inactivity before a closed issue or pull request is locked
+daysUntilLock: 60
+
+# Skip issues and pull requests created before a given timestamp. Timestamp must
+# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
+skipCreatedBefore: false
+
+# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
+exemptLabels:
+ - enhancement
+
+# Label to add before locking, such as `outdated`. Set to `false` to disable
+lockLabel: false
+
+# Comment to post before locking. Set to `false` to disable
+lockComment: >
+ This issue has been automatically locked since there has not been
+ any recent activity after it was closed. If you experience an issue
+ that looks similar to this one, please open a new issue with details
+ about your specific streaming setup.
+
+# Don't lock PRs
+only: issues
\ No newline at end of file
diff --git a/.github/no-response.yml b/.github/no-response.yml
new file mode 100644
index 0000000..156bc9e
--- /dev/null
+++ b/.github/no-response.yml
@@ -0,0 +1,8 @@
+# ProBot No Response (https://probot.github.io/apps/no-response/)
+
+daysUntilClose: 7
+responseRequiredLabel: 'need more info'
+closeComment: >
+ This issue has been automatically closed because there was no response to a
+ request for more information from the issue opener. Please leave a comment or
+ open a new issue if you have additional information related to this issue.
diff --git a/.github/stale.yml b/.github/stale.yml
new file mode 100644
index 0000000..df9115c
--- /dev/null
+++ b/.github/stale.yml
@@ -0,0 +1,14 @@
+# ProBot Stale (https://probot.github.io/apps/stale/)
+
+daysUntilStale: 90
+daysUntilClose: 7
+exemptLabels:
+ - accepted
+ - bug
+ - enhancement
+ - meta
+staleLabel: stale
+markComment: >
+ This issue has been automatically marked as stale because it has not had
+ recent activity. It will be closed if no further activity occurs.
+closeComment: false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4c75fde
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,45 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+ branches:
+ - master
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build (${{ matrix.platform }})
+ runs-on: macos-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - scheme: Moonlight
+ sdk: iphonesimulator
+ platform: iOS
+ - scheme: Moonlight TV
+ sdk: appletvsimulator
+ platform: tvOS
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ submodules: recursive
+
+ - name: Download ${{ matrix.platform }} platform
+ run: xcodebuild -downloadPlatform ${{ matrix.platform }}
+
+ - name: Build Debug
+ run: xcodebuild -project Moonlight.xcodeproj -scheme "${{ matrix.scheme }}" -configuration Debug -sdk ${{ matrix.sdk }} clean build
+
+ - name: Build Release
+ run: xcodebuild -project Moonlight.xcodeproj -scheme "${{ matrix.scheme }}" -configuration Release -sdk ${{ matrix.sdk }} clean build
diff --git a/.gitignore b/.gitignore
index 3c53649..861c417 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
**/xcuserdata/
**/Moonlight.xcscmblueprint
+**/xcschemes/*.xcscheme
+.DS_Store
Build
DerivedData
diff --git a/.gitmodules b/.gitmodules
index 11cd918..ca517c2 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,6 @@
[submodule "moonlight-common/moonlight-common-c"]
path = moonlight-common/moonlight-common-c
url = https://github.com/moonlight-stream/moonlight-common-c.git
+[submodule "X1Kit"]
+ path = X1Kit
+ url = https://github.com/cgutman/X1Kit.git
diff --git a/BuildScripts/Opus/build.sh b/BuildScripts/Opus/build.sh
deleted file mode 100755
index 283dba8..0000000
--- a/BuildScripts/Opus/build.sh
+++ /dev/null
@@ -1,144 +0,0 @@
-#!/bin/bash
-
-set -e
-
-source config.sh
-
-# Number of CPUs (for make -j)
-NCPU=`sysctl -n hw.ncpu`
-if test x$NJOB = x; then
- NJOB=$NCPU
-fi
-
-PLATFORMBASE=$(xcode-select -print-path)"/Platforms"
-
-SCRIPT_DIR=$( (cd -P $(dirname $0) && pwd) )
-DIST_DIR_BASE=${DIST_DIR_BASE:="$SCRIPT_DIR/dist"}
-
-if [ ! -d opus ]
-then
- echo "opus source directory does not exist, run sync.sh"
-fi
-
-# PATH=${SCRIPT_DIR}/gas-preprocessor/:$PATH
-
-for ARCH in $ARCHS
-do
- OPUS_DIR=opus-$ARCH
- if [ ! -d $OPUS_DIR ]
- then
- echo "Directory $OPUS_DIR does not exist, run sync.sh"
- exit 1
- fi
- echo "Compiling source for $ARCH in directory $OPUS_DIR"
- echo "cd $OPUS_DIR"
- cd $OPUS_DIR
-
- DIST_DIR=$DIST_DIR_BASE-$ARCH
- echo "mkdir -p $DIST_DIR"
- mkdir -p $DIST_DIR
- CFLAGS_ARCH=$ARCH
- case $ARCH in
- armv7)
- EXTRA_FLAGS="--with-pic --enable-fixed-point"
- EXTRA_CFLAGS="-mcpu=cortex-a8 -mfpu=neon -miphoneos-version-min=7.1"
- PLATFORM="${PLATFORMBASE}/iPhoneOS.platform"
- IOSSDK=iPhoneOS
- ;;
- armv7s)
- EXTRA_FLAGS="--with-pic --enable-fixed-point"
- EXTRA_CFLAGS="-mcpu=cortex-a9 -mfpu=neon -miphoneos-version-min=7.1"
- PLATFORM="${PLATFORMBASE}/iPhoneOS.platform"
- IOSSDK=iPhoneOS
- ;;
- aarch64)
- CFLAGS_ARCH="arm64"
- EXTRA_FLAGS="--with-pic --enable-fixed-point"
- EXTRA_CFLAGS="-miphoneos-version-min=7.1"
- PLATFORM="${PLATFORMBASE}/iPhoneOS.platform"
- IOSSDK=iPhoneOS
- ;;
- x86_64)
- EXTRA_FLAGS="--with-pic"
- EXTRA_CFLAGS="-miphoneos-version-min=7.1"
- PLATFORM="${PLATFORMBASE}/iPhoneSimulator.platform"
- IOSSDK=iPhoneSimulator
- ;;
- i386)
- EXTRA_FLAGS="--with-pic"
- EXTRA_CFLAGS="-miphoneos-version-min=7.1"
- PLATFORM="${PLATFORMBASE}/iPhoneSimulator.platform"
- IOSSDK=iPhoneSimulator
- ;;
- *)
- echo "Unsupported architecture ${ARCH}"
- exit 1
- ;;
- esac
-
- echo "Configuring opus for $ARCH..."
- echo "./autogen.sh"
- ./autogen.sh
-
- CFLAGS="-g -O2 -pipe -arch ${CFLAGS_ARCH} \
- -isysroot ${PLATFORM}/Developer/SDKs/${IOSSDK}.sdk \
- -I${PLATFORM}/Developer/SDKs/${IOSSDK}.sdk/usr/include \
- ${EXTRA_CFLAGS}"
- LDFLAGS="-arch ${CFLAGS_ARCH} \
- -isysroot ${PLATFORM}/Developer/SDKs/${IOSSDK}.sdk \
- -L${PLATFORM}/Developer/SDKs/${IOSSDK}.sdk/usr/lib"
-
- echo "CFLAGS=$CFLAGS"
- echo "LDFLAGS=$LDFLAGS"
-
- export CFLAGS
- export LDFLAGS
-
- export CXXCPP="/usr/bin/cpp"
- export CPP="$CXXCPP"
- export CXX="/usr/bin/gcc"
- export CC="/usr/bin/gcc"
- export LD="/usr/bin/ld"
- export AR="/usr/bin/ar"
- export AS="/usr/bin/ls"
- export NM="/usr/bin/nm"
- export RANLIB="/usr/bin/ranlib"
- export STRIP="/usr/bin/strip"
- echo "./configure \
- --prefix=$DIST_DIR \
- --host=${ARCH}-apple-darwin \
- --with-sysroot=${PLATFORM}/Developer/SDKs/${IOSSDK}.sdk \
- --enable-static=yes \
- --enable-shared=no \
- --disable-doc \
- ${EXTRA_FLAGS}"
-
- ./configure \
- --prefix=$DIST_DIR \
- --host=${ARCH}-apple-darwin \
- --with-sysroot=${PLATFORM}/Developer/SDKs/${IOSSDK}.sdk \
- --enable-static=yes \
- --enable-shared=no \
- --disable-doc \
- ${EXTRA_FLAGS}
-
- echo "Installing opus for $ARCH..."
- echo "make clean"
- make clean
- echo "make -j$NJOB V=1"
- make -j$NJOB V=1
- echo "make install"
- make install
-
- echo "cd $SCRIPT_DIR"
- cd $SCRIPT_DIR
-
- if [ -d $DIST_DIR/bin ]
- then
- rm -rf $DIST_DIR/bin
- fi
- if [ -d $DIST_DIR/share ]
- then
- rm -rf $DIST_DIR/share
- fi
-done
diff --git a/BuildScripts/Opus/combine.sh b/BuildScripts/Opus/combine.sh
deleted file mode 100755
index 33c0c13..0000000
--- a/BuildScripts/Opus/combine.sh
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/bin/bash
-
-set -e
-
-source config.sh
-
-for ARCH in $ARCHS
-do
-
- if [ -d dist-$ARCH ]
- then
- MAIN_ARCH=$ARCH
- fi
-done
-
-if [ -z "$MAIN_ARCH" ]
-then
- echo "Please compile an architecture"
- exit 1
-fi
-
-
-OUTPUT_DIR="dist-uarch"
-rm -rf $OUTPUT_DIR
-
-mkdir -p $OUTPUT_DIR/lib $OUTPUT_DIR/include
-
-for LIB in dist-$MAIN_ARCH/lib/*.a
-do
- LIB=`basename $LIB`
- LIPO_CREATE=""
- for ARCH in $ARCHS
- do
- LIPO_ARCH=$ARCH
- if [ "$ARCH" = "aarch64" ];
- then
- LIPO_ARCH="arm64"
- fi
- if [ -d dist-$ARCH ]
- then
- LIPO_CREATE="$LIPO_CREATE-arch $LIPO_ARCH dist-$ARCH/lib/$LIB "
- fi
- done
- OUTPUT="$OUTPUT_DIR/lib/$LIB"
- echo "Creating: $OUTPUT"
- xcrun -sdk iphoneos lipo -create $LIPO_CREATE -output $OUTPUT
- xcrun -sdk iphoneos lipo -info $OUTPUT
-done
-
-echo "Copying headers from dist-$MAIN_ARCH..."
-cp -R dist-$MAIN_ARCH/include/* $OUTPUT_DIR/include
diff --git a/BuildScripts/Opus/config.sh b/BuildScripts/Opus/config.sh
deleted file mode 100755
index 63a9f44..0000000
--- a/BuildScripts/Opus/config.sh
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/bin/bash
-
-OPUS_DIR="opus"
-IOSSDK_VER=8.1
-ARCHS="armv7 armv7s aarch64 i386 x86_64"
-
-remove_arch() {
- OLD_ARCHS="$ARCHS"
- NEW_ARCHS=""
- REMOVAL="$1"
-
- for ARCH in $OLD_ARCHS; do
- if [ "$ARCH" != "$REMOVAL" ] ; then
- NEW_ARCHS="$NEW_ARCHS $ARCH"
- fi
- done
-
- ARCHS=$NEW_ARCHS
-}
-
-# armv7s is only supported in iOS 6.0+
-CHECK=`echo $IOSSDK_VER '>= 6.0' | bc -l`
-if [ "$CHECK" = "0" ] ; then
- remove_arch "armv7s"
-fi
-
-# armv6 is not supported any more since iOS 6.0
-CHECK=`echo $IOSSDK_VER '< 6.0' | bc -l`
-if [ "$CHECK" = "0" ] ; then
- remove_arch "armv6"
-fi
-
-echo 'Architectures to build:' $ARCHS
-
-
-
diff --git a/BuildScripts/Opus/sync.sh b/BuildScripts/Opus/sync.sh
deleted file mode 100755
index 31f5239..0000000
--- a/BuildScripts/Opus/sync.sh
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/bin/sh
-
-set -e
-
-source config.sh
-
-SCRIPT_DIR=$( (cd -P $(dirname $0) && pwd) )
-DIST_DIR_BASE=${DIST_DIR_BASE:="$SCRIPT_DIR/dist"}
-
-cd opus
-git checkout master
-git pull origin master
-cd ..
-
-for ARCH in $ARCHS
-do
- OPUS_DIR=opus-$ARCH
- echo "Syncing source for $ARCH to directory $OPUS_DIR"
- rsync opus/ $OPUS_DIR/ --exclude '.*' -a --delete
-done
diff --git a/BuildScripts/build-ffmpeg.sh b/BuildScripts/build-ffmpeg.sh
deleted file mode 100755
index 7edfab9..0000000
--- a/BuildScripts/build-ffmpeg.sh
+++ /dev/null
@@ -1,104 +0,0 @@
-#!/bin/bash
-
-SYSROOT_iPHONE="/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk"
-SYSROOT_SIMULATOR="/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk"
-CC_IPHONE="xcrun -sdk iphoneos clang"
-CC_SIMULATOR="xcrun -sdk iphonesimulator clang"
-function build_one {
- ./configure \
- --prefix=$PREFIX \
- --disable-ffmpeg \
- --disable-ffplay \
- --disable-ffprobe \
- --disable-ffserver \
- --disable-armv5te \
- --disable-armv6 \
- --disable-doc \
- --disable-everything \
- --disable-debug \
- --enable-decoder=h264 \
- --enable-avresample \
- --enable-cross-compile \
- --sysroot=$SYSROOT \
- --target-os=darwin \
- --cc="$CC" \
- --extra-cflags="$CFLAGS" \
- --extra-ldflags="$LDFLAGS" \
- --enable-pic \
- $ADDI_FLAGS
- make clean && make -j4 && make install
-}
-
-
-# armv7
-function build_armv7 {
- PREFIX="armv7"
- SYSROOT=$SYSROOT_iPHONE
- CC=$CC_IPHONE
- CFLAGS="-arch armv7 -mfpu=neon -miphoneos-version-min=7.1 -fpic"
- LDFLAGS="-arch armv7 -isysroot $SYSROOT_iPHONE -miphoneos-version-min=7.1"
- ADDI_FLAGS="--arch=arm --cpu=cortex-a9"
- build_one
-}
-
-# armv7s
-function build_armv7s {
- PREFIX="armv7s"
- SYSROOT=$SYSROOT_iPHONE
- CC=$CC_IPHONE
- CFLAGS="-arch armv7s -mfpu=neon -miphoneos-version-min=7.1"
- LDFLAGS="-arch armv7s -isysroot $SYSROOT_iPHONE -miphoneos-version-min=7.1"
- ADDI_FLAGS="--arch=arm --cpu=cortex-a9"
- build_one
-}
-
-# i386
-function build_i386 {
- PREFIX="i386"
- SYSROOT=$SYSROOT_SIMULATOR
- CC=$CC_SIMULATOR
- CFLAGS="-arch i386"
- LDFLAGS="-arch i386 -isysroot $SYSROOT_SIMULATOR -mios-simulator-version-min=7.1"
- ADDI_FLAGS="--arch=i386 --cpu=i386 --disable-asm"
- build_one
-}
-
-
-# create fat library
-function build_universal {
- cd armv7/lib
- for file in *.a
- do
- cd ../..
- xcrun -sdk iphoneos lipo -output universal/lib/$file -create \
- -arch armv7 armv7/lib/$file \
- -arch armv7s armv7s/lib/$file \
- -arch i386 i386/lib/$file
- echo "Universal $file created."
- cd -
- done
- cd ../..
-}
-
-echo "FFmpeg directory?"
-read SOURCE
-
-cd $SOURCE
-
-if [ "$1" = "clean" ]
- then
- rm -r armv7 armv7s i386 universal
- make clean
- exit 0
-fi
-mkdir armv7
-mkdir armv7s
-mkdir i386
-mkdir -p universal/lib
-
-build_armv7
-build_armv7s
-build_i386
-build_universal
-echo "Ouput files in $SOURCE/armv7 $SOURCE/armv7s $SOURCE/i386 $SOURCE/universal"
-
diff --git a/BuildScripts/build-libopus.sh b/BuildScripts/build-libopus.sh
new file mode 100755
index 0000000..aa4e568
--- /dev/null
+++ b/BuildScripts/build-libopus.sh
@@ -0,0 +1,180 @@
+#!/bin/bash
+# Builds libopus for iOS or tvOS.
+#
+# Copyright 2012 Mike Tigas
+#
+# Based on work by Felix Schulze on 16.12.10.
+# Copyright 2010 Felix Schulze. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+###########################################################################
+# Choose your libopus version and your currently-installed iOS SDK version:
+#
+TARGET="AppleTV" # "iPhone"
+VERSION="1.3.1"
+SDKVERSION="16.1"
+MINVERSIONDEF="-mtvos-version-min=12.0" # "-miphoneos-version-min=12.0"
+
+###########################################################################
+#
+# Don't change anything under this line!
+#
+###########################################################################
+
+# by default, we won't build for debugging purposes
+if [ "${DEBUG}" == "true" ]; then
+ echo "Compiling for debugging ..."
+ OPT_CFLAGS="-O0 -fno-inline -g"
+ OPT_LDFLAGS=""
+ OPT_CONFIG_ARGS="--enable-assertions --disable-asm"
+else
+ OPT_CFLAGS="-O3 -g"
+ OPT_LDFLAGS=""
+ OPT_CONFIG_ARGS=""
+fi
+
+
+# No need to change this since xcode build will only compile in the
+# necessary bits from the libraries we create
+ARCHS="x86_64 arm64"
+
+DEVELOPER=`xcode-select -print-path`
+#DEVELOPER="/Applications/Xcode.app/Contents/Developer"
+
+cd "`dirname \"$0\"`"
+REPOROOT=$(pwd)
+
+# Where we'll end up storing things in the end
+OUTPUTDIR="${REPOROOT}/dependencies"
+mkdir -p ${OUTPUTDIR}/include
+mkdir -p ${OUTPUTDIR}/lib
+
+
+BUILDDIR="${REPOROOT}/build"
+
+# where we will keep our sources and build from.
+SRCDIR="${BUILDDIR}/src"
+mkdir -p $SRCDIR
+# where we will store intermediary builds
+INTERDIR="${BUILDDIR}/built"
+mkdir -p $INTERDIR
+
+########################################
+
+cd $SRCDIR
+
+# Exit the script if an error happens
+set -e
+
+if [ ! -e "${SRCDIR}/opus-${VERSION}.tar.gz" ]; then
+ echo "Downloading opus-${VERSION}.tar.gz"
+ curl -LO http://downloads.xiph.org/releases/opus/opus-${VERSION}.tar.gz
+fi
+echo "Using opus-${VERSION}.tar.gz"
+
+tar zxf opus-${VERSION}.tar.gz -C $SRCDIR
+cd "${SRCDIR}/opus-${VERSION}"
+
+set +e # don't bail out of bash script if ccache doesn't exist
+CCACHE=`which ccache`
+if [ $? == "0" ]; then
+ echo "Building with ccache: $CCACHE"
+ CCACHE="${CCACHE} "
+else
+ echo "Building without ccache"
+ CCACHE=""
+fi
+set -e # back to regular "bail out on error" mode
+
+export ORIGINALPATH=$PATH
+
+for ARCH in ${ARCHS}
+do
+ if [ "${ARCH}" == "i386" ] || [ "${ARCH}" == "x86_64" ]; then
+ PLATFORM="${TARGET}Simulator"
+ EXTRA_CFLAGS="-arch ${ARCH}"
+ EXTRA_CONFIG="--host=x86_64-apple-darwin"
+ else
+ PLATFORM="${TARGET}OS"
+ EXTRA_CFLAGS="-arch ${ARCH}"
+ EXTRA_CONFIG="--host=arm-apple-darwin"
+ fi
+
+ mkdir -p "${INTERDIR}/${PLATFORM}${SDKVERSION}-${ARCH}.sdk"
+
+ ./configure --disable-shared --enable-static --with-pic --disable-extra-programs --disable-doc ${EXTRA_CONFIG} \
+ --prefix="${INTERDIR}/${PLATFORM}${SDKVERSION}-${ARCH}.sdk" \
+ LDFLAGS="$LDFLAGS ${OPT_LDFLAGS} -fPIE ${MINVERSIONDEF} -L${OUTPUTDIR}/lib" \
+ CFLAGS="$CFLAGS ${EXTRA_CFLAGS} ${OPT_CFLAGS} -fPIE ${MINVERSIONDEF} -I${OUTPUTDIR}/include -isysroot ${DEVELOPER}/Platforms/${PLATFORM}.platform/Developer/SDKs/${PLATFORM}${SDKVERSION}.sdk" \
+
+ # Build the application and install it to the fake SDK intermediary dir
+ # we have set up. Make sure to clean up afterward because we will re-use
+ # this source tree to cross-compile other targets.
+ make -j$(nproc)
+ make install
+ make clean
+done
+
+########################################
+
+echo "Build library..."
+
+# These are the libs that comprise libopus.
+OUTPUT_LIBS="libopus.a"
+for OUTPUT_LIB in ${OUTPUT_LIBS}; do
+ INPUT_LIBS=""
+ for ARCH in ${ARCHS}; do
+ if [ "${ARCH}" == "i386" ] || [ "${ARCH}" == "x86_64" ];
+ then
+ PLATFORM="${TARGET}Simulator"
+ else
+ PLATFORM="${TARGET}OS"
+ fi
+ INPUT_ARCH_LIB="${INTERDIR}/${PLATFORM}${SDKVERSION}-${ARCH}.sdk/lib/${OUTPUT_LIB}"
+ if [ -e $INPUT_ARCH_LIB ]; then
+ INPUT_LIBS="${INPUT_LIBS} ${INPUT_ARCH_LIB}"
+ fi
+ done
+ # Combine the three architectures into a universal library.
+ if [ -n "$INPUT_LIBS" ]; then
+ lipo -create $INPUT_LIBS \
+ -output "${OUTPUTDIR}/lib/${OUTPUT_LIB}"
+ else
+ echo "$OUTPUT_LIB does not exist, skipping (are the dependencies installed?)"
+ fi
+done
+
+for ARCH in ${ARCHS}; do
+ if [ "${ARCH}" == "i386" ] || [ "${ARCH}" == "x86_64" ];
+ then
+ PLATFORM="${TARGET}Simulator"
+ else
+ PLATFORM="${TARGET}OS"
+ fi
+ cp -R ${INTERDIR}/${PLATFORM}${SDKVERSION}-${ARCH}.sdk/include/* ${OUTPUTDIR}/include/
+ if [ $? == "0" ]; then
+ # We only need to copy the headers over once. (So break out of forloop
+ # once we get first success.)
+ break
+ fi
+done
+
+
+####################
+
+echo "Building done."
+echo "Cleaning up..."
+rm -fr ${INTERDIR}
+rm -fr "${SRCDIR}/opus-${VERSION}"
+echo "Done."
diff --git a/BuildScripts/build-openssl.sh b/BuildScripts/build-openssl.sh
deleted file mode 100755
index 8aafd96..0000000
--- a/BuildScripts/build-openssl.sh
+++ /dev/null
@@ -1,124 +0,0 @@
-#!/bin/bash
-
-# Yay shell scripting! This script builds a static version of
-# OpenSSL ${OPENSSL_VERSION} for iOS 7.0 that contains code for
-# armv6, armv7, arm7s and i386.
-
-#set -x
-
-# Setup paths to stuff we need
-
-OPENSSL_VERSION="1.0.1h"
-
-DEVELOPER="/Applications/Xcode.app/Contents/Developer"
-
-SDK_VERSION="7.1"
-MIN_VERSION="7.1"
-
-IPHONEOS_PLATFORM="${DEVELOPER}/Platforms/iPhoneOS.platform"
-IPHONEOS_SDK="${IPHONEOS_PLATFORM}/Developer/SDKs/iPhoneOS.sdk"
-IPHONEOS_GCC="/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang"
-
-IPHONESIMULATOR_PLATFORM="${DEVELOPER}/Platforms/iPhoneSimulator.platform"
-IPHONESIMULATOR_SDK="${IPHONESIMULATOR_PLATFORM}/Developer/SDKs/iPhoneSimulator.sdk"
-IPHONESIMULATOR_GCC="/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang"
-
-# Make sure things actually exist
-
-if [ ! -d "$IPHONEOS_PLATFORM" ]; then
- echo "Cannot find $IPHONEOS_PLATFORM"
- exit 1
-fi
-
-if [ ! -d "$IPHONEOS_SDK" ]; then
- echo "Cannot find $IPHONEOS_SDK"
- exit 1
-fi
-
-if [ ! -x "$IPHONEOS_GCC" ]; then
- echo "Cannot find $IPHONEOS_GCC"
- exit 1
-fi
-
-if [ ! -d "$IPHONESIMULATOR_PLATFORM" ]; then
- echo "Cannot find $IPHONESIMULATOR_PLATFORM"
- exit 1
-fi
-
-if [ ! -d "$IPHONESIMULATOR_SDK" ]; then
- echo "Cannot find $IPHONESIMULATOR_SDK"
- exit 1
-fi
-
-if [ ! -x "$IPHONESIMULATOR_GCC" ]; then
- echo "Cannot find $IPHONESIMULATOR_GCC"
- exit 1
-fi
-
-# Clean up whatever was left from our previous build
-
-rm -rf include lib
-rm -rf /tmp/openssl-${OPENSSL_VERSION}-*
-rm -rf /tmp/openssl-${OPENSSL_VERSION}-*.*-log
-
-build()
-{
- TARGET=$1
- ARCH=$2
- GCC=$3
- SDK=$4
- EXTRA=$5
- rm -rf "openssl-${OPENSSL_VERSION}"
- tar xfz "openssl-${OPENSSL_VERSION}.tar.gz"
- pushd .
- cd "openssl-${OPENSSL_VERSION}"
- ./Configure ${TARGET} --openssldir="/tmp/openssl-${OPENSSL_VERSION}-${ARCH}" ${EXTRA} &> "/tmp/openssl-${OPENSSL_VERSION}-${ARCH}.log"
- perl -i -pe 's|static volatile sig_atomic_t intr_signal|static volatile int intr_signal|' crypto/ui/ui_openssl.c
- perl -i -pe "s|^CC= gcc|CC= ${GCC} -arch ${ARCH} -miphoneos-version-min=${MIN_VERSION}|g" Makefile
- perl -i -pe "s|^CFLAG= (.*)|CFLAG= -isysroot ${SDK} \$1|g" Makefile
- make &> "/tmp/openssl-${OPENSSL_VERSION}-${ARCH}.build-log"
- make install &> "/tmp/openssl-${OPENSSL_VERSION}-${ARCH}.install-log"
- popd
- rm -rf "openssl-${OPENSSL_VERSION}"
-}
-
-mkdir openssl
-cd openssl
-if [ ! -e ${OPENSSL_VERSION}.tar.gz ]; then
-echo "Downloading ${OPENSSL_VERSION}.tar.gz"
-curl -O http://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz
-else
-echo "Using ${OPENSSL_VERSION}.tar.gz"
-fi
-
-
-build "BSD-generic32" "armv7" "${IPHONEOS_GCC}" "${IPHONEOS_SDK}" ""
-build "BSD-generic32" "armv7s" "${IPHONEOS_GCC}" "${IPHONEOS_SDK}" ""
-build "BSD-generic64" "arm64" "${IPHONEOS_GCC}" "${IPHONEOS_SDK}" ""
-build "BSD-generic32" "i386" "${IPHONESIMULATOR_GCC}" "${IPHONESIMULATOR_SDK}" ""
-build "BSD-generic64" "x86_64" "${IPHONESIMULATOR_GCC}" "${IPHONESIMULATOR_SDK}" "-DOPENSSL_NO_ASM"
-
-#
-
-mkdir include
-cp -r /tmp/openssl-${OPENSSL_VERSION}-i386/include/openssl include/
-
-mkdir lib
-lipo \
- "/tmp/openssl-${OPENSSL_VERSION}-armv7/lib/libcrypto.a" \
- "/tmp/openssl-${OPENSSL_VERSION}-armv7s/lib/libcrypto.a" \
- "/tmp/openssl-${OPENSSL_VERSION}-arm64/lib/libcrypto.a" \
- "/tmp/openssl-${OPENSSL_VERSION}-i386/lib/libcrypto.a" \
- "/tmp/openssl-${OPENSSL_VERSION}-x86_64/lib/libcrypto.a" \
- -create -output lib/libcrypto.a
-lipo \
- "/tmp/openssl-${OPENSSL_VERSION}-armv7/lib/libssl.a" \
- "/tmp/openssl-${OPENSSL_VERSION}-armv7s/lib/libssl.a" \
- "/tmp/openssl-${OPENSSL_VERSION}-arm64/lib/libssl.a" \
- "/tmp/openssl-${OPENSSL_VERSION}-i386/lib/libssl.a" \
- "/tmp/openssl-${OPENSSL_VERSION}-x86_64/lib/libssl.a" \
- -create -output lib/libssl.a
-
-rm -rf "/tmp/openssl-${OPENSSL_VERSION}-*"
-rm -rf "/tmp/openssl-${OPENSSL_VERSION}-*.*-log"
-
diff --git a/Builds/Limelight_0_1.ipa b/Builds/Limelight_0_1.ipa
deleted file mode 100644
index cb8e9b4..0000000
Binary files a/Builds/Limelight_0_1.ipa and /dev/null differ
diff --git a/Builds/Limelight_0_2.ipa b/Builds/Limelight_0_2.ipa
deleted file mode 100644
index bf6a715..0000000
Binary files a/Builds/Limelight_0_2.ipa and /dev/null differ
diff --git a/Builds/Limelight_0_2_1.ipa b/Builds/Limelight_0_2_1.ipa
deleted file mode 100644
index 163a651..0000000
Binary files a/Builds/Limelight_0_2_1.ipa and /dev/null differ
diff --git a/Builds/Limelight_0_2_2.ipa b/Builds/Limelight_0_2_2.ipa
deleted file mode 100644
index 928578f..0000000
Binary files a/Builds/Limelight_0_2_2.ipa and /dev/null differ
diff --git a/Builds/Limelight_0_3_0.ipa b/Builds/Limelight_0_3_0.ipa
deleted file mode 100644
index 74c1a68..0000000
Binary files a/Builds/Limelight_0_3_0.ipa and /dev/null differ
diff --git a/Builds/Limelight_0_3_1.ipa b/Builds/Limelight_0_3_1.ipa
deleted file mode 100644
index b8b3298..0000000
Binary files a/Builds/Limelight_0_3_1.ipa and /dev/null differ
diff --git a/Builds/Limelight_0_3_2.ipa b/Builds/Limelight_0_3_2.ipa
deleted file mode 100644
index fdec6d5..0000000
Binary files a/Builds/Limelight_0_3_2.ipa and /dev/null differ
diff --git a/Builds/Limelight_0_4_0.ipa b/Builds/Limelight_0_4_0.ipa
deleted file mode 100644
index a053dcf..0000000
Binary files a/Builds/Limelight_0_4_0.ipa and /dev/null differ
diff --git a/Builds/Limelight_0_4_1.ipa b/Builds/Limelight_0_4_1.ipa
deleted file mode 100644
index 43bbfcb..0000000
Binary files a/Builds/Limelight_0_4_1.ipa and /dev/null differ
diff --git a/Builds/Limelight_0_9_0.ipa b/Builds/Limelight_0_9_0.ipa
deleted file mode 100644
index ee5dbee..0000000
Binary files a/Builds/Limelight_0_9_0.ipa and /dev/null differ
diff --git a/Builds/Moonlight_0_9_2.ipa b/Builds/Moonlight_0_9_2.ipa
deleted file mode 100644
index 8662f9a..0000000
Binary files a/Builds/Moonlight_0_9_2.ipa and /dev/null differ
diff --git a/LICENSE.txt b/LICENSE.txt
index 94a9ed0..10c41a5 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -672,3 +672,11 @@ may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
+
+App Store Distribution Exception
+
+As additional permission under section 7, you are allowed to distribute the
+software through an app store, even if that store has restrictive terms and
+conditions that are incompatible with the GPL, provided that the source is
+also available under the GPL with or without this permission through a channel
+without those restrictive terms and conditions.
diff --git a/Launch Screen.xib b/Launch Screen.xib
index 0cd1c85..c238181 100644
--- a/Launch Screen.xib
+++ b/Launch Screen.xib
@@ -1,51 +1,19 @@
-
-
+
+
+
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
diff --git a/Limelight/AppDelegate.h b/Limelight/AppDelegate.h
index 0985139..90deb40 100644
--- a/Limelight/AppDelegate.h
+++ b/Limelight/AppDelegate.h
@@ -11,6 +11,8 @@
@interface AppDelegate : UIResponder
@property (strong, nonatomic) UIWindow *window;
+@property (strong, nonatomic) NSString *pcUuidToLoad;
+@property (strong, nonatomic) void (^shortcutCompletionHandler)(BOOL);
@property (readonly, strong, nonatomic) NSManagedObjectContext *managedObjectContext;
@property (readonly, strong, nonatomic) NSManagedObjectModel *managedObjectModel;
diff --git a/Limelight/AppDelegate.m b/Limelight/AppDelegate.m
index 2a3b8ec..5db73f5 100644
--- a/Limelight/AppDelegate.m
+++ b/Limelight/AppDelegate.m
@@ -16,39 +16,29 @@
static NSOperationQueue* mainQueue;
-- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
-{
- [[UILabel appearance] setFont:[UIFont fontWithName:@"Roboto-Regular" size:[UIFont systemFontSize]]];
- [[UIButton appearance].titleLabel setFont:[UIFont fontWithName:@"Roboto-Regular" size:[UIFont systemFontSize]]];
+#if TARGET_OS_TV
+static NSString* DB_NAME = @"Moonlight_tvOS.bin";
+#else
+static NSString* DB_NAME = @"Limelight_iOS.sqlite";
+#endif
-
- // Generate selected segment background image
- CGSize borderImageSize = CGSizeMake(1.f, 100.f);
-
- UIGraphicsBeginImageContext(borderImageSize);
- CGContextRef context = UIGraphicsGetCurrentContext();
-
- CGContextSetStrokeColorWithColor(context, [UIColor whiteColor].CGColor);
- CGContextFillRect(context, CGRectMake(0.f, borderImageSize.height * 0.8, borderImageSize.width, borderImageSize.height));
-
- UIImage *selectedSegmentBG = UIGraphicsGetImageFromCurrentImageContext();
- UIGraphicsEndImageContext();
-
- // Clear default border and background color
- [[UISegmentedControl appearance] setBackgroundImage:[[UIImage alloc] init] forState:UIControlStateNormal barMetrics:UIBarMetricsDefault];
- [[UISegmentedControl appearance] setDividerImage:[[UIImage alloc] init] forLeftSegmentState:UIControlStateNormal rightSegmentState:UIControlStateNormal barMetrics:UIBarMetricsDefault];
-
- // Set selected segment background image
- [[UISegmentedControl appearance] setBackgroundImage:[selectedSegmentBG imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate] forState:UIControlStateSelected barMetrics:UIBarMetricsDefault];
-
- // Change font on UISegmentedControl
- [[UISegmentedControl appearance] setTitleTextAttributes:[NSDictionary dictionaryWithObjectsAndKeys:
- [UIColor whiteColor], NSForegroundColorAttributeName,
- [UIFont fontWithName:@"Roboto-Regular" size:[UIFont systemFontSize]], NSFontAttributeName, nil] forState:UIControlStateNormal];
-
+- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
+#if !TARGET_OS_TV
+ UIApplicationShortcutItem* shortcut = [launchOptions valueForKey:UIApplicationLaunchOptionsShortcutItemKey];
+ if (shortcut != nil) {
+ _pcUuidToLoad = (NSString*)[shortcut.userInfo objectForKey:@"UUID"];
+ }
+#endif
return YES;
}
+#if !TARGET_OS_TV
+- (void)application:(UIApplication *)application performActionForShortcutItem:(UIApplicationShortcutItem *)shortcutItem completionHandler:(void (^)(BOOL succeeded))completionHandler {
+ _pcUuidToLoad = (NSString*)[shortcutItem.userInfo objectForKey:@"UUID"];
+ _shortcutCompletionHandler = completionHandler;
+}
+#endif
+
- (void)applicationWillResignActive:(UIApplication *)application
{
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
@@ -57,7 +47,7 @@ static NSOperationQueue* mainQueue;
- (void)applicationDidEnterBackground:(UIApplication *)application
{
- // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
+ // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
@@ -79,12 +69,22 @@ static NSOperationQueue* mainQueue;
- (void)saveContext
{
- NSError *error = nil;
- NSManagedObjectContext *managedObjectContext = self.managedObjectContext;
+ NSManagedObjectContext *managedObjectContext = [self managedObjectContext];
if (managedObjectContext != nil) {
- if ([managedObjectContext hasChanges] && ![managedObjectContext save:&error]) {
- Log(LOG_E, @"Critical database error: %@, %@", error, [error userInfo]);
- }
+ [managedObjectContext performBlock:^{
+ if (![managedObjectContext hasChanges]) {
+ return;
+ }
+ NSError *error = nil;
+ if (![managedObjectContext save:&error]) {
+ Log(LOG_E, @"Critical database error: %@, %@", error, [error userInfo]);
+ }
+
+#if TARGET_OS_TV
+ NSData* dbData = [NSData dataWithContentsOfURL:[[[[NSFileManager defaultManager] URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask] lastObject] URLByAppendingPathComponent:DB_NAME]];
+ [[NSUserDefaults standardUserDefaults] setObject:dbData forKey:DB_NAME];
+#endif
+ }];
}
}
@@ -125,19 +125,30 @@ static NSOperationQueue* mainQueue;
return _persistentStoreCoordinator;
}
- NSURL *storeURL = [self getStoreURL];
-
NSError *error = nil;
_persistentStoreCoordinator = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:[self managedObjectModel]];
NSDictionary *options = [NSDictionary dictionaryWithObjectsAndKeys:
[NSNumber numberWithBool:YES], NSMigratePersistentStoresAutomaticallyOption,
[NSNumber numberWithBool:YES], NSInferMappingModelAutomaticallyOption, nil];
- if (![_persistentStoreCoordinator addPersistentStoreWithType:NSSQLiteStoreType configuration:nil URL:storeURL options:options error:&error]) {
+ NSString* storeType;
+
+#if TARGET_OS_TV
+ // Use a binary store for tvOS since we will need exclusive access to the file
+ // to serialize into NSUserDefaults.
+ storeType = NSBinaryStoreType;
+#else
+ storeType = NSSQLiteStoreType;
+#endif
+
+ // We must ensure the persistent store is ready to opened
+ [self preparePersistentStore];
+
+ if (![_persistentStoreCoordinator addPersistentStoreWithType:storeType configuration:nil URL:[self getStoreURL] options:options error:&error]) {
// Log the error
Log(LOG_E, @"Critical database error: %@, %@", error, [error userInfo]);
// Drop the database
- [[NSFileManager defaultManager] removeItemAtURL:storeURL error:nil];
+ [self dropDatabase];
// Try again
return [self persistentStoreCoordinator];
@@ -154,8 +165,50 @@ static NSOperationQueue* mainQueue;
return [[[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask] lastObject];
}
+- (void) dropDatabase
+{
+ // Delete the file on disk
+ [[NSFileManager defaultManager] removeItemAtURL:[self getStoreURL] error:nil];
+
+#if TARGET_OS_TV
+ // Also delete the copy in the NSUserDefaults on tvOS
+ [[NSUserDefaults standardUserDefaults] removeObjectForKey:DB_NAME];
+#endif
+}
+
+- (void) preparePersistentStore
+{
+#if TARGET_OS_TV
+ // On tvOS, we may need to inflate the DB from NSUserDefaults
+ NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
+ NSString *cacheDirectory = [paths objectAtIndex:0];
+ NSString *dbPath = [cacheDirectory stringByAppendingPathComponent:DB_NAME];
+
+ // Always prefer the on disk version
+ if (![[NSFileManager defaultManager] fileExistsAtPath:dbPath]) {
+ // If that is unavailable, inflate it from NSUserDefaults
+ NSData* data = [[NSUserDefaults standardUserDefaults] dataForKey:DB_NAME];
+ if (data != nil) {
+ Log(LOG_I, @"Inflating database from NSUserDefaults");
+ [data writeToFile:dbPath atomically:YES];
+ }
+ else {
+ Log(LOG_I, @"No database on disk or in NSUserDefaults");
+ }
+ }
+ else {
+ Log(LOG_I, @"Using cached database");
+ }
+#endif
+}
+
- (NSURL*) getStoreURL {
- return [[self applicationDocumentsDirectory] URLByAppendingPathComponent:@"Limelight_iOS.sqlite"];
+#if TARGET_OS_TV
+ // We use the cache folder to store our database on tvOS
+ return [[[[NSFileManager defaultManager] URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask] lastObject] URLByAppendingPathComponent:DB_NAME];
+#else
+ return [[self applicationDocumentsDirectory] URLByAppendingPathComponent:DB_NAME];
+#endif
}
@end
diff --git a/Limelight/Crypto/CryptoManager.h b/Limelight/Crypto/CryptoManager.h
index f716083..b1fc76d 100644
--- a/Limelight/Crypto/CryptoManager.h
+++ b/Limelight/Crypto/CryptoManager.h
@@ -6,16 +6,14 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
-
@interface CryptoManager : NSObject
-+ (void) generateKeyPairUsingSSl;
++ (void) generateKeyPairUsingSSL;
+ (NSData*) readCertFromFile;
+ (NSData*) readKeyFromFile;
+ (NSData*) readP12FromFile;
+ (NSData*) getSignatureFromCert:(NSData*)cert;
-+ (NSData*) nullTerminateString:(NSData*)data;
++ (NSData*) pemToDer:(NSData*)pemCertBytes;
- (NSData*) createAESKeyFromSaltSHA1:(NSData*)saltedPIN;
- (NSData*) createAESKeyFromSaltSHA256:(NSData*)saltedPIN;
diff --git a/Limelight/Crypto/CryptoManager.m b/Limelight/Crypto/CryptoManager.m
index 38b4e8b..f9603cc 100644
--- a/Limelight/Crypto/CryptoManager.m
+++ b/Limelight/Crypto/CryptoManager.m
@@ -9,7 +9,6 @@
#import "CryptoManager.h"
#import "mkcert.h"
-#include
#include
#include
#include
@@ -45,59 +44,72 @@ static NSData* p12 = nil;
}
- (NSData*) aesEncrypt:(NSData*)data withKey:(NSData*)key {
- AES_KEY aesKey;
- AES_set_encrypt_key([key bytes], 128, &aesKey);
- int size = [self getEncryptSize:data];
- unsigned char* buffer = malloc(size);
- unsigned char* blockRoundedBuffer = calloc(1, size);
- memcpy(blockRoundedBuffer, [data bytes], [data length]);
+ EVP_CIPHER_CTX* cipher;
+ int ciphertextLen;
+
+ cipher = EVP_CIPHER_CTX_new();
+
+ EVP_EncryptInit(cipher, EVP_aes_128_ecb(), [key bytes], NULL);
+ EVP_CIPHER_CTX_set_padding(cipher, 0);
+
+ NSMutableData* ciphertext = [NSMutableData dataWithLength:[data length]];
+ EVP_EncryptUpdate(cipher,
+ [ciphertext mutableBytes],
+ &ciphertextLen,
+ [data bytes],
+ (int)[data length]);
+ assert(ciphertextLen == [ciphertext length]);
+
+ EVP_CIPHER_CTX_free(cipher);
- // AES_encrypt only encrypts the first 16 bytes so iterate the entire buffer
- int blockOffset = 0;
- while (blockOffset < size) {
- AES_encrypt(blockRoundedBuffer + blockOffset, buffer + blockOffset, &aesKey);
- blockOffset += 16;
- }
-
- NSData* encryptedData = [NSData dataWithBytes:buffer length:size];
- free(buffer);
- free(blockRoundedBuffer);
- return encryptedData;
+ return ciphertext;
}
- (NSData*) aesDecrypt:(NSData*)data withKey:(NSData*)key {
- AES_KEY aesKey;
- AES_set_decrypt_key([key bytes], 128, &aesKey);
- unsigned char* buffer = malloc([data length]);
+ EVP_CIPHER_CTX* cipher;
+ int plaintextLen;
+
+ cipher = EVP_CIPHER_CTX_new();
+
+ EVP_DecryptInit(cipher, EVP_aes_128_ecb(), [key bytes], NULL);
+ EVP_CIPHER_CTX_set_padding(cipher, 0);
+
+ NSMutableData* plaintext = [NSMutableData dataWithLength:[data length]];
+ EVP_DecryptUpdate(cipher,
+ [plaintext mutableBytes],
+ &plaintextLen,
+ [data bytes],
+ (int)[data length]);
+ assert(plaintextLen == [plaintext length]);
+
+ EVP_CIPHER_CTX_free(cipher);
- // AES_decrypt only decrypts the first 16 bytes so iterate the entire buffer
- int blockOffset = 0;
- while (blockOffset < [data length]) {
- AES_decrypt([data bytes] + blockOffset, buffer + blockOffset, &aesKey);
- blockOffset += 16;
- }
-
- NSData* decryptedData = [NSData dataWithBytes:buffer length:[data length]];
- free(buffer);
- return decryptedData;
+ return plaintext;
}
-- (int) getEncryptSize:(NSData*)data {
- // the size is the length of the data ceiling to the nearest 16 bytes
- return (((int)[data length] + 15) / 16) * 16;
-}
++ (NSData*) pemToDer:(NSData*)pemCertBytes {
+ X509* x509;
+
+ BIO* bio = BIO_new_mem_buf([pemCertBytes bytes], (int)[pemCertBytes length]);
+ x509 = PEM_read_bio_X509(bio, NULL, NULL, NULL);
+ BIO_free(bio);
+
+ bio = BIO_new(BIO_s_mem());
+ i2d_X509_bio(bio, x509);
+ X509_free(x509);
-+ (NSData*) nullTerminateString:(NSData*)data {
- NSMutableData* mutData = [NSMutableData dataWithData:data];
- [mutData appendBytes:"" length:1];
- return mutData;
+ BUF_MEM* mem;
+ BIO_get_mem_ptr(bio, &mem);
+
+ NSData* ret = [[NSData alloc] initWithBytes:mem->data length:mem->length];
+ BIO_free(bio);
+
+ return ret;
}
- (bool) verifySignature:(NSData *)data withSignature:(NSData*)signature andCert:(NSData*)cert {
- const char* buffer = [[CryptoManager nullTerminateString:cert] bytes];
X509* x509;
- BIO* bio = BIO_new(BIO_s_mem());
- BIO_puts(bio, buffer);
+ BIO* bio = BIO_new_mem_buf([cert bytes], (int)[cert length]);
x509 = PEM_read_bio_X509(bio, NULL, NULL, NULL);
BIO_free(bio);
@@ -122,9 +134,7 @@ static NSData* p12 = nil;
}
- (NSData *)signData:(NSData *)data withKey:(NSData *)key {
- const char* buffer = [[CryptoManager nullTerminateString:key] bytes];
- BIO* bio = BIO_new(BIO_s_mem());
- BIO_puts(bio, buffer);
+ BIO* bio = BIO_new_mem_buf([key bytes], (int)[key length]);
EVP_PKEY* pkey;
pkey = PEM_read_bio_PrivateKey(bio, NULL, NULL, NULL);
@@ -159,82 +169,137 @@ static NSData* p12 = nil;
return signedData;
}
-// TODO: these three methods are almost identical, fix the copy-pasta
++ (NSData*) readCryptoObject:(NSString*)item {
+#if TARGET_OS_TV
+ return [[NSUserDefaults standardUserDefaults] dataForKey:item];
+#else
+ NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
+ NSString *documentsDirectory = [paths objectAtIndex:0];
+ NSString *file = [documentsDirectory stringByAppendingPathComponent:item];
+ return [NSData dataWithContentsOfFile:file];
+#endif
+}
+
++ (void) writeCryptoObject:(NSString*)item data:(NSData*)data {
+#if TARGET_OS_TV
+ [[NSUserDefaults standardUserDefaults] setObject:data forKey:item];
+#else
+ NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
+ NSString *documentsDirectory = [paths objectAtIndex:0];
+ NSString *file = [documentsDirectory stringByAppendingPathComponent:item];
+ [data writeToFile:file atomically:NO];
+#endif
+}
+
+ (NSData*) readCertFromFile {
if (cert == nil) {
- NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
- NSString *documentsDirectory = [paths objectAtIndex:0];
- NSString *certFile = [documentsDirectory stringByAppendingPathComponent:@"client.crt"];
- cert = [NSData dataWithContentsOfFile:certFile];
+ cert = [CryptoManager readCryptoObject:@"client.crt"];
}
return cert;
}
+ (NSData*) readP12FromFile {
if (p12 == nil) {
- NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
- NSString *documentsDirectory = [paths objectAtIndex:0];
- NSString *p12File = [documentsDirectory stringByAppendingPathComponent:@"client.p12"];
- p12 = [NSData dataWithContentsOfFile:p12File];
+ p12 = [CryptoManager readCryptoObject:@"client.p12"];
}
return p12;
}
+ (NSData*) readKeyFromFile {
if (key == nil) {
- NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
- NSString *documentsDirectory = [paths objectAtIndex:0];
- NSString *keyFile = [documentsDirectory stringByAppendingPathComponent:@"client.key"];
- key = [NSData dataWithContentsOfFile:keyFile];
+ key = [CryptoManager readCryptoObject:@"client.key"];
}
return key;
}
+ (bool) keyPairExists {
- NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
- NSString *documentsDirectory = [paths objectAtIndex:0];
- NSString *keyFile = [documentsDirectory stringByAppendingPathComponent:@"client.key"];
- NSString *p12File = [documentsDirectory stringByAppendingPathComponent:@"client.p12"];
- NSString *certFile = [documentsDirectory stringByAppendingPathComponent:@"client.crt"];
-
- bool keyFileExists = [[NSFileManager defaultManager] fileExistsAtPath:keyFile];
- bool p12FileExists = [[NSFileManager defaultManager] fileExistsAtPath:p12File];
- bool certFileExists = [[NSFileManager defaultManager] fileExistsAtPath:certFile];
+ bool keyFileExists = [CryptoManager readCryptoObject:@"client.key"] != nil;
+ bool p12FileExists = [CryptoManager readCryptoObject:@"client.p12"] != nil;
+ bool certFileExists = [CryptoManager readCryptoObject:@"client.crt"] != nil;
return keyFileExists && p12FileExists && certFileExists;
}
+ (NSData *)getSignatureFromCert:(NSData *)cert {
- const char* buffer = [[CryptoManager nullTerminateString:cert] bytes];
- X509* x509;
- BIO* bio = BIO_new(BIO_s_mem());
- BIO_puts(bio, buffer);
- x509 = PEM_read_bio_X509(bio, NULL, NULL, NULL);
+ BIO* bio = BIO_new_mem_buf([cert bytes], (int)[cert length]);
+ X509* x509 = PEM_read_bio_X509(bio, NULL, NULL, NULL);
+ BIO_free(bio);
if (!x509) {
Log(LOG_E, @"Unable to parse certificate in memory!");
return NULL;
}
- return [NSData dataWithBytes:x509->signature->data length:x509->signature->length];
+
+#if (OPENSSL_VERSION_NUMBER < 0x10002000L)
+ ASN1_BIT_STRING *asnSignature = x509->signature;
+#elif (OPENSSL_VERSION_NUMBER < 0x10100000L)
+ ASN1_BIT_STRING *asnSignature;
+ X509_get0_signature(&asnSignature, NULL, x509);
+#else
+ const ASN1_BIT_STRING *asnSignature;
+ X509_get0_signature(&asnSignature, NULL, x509);
+#endif
+
+ NSData* sig = [NSData dataWithBytes:asnSignature->data length:asnSignature->length];
+
+ X509_free(x509);
+
+ return sig;
}
-+ (void) generateKeyPairUsingSSl {
++ (NSData*)getKeyFromCertKeyPair:(CertKeyPair*)certKeyPair {
+ BIO* bio = BIO_new(BIO_s_mem());
+
+ PEM_write_bio_PrivateKey_traditional(bio, certKeyPair->pkey, NULL, NULL, 0, NULL, NULL);
+
+ BUF_MEM* mem;
+ BIO_get_mem_ptr(bio, &mem);
+ NSData* data = [NSData dataWithBytes:mem->data length:mem->length];
+ BIO_free(bio);
+ return data;
+}
+
++ (NSData*)getP12FromCertKeyPair:(CertKeyPair*)certKeyPair {
+ BIO* bio = BIO_new(BIO_s_mem());
+
+ i2d_PKCS12_bio(bio, certKeyPair->p12);
+
+ BUF_MEM* mem;
+ BIO_get_mem_ptr(bio, &mem);
+ NSData* data = [NSData dataWithBytes:mem->data length:mem->length];
+ BIO_free(bio);
+ return data;
+}
+
++ (NSData*)getCertFromCertKeyPair:(CertKeyPair*)certKeyPair {
+ BIO* bio = BIO_new(BIO_s_mem());
+
+ PEM_write_bio_X509(bio, certKeyPair->x509);
+
+ BUF_MEM* mem;
+ BIO_get_mem_ptr(bio, &mem);
+ NSData* data = [NSData dataWithBytes:mem->data length:mem->length];
+ BIO_free(bio);
+ return data;
+}
+
++ (void) generateKeyPairUsingSSL {
static dispatch_once_t pred;
dispatch_once(&pred, ^{
if (![CryptoManager keyPairExists]) {
-
Log(LOG_I, @"Generating Certificate... ");
CertKeyPair certKeyPair = generateCertKeyPair();
- NSArray* paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
- NSString* documentsDirectory = [paths objectAtIndex:0];
- NSString* certFile = [documentsDirectory stringByAppendingPathComponent:@"client.crt"];
- NSString* keyPairFile = [documentsDirectory stringByAppendingPathComponent:@"client.key"];
- NSString* p12File = [documentsDirectory stringByAppendingPathComponent:@"client.p12"];
+ NSData* certData = [CryptoManager getCertFromCertKeyPair:&certKeyPair];
+ NSData* p12Data = [CryptoManager getP12FromCertKeyPair:&certKeyPair];
+ NSData* keyData = [CryptoManager getKeyFromCertKeyPair:&certKeyPair];
- //Log(LOG_D, @"Writing cert and key to: \n%@\n%@", certFile, keyPairFile);
- saveCertKeyPair([certFile UTF8String], [p12File UTF8String], [keyPairFile UTF8String], certKeyPair);
freeCertKeyPair(certKeyPair);
+
+ [CryptoManager writeCryptoObject:@"client.crt" data:certData];
+ [CryptoManager writeCryptoObject:@"client.p12" data:p12Data];
+ [CryptoManager writeCryptoObject:@"client.key" data:keyData];
+
Log(LOG_I, @"Certificate created");
}
});
diff --git a/Limelight/Crypto/IdManager.h b/Limelight/Crypto/IdManager.h
index 3fc5fae..1e5aaeb 100644
--- a/Limelight/Crypto/IdManager.h
+++ b/Limelight/Crypto/IdManager.h
@@ -6,8 +6,6 @@
// Copyright © 2015 Moonlight Stream. All rights reserved.
//
-#import
-
@interface IdManager : NSObject
+ (NSString*) getUniqueId;
diff --git a/Limelight/Crypto/mkcert.c b/Limelight/Crypto/mkcert.c
index 98a5d49..b21b812 100644
--- a/Limelight/Crypto/mkcert.c
+++ b/Limelight/Crypto/mkcert.c
@@ -4,54 +4,88 @@
#include
#include
+#include
#include
-#include
-#include
-
-#ifndef OPENSSL_NO_ENGINE
-#include
-#endif
+#include
+#include
+#include
+#include
static const int NUM_BITS = 2048;
static const int SERIAL = 0;
-static const int NUM_YEARS = 10;
+static const int NUM_YEARS = 20;
-int mkcert(X509 **x509p, EVP_PKEY **pkeyp, int bits, int serial, int years);
-int add_ext(X509 *cert, int nid, char *value);
+void mkcert(X509 **x509p, EVP_PKEY **pkeyp, int bits, int serial, int years) {
+ X509* cert = X509_new();
+
+ EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, NULL);
-struct CertKeyPair generateCertKeyPair() {
- BIO *bio_err;
+ EVP_PKEY_keygen_init(ctx);
+ EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, bits);
+
+ // pk must be initialized on input
+ EVP_PKEY* pk = NULL;
+ EVP_PKEY_keygen(ctx, &pk);
+
+ EVP_PKEY_CTX_free(ctx);
+
+ X509_set_version(cert, 2);
+ ASN1_INTEGER_set(X509_get_serialNumber(cert), serial);
+#if OPENSSL_VERSION_NUMBER < 0x10100000L
+ X509_gmtime_adj(X509_get_notBefore(cert), 0);
+ X509_gmtime_adj(X509_get_notAfter(cert), 60 * 60 * 24 * 365 * years);
+#else
+ ASN1_TIME* before = ASN1_STRING_dup(X509_get0_notBefore(cert));
+ ASN1_TIME* after = ASN1_STRING_dup(X509_get0_notAfter(cert));
+
+ X509_gmtime_adj(before, 0);
+ X509_gmtime_adj(after, 60 * 60 * 24 * 365 * years);
+
+ X509_set1_notBefore(cert, before);
+ X509_set1_notAfter(cert, after);
+
+ ASN1_STRING_free(before);
+ ASN1_STRING_free(after);
+#endif
+
+ X509_set_pubkey(cert, pk);
+
+ X509_NAME* name = X509_get_subject_name(cert);
+ X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC,
+ (const unsigned char*)"NVIDIA GameStream Client",
+ -1, -1, 0);
+ X509_set_issuer_name(cert, name);
+
+ X509_sign(cert, pk, EVP_sha256());
+
+ *x509p = cert;
+ *pkeyp = pk;
+}
+
+struct CertKeyPair generateCertKeyPair(void) {
X509 *x509 = NULL;
EVP_PKEY *pkey = NULL;
PKCS12 *p12 = NULL;
-
- CRYPTO_mem_ctrl(CRYPTO_MEM_CHECK_ON);
- bio_err = BIO_new_fp(stderr, BIO_NOCLOSE);
-
- SSLeay_add_all_algorithms();
- ERR_load_crypto_strings();
mkcert(&x509, &pkey, NUM_BITS, SERIAL, NUM_YEARS);
-
- p12 = PKCS12_create("limelight", "GameStream", pkey, x509, NULL, 0, 0, 0, 0, 0);
+
+ char* pass = "limelight";
+ p12 = PKCS12_create(pass,
+ "GameStream",
+ pkey,
+ x509,
+ NULL,
+ NID_pbe_WithSHA1And3_Key_TripleDES_CBC,
+ -1, // disable certificate encryption
+ 2048,
+ -1, // disable the automatic MAC
+ 0);
+ // MAC it ourselves with SHA1 since iOS refuses to load anything else.
+ PKCS12_set_mac(p12, pass, -1, NULL, 0, 1, EVP_sha1());
+
if (p12 == NULL) {
printf("Error generating a valid PKCS12 certificate.\n");
}
-
- // Debug Print statements
- //RSA_print_fp(stdout, pkey->pkey.rsa, 0);
- //X509_print_fp(stdout, x509);
- //PEM_write_PUBKEY(stdout, pkey);
- //PEM_write_PrivateKey(stdout, pkey, NULL, NULL, 0, NULL, NULL);
- //PEM_write_X509(stdout, x509);
-
-#ifndef OPENSSL_NO_ENGINE
- ENGINE_cleanup();
-#endif
- CRYPTO_cleanup_all_ex_data();
-
- CRYPTO_mem_leaks(bio_err);
- BIO_free(bio_err);
return (CertKeyPair){x509, pkey, p12};
}
@@ -61,107 +95,3 @@ void freeCertKeyPair(struct CertKeyPair certKeyPair) {
EVP_PKEY_free(certKeyPair.pkey);
PKCS12_free(certKeyPair.p12);
}
-
-void saveCertKeyPair(const char* certFile, const char* p12File, const char* keyPairFile, CertKeyPair certKeyPair) {
- FILE* certFilePtr = fopen(certFile, "w");
- FILE* keyPairFilePtr = fopen(keyPairFile, "w");
- FILE* p12FilePtr = fopen(p12File, "wb");
-
- //TODO: error check
- PEM_write_PrivateKey(keyPairFilePtr, certKeyPair.pkey, NULL, NULL, 0, NULL, NULL);
- PEM_write_X509(certFilePtr, certKeyPair.x509);
- i2d_PKCS12_fp(p12FilePtr, certKeyPair.p12);
-
- fclose(p12FilePtr);
- fclose(certFilePtr);
- fclose(keyPairFilePtr);
-}
-
-int mkcert(X509 **x509p, EVP_PKEY **pkeyp, int bits, int serial, int years) {
- X509 *x;
- EVP_PKEY *pk;
- RSA *rsa;
- X509_NAME *name = NULL;
-
- if (*pkeyp == NULL) {
- if ((pk=EVP_PKEY_new()) == NULL) {
- return(0);
- }
- } else {
- pk = *pkeyp;
- }
-
- if (*x509p == NULL) {
- if ((x = X509_new()) == NULL) {
- goto err;
- }
- } else {
- x = *x509p;
- }
-
- rsa = RSA_generate_key(bits, RSA_F4, NULL, NULL);
- if (!EVP_PKEY_assign_RSA(pk, rsa)) {
- goto err;
- }
-
- X509_set_version(x, 2);
- ASN1_INTEGER_set(X509_get_serialNumber(x), serial);
- X509_gmtime_adj(X509_get_notBefore(x), 0);
- X509_gmtime_adj(X509_get_notAfter(x), (long)60*60*24*365*years);
- X509_set_pubkey(x, pk);
-
- name = X509_get_subject_name(x);
-
- /* This function creates and adds the entry, working out the
- * correct string type and performing checks on its length.
- */
- X509_NAME_add_entry_by_txt(name,"CN", MBSTRING_ASC, (unsigned char*)"NVIDIA GameStream Client", -1, -1, 0);
-
- /* Its self signed so set the issuer name to be the same as the
- * subject.
- */
- X509_set_issuer_name(x, name);
-
- /* Add various extensions: standard extensions */
- add_ext(x, NID_basic_constraints, "critical,CA:TRUE");
- add_ext(x, NID_key_usage, "critical,keyCertSign,cRLSign");
-
- add_ext(x, NID_subject_key_identifier, "hash");
-
- if (!X509_sign(x, pk, EVP_sha1())) {
- goto err;
- }
-
- *x509p = x;
- *pkeyp = pk;
-
- return(1);
-err:
- return(0);
-}
-
-/* Add extension using V3 code: we can set the config file as NULL
- * because we wont reference any other sections.
- */
-
-int add_ext(X509 *cert, int nid, char *value)
-{
- X509_EXTENSION *ex;
- X509V3_CTX ctx;
- /* This sets the 'context' of the extensions. */
- /* No configuration database */
- X509V3_set_ctx_nodb(&ctx);
- /* Issuer and subject certs: both the target since it is self signed,
- * no request and no CRL
- */
- X509V3_set_ctx(&ctx, cert, cert, NULL, NULL, 0);
- ex = X509V3_EXT_conf_nid(NULL, &ctx, nid, value);
- if (!ex) {
- return 0;
- }
-
- X509_add_ext(cert, ex, -1);
- X509_EXTENSION_free(ex);
- return 1;
-}
-
diff --git a/Limelight/Crypto/mkcert.h b/Limelight/Crypto/mkcert.h
index 22e62f5..f3c62f8 100644
--- a/Limelight/Crypto/mkcert.h
+++ b/Limelight/Crypto/mkcert.h
@@ -18,8 +18,7 @@ typedef struct CertKeyPair {
PKCS12 *p12;
} CertKeyPair;
-struct CertKeyPair generateCertKeyPair();
+struct CertKeyPair generateCertKeyPair(void);
void freeCertKeyPair(CertKeyPair);
-void saveCertKeyPair(const char* certFile, const char* p12File, const char* keyPairFile, CertKeyPair certKeyPair);
#endif
diff --git a/Limelight/Database/App+CoreDataProperties.h b/Limelight/Database/App+CoreDataProperties.h
deleted file mode 100644
index 8df4ef6..0000000
--- a/Limelight/Database/App+CoreDataProperties.h
+++ /dev/null
@@ -1,25 +0,0 @@
-//
-// App+CoreDataProperties.h
-// Moonlight
-//
-// Created by Diego Waxemberg on 7/10/15.
-// Copyright © 2015 Limelight Stream. All rights reserved.
-//
-// Delete this file and regenerate it using "Create NSManagedObject Subclass…"
-// to keep your implementation up to date with your model.
-//
-
-#import "App.h"
-
-NS_ASSUME_NONNULL_BEGIN
-
-@interface App (CoreDataProperties)
-
-@property (nullable, nonatomic, retain) NSString *id;
-@property (nullable, nonatomic, retain) NSData *image;
-@property (nullable, nonatomic, retain) NSString *name;
-@property (nullable, nonatomic, retain) Host *host;
-
-@end
-
-NS_ASSUME_NONNULL_END
diff --git a/Limelight/Database/App.h b/Limelight/Database/App.h
deleted file mode 100644
index 660dab7..0000000
--- a/Limelight/Database/App.h
+++ /dev/null
@@ -1,18 +0,0 @@
-//
-// App.h
-// Moonlight
-//
-// Created by Diego Waxemberg on 7/10/15.
-// Copyright © 2015 Limelight Stream. All rights reserved.
-//
-
-#import
-#import
-
-@class Host;
-
-@interface App : NSManagedObject
-
-@end
-
-#import "App+CoreDataProperties.h"
diff --git a/Limelight/Database/App.m b/Limelight/Database/App.m
deleted file mode 100644
index edb6355..0000000
--- a/Limelight/Database/App.m
+++ /dev/null
@@ -1,13 +0,0 @@
-//
-// App.m
-// Moonlight
-//
-// Created by Diego Waxemberg on 7/10/15.
-// Copyright © 2015 Limelight Stream. All rights reserved.
-//
-
-#import "App.h"
-
-@implementation App
-
-@end
diff --git a/Limelight/Database/DataManager.h b/Limelight/Database/DataManager.h
index d83b3c0..5717a79 100644
--- a/Limelight/Database/DataManager.h
+++ b/Limelight/Database/DataManager.h
@@ -6,7 +6,6 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
#import "AppDelegate.h"
#import "TemporaryHost.h"
#import "TemporaryApp.h"
@@ -14,12 +13,26 @@
@interface DataManager : NSObject
-- (void) saveSettingsWithBitrate:(NSInteger)bitrate framerate:(NSInteger)framerate height:(NSInteger)height width:(NSInteger)width onscreenControls:(NSInteger)onscreenControls;
+- (void) saveSettingsWithBitrate:(NSInteger)bitrate
+ framerate:(NSInteger)framerate
+ height:(NSInteger)height
+ width:(NSInteger)width
+ audioConfig:(NSInteger)audioConfig
+ onscreenControls:(NSInteger)onscreenControls
+ optimizeGames:(BOOL)optimizeGames
+ multiController:(BOOL)multiController
+ swapABXYButtons:(BOOL)swapABXYButtons
+ audioOnPC:(BOOL)audioOnPC
+ preferredCodec:(uint32_t)preferredCodec
+ useFramePacing:(BOOL)useFramePacing
+ enableHdr:(BOOL)enableHdr
+ btMouseSupport:(BOOL)btMouseSupport
+ absoluteTouchMode:(BOOL)absoluteTouchMode
+ statsOverlay:(BOOL)statsOverlay;
- (NSArray*) getHosts;
- (void) updateHost:(TemporaryHost*)host;
- (void) updateAppsForExistingHost:(TemporaryHost *)host;
-- (void) updateIconForExistingApp:(TemporaryApp*)app;
- (void) removeHost:(TemporaryHost*)host;
- (void) removeApp:(TemporaryApp*)app;
diff --git a/Limelight/Database/DataManager.m b/Limelight/Database/DataManager.m
index afc6141..0f5edeb 100644
--- a/Limelight/Database/DataManager.m
+++ b/Limelight/Database/DataManager.m
@@ -9,7 +9,6 @@
#import "DataManager.h"
#import "TemporaryApp.h"
#import "TemporarySettings.h"
-#import "Settings.h"
@implementation DataManager {
NSManagedObjectContext *_managedObjectContext;
@@ -26,12 +25,12 @@
}
else {
dispatch_sync(dispatch_get_main_queue(), ^{
- _appDelegate = (AppDelegate *)[[UIApplication sharedApplication] delegate];
+ self->_appDelegate = (AppDelegate *)[[UIApplication sharedApplication] delegate];
});
}
_managedObjectContext = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
- [_managedObjectContext setPersistentStoreCoordinator:_appDelegate.persistentStoreCoordinator];
+ [_managedObjectContext setParentContext:[_appDelegate managedObjectContext]];
return self;
}
@@ -53,16 +52,41 @@
return uid;
}
-- (void) saveSettingsWithBitrate:(NSInteger)bitrate framerate:(NSInteger)framerate height:(NSInteger)height width:(NSInteger)width onscreenControls:(NSInteger)onscreenControls {
+- (void) saveSettingsWithBitrate:(NSInteger)bitrate
+ framerate:(NSInteger)framerate
+ height:(NSInteger)height
+ width:(NSInteger)width
+ audioConfig:(NSInteger)audioConfig
+ onscreenControls:(NSInteger)onscreenControls
+ optimizeGames:(BOOL)optimizeGames
+ multiController:(BOOL)multiController
+ swapABXYButtons:(BOOL)swapABXYButtons
+ audioOnPC:(BOOL)audioOnPC
+ preferredCodec:(uint32_t)preferredCodec
+ useFramePacing:(BOOL)useFramePacing
+ enableHdr:(BOOL)enableHdr
+ btMouseSupport:(BOOL)btMouseSupport
+ absoluteTouchMode:(BOOL)absoluteTouchMode
+ statsOverlay:(BOOL)statsOverlay {
[_managedObjectContext performBlockAndWait:^{
Settings* settingsToSave = [self retrieveSettings];
settingsToSave.framerate = [NSNumber numberWithInteger:framerate];
- // Bitrate is persisted in kbps
settingsToSave.bitrate = [NSNumber numberWithInteger:bitrate];
settingsToSave.height = [NSNumber numberWithInteger:height];
settingsToSave.width = [NSNumber numberWithInteger:width];
+ settingsToSave.audioConfig = [NSNumber numberWithInteger:audioConfig];
settingsToSave.onscreenControls = [NSNumber numberWithInteger:onscreenControls];
+ settingsToSave.optimizeGames = optimizeGames;
+ settingsToSave.multiController = multiController;
+ settingsToSave.swapABXYButtons = swapABXYButtons;
+ settingsToSave.playAudioOnPC = audioOnPC;
+ settingsToSave.preferredCodec = preferredCodec;
+ settingsToSave.useFramePacing = useFramePacing;
+ settingsToSave.enableHdr = enableHdr;
+ settingsToSave.btMouseSupport = btMouseSupport;
+ settingsToSave.absoluteTouchMode = absoluteTouchMode;
+ settingsToSave.statsOverlay = statsOverlay;
[self saveData];
}];
@@ -73,8 +97,8 @@
// Add a new persistent managed object if one doesn't exist
Host* parent = [self getHostForTemporaryHost:host withHostRecords:[self fetchRecords:@"Host"]];
if (parent == nil) {
- NSEntityDescription* entity = [NSEntityDescription entityForName:@"Host" inManagedObjectContext:_managedObjectContext];
- parent = [[Host alloc] initWithEntity:entity insertIntoManagedObjectContext:_managedObjectContext];
+ NSEntityDescription* entity = [NSEntityDescription entityForName:@"Host" inManagedObjectContext:self->_managedObjectContext];
+ parent = [[Host alloc] initWithEntity:entity insertIntoManagedObjectContext:self->_managedObjectContext];
}
// Push changes from the temp host to the persistent one
@@ -98,8 +122,8 @@
// Add a new persistent managed object if one doesn't exist
App* parentApp = [self getAppForTemporaryApp:app withAppRecords:appRecords];
if (parentApp == nil) {
- NSEntityDescription* entity = [NSEntityDescription entityForName:@"App" inManagedObjectContext:_managedObjectContext];
- parentApp = [[App alloc] initWithEntity:entity insertIntoManagedObjectContext:_managedObjectContext];
+ NSEntityDescription* entity = [NSEntityDescription entityForName:@"App" inManagedObjectContext:self->_managedObjectContext];
+ parentApp = [[App alloc] initWithEntity:entity insertIntoManagedObjectContext:self->_managedObjectContext];
}
[app propagateChangesToParent:parentApp withHost:parent];
@@ -113,20 +137,6 @@
}];
}
-- (void) updateIconForExistingApp:(TemporaryApp*)app {
- [_managedObjectContext performBlockAndWait:^{
- App* parentApp = [self getAppForTemporaryApp:app withAppRecords:[self fetchRecords:@"App"]];
- if (parentApp == nil) {
- // The app must exist to be updated
- return;
- }
-
- parentApp.image = app.image;
-
- [self saveData];
- }];
-}
-
- (TemporarySettings*) getSettings {
__block TemporarySettings *tempSettings;
@@ -155,7 +165,7 @@
[_managedObjectContext performBlockAndWait:^{
App* managedApp = [self getAppForTemporaryApp:app withAppRecords:[self fetchRecords:@"App"]];
if (managedApp != nil) {
- [_managedObjectContext deleteObject:managedApp];
+ [self->_managedObjectContext deleteObject:managedApp];
[self saveData];
}
}];
@@ -165,7 +175,7 @@
[_managedObjectContext performBlockAndWait:^{
Host* managedHost = [self getHostForTemporaryHost:host withHostRecords:[self fetchRecords:@"Host"]];
if (managedHost != nil) {
- [_managedObjectContext deleteObject:managedHost];
+ [self->_managedObjectContext deleteObject:managedHost];
[self saveData];
}
}];
@@ -173,7 +183,7 @@
- (void) saveData {
NSError* error;
- if (![_managedObjectContext save:&error]) {
+ if ([_managedObjectContext hasChanges] && ![_managedObjectContext save:&error]) {
Log(LOG_E, @"Unable to save hosts to database: %@", error);
}
@@ -209,7 +219,7 @@
- (App*) getAppForTemporaryApp:(TemporaryApp*)tempApp withAppRecords:(NSArray*)apps {
for (App* app in apps) {
if ([app.id isEqualToString:tempApp.id] &&
- [app.host.uuid isEqualToString:app.host.uuid]) {
+ [app.host.uuid isEqualToString:tempApp.host.uuid]) {
return app;
}
}
diff --git a/Limelight/Database/Host+CoreDataProperties.h b/Limelight/Database/Host+CoreDataProperties.h
deleted file mode 100644
index 8e22d42..0000000
--- a/Limelight/Database/Host+CoreDataProperties.h
+++ /dev/null
@@ -1,38 +0,0 @@
-//
-// Host+CoreDataProperties.h
-// Moonlight
-//
-// Created by Diego Waxemberg on 7/10/15.
-// Copyright © 2015 Limelight Stream. All rights reserved.
-//
-// Delete this file and regenerate it using "Create NSManagedObject Subclass…"
-// to keep your implementation up to date with your model.
-//
-
-#import "Host.h"
-
-NS_ASSUME_NONNULL_BEGIN
-
-@interface Host (CoreDataProperties)
-
-@property (nullable, nonatomic, retain) NSString *address;
-@property (nullable, nonatomic, retain) NSString *externalAddress;
-@property (nullable, nonatomic, retain) NSString *localAddress;
-@property (nullable, nonatomic, retain) NSString *mac;
-@property (nullable, nonatomic, retain) NSString *name;
-@property (nullable, nonatomic, retain) NSNumber *pairState;
-@property (nullable, nonatomic, retain) NSString *uuid;
-@property (nullable, nonatomic, retain) NSSet *appList;
-
-@end
-
-@interface Host (CoreDataGeneratedAccessors)
-
-- (void)addAppListObject:(NSManagedObject *)value;
-- (void)removeAppListObject:(NSManagedObject *)value;
-- (void)addAppList:(NSSet *)values;
-- (void)removeAppList:(NSSet *)values;
-
-@end
-
-NS_ASSUME_NONNULL_END
diff --git a/Limelight/Database/Host.h b/Limelight/Database/Host.h
deleted file mode 100644
index d2cb19a..0000000
--- a/Limelight/Database/Host.h
+++ /dev/null
@@ -1,17 +0,0 @@
-//
-// Host.h
-// Moonlight
-//
-// Created by Diego Waxemberg on 7/10/15.
-// Copyright © 2015 Limelight Stream. All rights reserved.
-//
-
-#import
-#import
-#import "Utils.h"
-
-@interface Host : NSManagedObject
-
-@end
-
-#import "Host+CoreDataProperties.h"
diff --git a/Limelight/Database/Host.m b/Limelight/Database/Host.m
deleted file mode 100644
index bc71c1c..0000000
--- a/Limelight/Database/Host.m
+++ /dev/null
@@ -1,13 +0,0 @@
-//
-// Host.m
-// Moonlight
-//
-// Created by Diego Waxemberg on 7/10/15.
-// Copyright © 2015 Limelight Stream. All rights reserved.
-//
-
-#import "Host.h"
-
-@implementation Host
-
-@end
diff --git a/Limelight/Database/Settings.h b/Limelight/Database/Settings.h
deleted file mode 100644
index 39aaa5b..0000000
--- a/Limelight/Database/Settings.h
+++ /dev/null
@@ -1,22 +0,0 @@
-//
-// Settings.h
-// Moonlight
-//
-// Created by Diego Waxemberg on 10/28/14.
-// Copyright (c) 2014 Moonlight Stream. All rights reserved.
-//
-
-#import
-#import
-
-
-@interface Settings : NSManagedObject
-
-@property (nonatomic, retain) NSNumber * bitrate;
-@property (nonatomic, retain) NSNumber * framerate;
-@property (nonatomic, retain) NSNumber * height;
-@property (nonatomic, retain) NSNumber * width;
-@property (nonatomic, retain) NSNumber * onscreenControls;
-@property (nonatomic, retain) NSString * uniqueId;
-
-@end
diff --git a/Limelight/Database/Settings.m b/Limelight/Database/Settings.m
deleted file mode 100644
index 0ad9ad8..0000000
--- a/Limelight/Database/Settings.m
+++ /dev/null
@@ -1,21 +0,0 @@
-//
-// Settings.m
-// Moonlight
-//
-// Created by Diego Waxemberg on 10/28/14.
-// Copyright (c) 2014 Moonlight Stream. All rights reserved.
-//
-
-#import "Settings.h"
-
-
-@implementation Settings
-
-@dynamic bitrate;
-@dynamic framerate;
-@dynamic height;
-@dynamic width;
-@dynamic onscreenControls;
-@dynamic uniqueId;
-
-@end
diff --git a/Limelight/Database/TemporaryApp.h b/Limelight/Database/TemporaryApp.h
index 15a8208..0a205fc 100644
--- a/Limelight/Database/TemporaryApp.h
+++ b/Limelight/Database/TemporaryApp.h
@@ -6,15 +6,16 @@
// Copyright © 2015 Moonlight Stream. All rights reserved.
//
-#import
#import "TemporaryHost.h"
-#import "App.h"
+#import "App+CoreDataClass.h"
@interface TemporaryApp : NSObject
@property (nullable, nonatomic, retain) NSString *id;
-@property (nullable, nonatomic, retain) NSData *image;
@property (nullable, nonatomic, retain) NSString *name;
+@property (nullable, nonatomic, retain) NSString *installPath;
+@property (nonatomic) BOOL hdrSupported;
+@property (nonatomic) BOOL hidden;
@property (nullable, nonatomic, retain) TemporaryHost *host;
NS_ASSUME_NONNULL_BEGIN
diff --git a/Limelight/Database/TemporaryApp.m b/Limelight/Database/TemporaryApp.m
index 97ef8e7..b3de384 100644
--- a/Limelight/Database/TemporaryApp.m
+++ b/Limelight/Database/TemporaryApp.m
@@ -7,7 +7,6 @@
//
#import "TemporaryApp.h"
-#import "App.h"
@implementation TemporaryApp
@@ -15,8 +14,9 @@
self = [self init];
self.id = app.id;
- self.image = app.image;
self.name = app.name;
+ self.hdrSupported = app.hdrSupported;
+ self.hidden = app.hidden;
self.host = tempHost;
return self;
@@ -25,6 +25,8 @@
- (void) propagateChangesToParent:(App*)parent withHost:(Host*)host {
parent.id = self.id;
parent.name = self.name;
+ parent.hdrSupported = self.hdrSupported;
+ parent.hidden = self.hidden;
parent.host = host;
}
@@ -41,7 +43,7 @@
return YES;
}
- if (![object isKindOfClass:[App class]]) {
+ if (![object isKindOfClass:[self class]]) {
return NO;
}
diff --git a/Limelight/Database/TemporaryHost.h b/Limelight/Database/TemporaryHost.h
index ffb5cdc..3cf5110 100644
--- a/Limelight/Database/TemporaryHost.h
+++ b/Limelight/Database/TemporaryHost.h
@@ -6,26 +6,31 @@
// Copyright © 2015 Moonlight Stream. All rights reserved.
//
-#import
#import "Utils.h"
-#import "Host.h"
+#import "Host+CoreDataClass.h"
@interface TemporaryHost : NSObject
-@property (nonatomic) BOOL online;
-@property (nonatomic) PairState pairState;
-@property (nonatomic, nullable) NSString * activeAddress;
-@property (nonatomic, nullable) NSString * currentGame;
+@property (atomic) State state;
+@property (atomic) PairState pairState;
+@property (atomic, nullable, retain) NSString * activeAddress;
+@property (atomic, nullable, retain) NSString * currentGame;
+@property (atomic) unsigned short httpsPort;
+@property (atomic) BOOL isNvidiaServerSoftware;
+
+@property (atomic, nullable, retain) NSData *serverCert;
+@property (atomic, nullable, retain) NSString *address;
+@property (atomic, nullable, retain) NSString *externalAddress;
+@property (atomic, nullable, retain) NSString *localAddress;
+@property (atomic, nullable, retain) NSString *ipv6Address;
+@property (atomic, nullable, retain) NSString *mac;
+@property (atomic) int serverCodecModeSupport;
NS_ASSUME_NONNULL_BEGIN
-@property (nonatomic, retain) NSString *address;
-@property (nonatomic, retain) NSString *externalAddress;
-@property (nonatomic, retain) NSString *localAddress;
-@property (nonatomic, retain) NSString *mac;
-@property (nonatomic, retain) NSString *name;
-@property (nonatomic, retain) NSString *uuid;
-@property (nonatomic, retain) NSMutableSet *appList;
+@property (atomic, retain) NSString *name;
+@property (atomic, retain) NSString *uuid;
+@property (atomic, retain) NSSet *appList;
- (id) initFromHost:(Host*)host;
diff --git a/Limelight/Database/TemporaryHost.m b/Limelight/Database/TemporaryHost.m
index 31c4d98..c71d7ab 100644
--- a/Limelight/Database/TemporaryHost.m
+++ b/Limelight/Database/TemporaryHost.m
@@ -8,9 +8,7 @@
#import "DataManager.h"
#import "TemporaryHost.h"
-#import "Host.h"
#import "TemporaryApp.h"
-#import "App.h"
@implementation TemporaryHost
@@ -18,6 +16,7 @@
self = [super init];
self.appList = [[NSMutableSet alloc] init];
self.currentGame = @"0";
+ self.state = StateUnknown;
return self;
}
@@ -28,10 +27,20 @@
self.address = host.address;
self.externalAddress = host.externalAddress;
self.localAddress = host.localAddress;
+ self.ipv6Address = host.ipv6Address;
self.mac = host.mac;
self.name = host.name;
self.uuid = host.uuid;
- self.pairState = [host.pairState intValue];
+ self.serverCodecModeSupport = host.serverCodecModeSupport;
+ self.serverCert = host.serverCert;
+
+ // Older clients stored a non-URL-escaped IPv6 string. Try to detect that and fix it up.
+ if (![self.ipv6Address containsString:@"["]) {
+ self.ipv6Address = [Utils addressAndPortToAddressPortString:self.ipv6Address port:47989];
+ }
+
+ // Ensure we don't use a stale cached pair state if we haven't pinned the cert yet
+ self.pairState = host.serverCert ? [host.pairState intValue] : PairStateUnpaired;
NSMutableSet *appList = [[NSMutableSet alloc] init];
@@ -46,12 +55,30 @@
}
- (void) propagateChangesToParent:(Host*)parentHost {
- parentHost.address = self.address;
- parentHost.externalAddress = self.externalAddress;
- parentHost.localAddress = self.localAddress;
- parentHost.mac = self.mac;
+ // Avoid overwriting existing data with nil if
+ // we don't have everything populated in the temporary
+ // host.
+ if (self.address != nil) {
+ parentHost.address = self.address;
+ }
+ if (self.externalAddress != nil) {
+ parentHost.externalAddress = self.externalAddress;
+ }
+ if (self.localAddress != nil) {
+ parentHost.localAddress = self.localAddress;
+ }
+ if (self.ipv6Address != nil) {
+ parentHost.ipv6Address = self.ipv6Address;
+ }
+ if (self.mac != nil) {
+ parentHost.mac = self.mac;
+ }
+ if (self.serverCert != nil) {
+ parentHost.serverCert = self.serverCert;
+ }
parentHost.name = self.name;
parentHost.uuid = self.uuid;
+ parentHost.serverCodecModeSupport = self.serverCodecModeSupport;
parentHost.pairState = [NSNumber numberWithInt:self.pairState];
}
@@ -68,7 +95,7 @@
return YES;
}
- if (![object isKindOfClass:[Host class]]) {
+ if (![object isKindOfClass:[self class]]) {
return NO;
}
diff --git a/Limelight/Database/TemporarySettings.h b/Limelight/Database/TemporarySettings.h
index 2228912..da35b30 100644
--- a/Limelight/Database/TemporarySettings.h
+++ b/Limelight/Database/TemporarySettings.h
@@ -6,8 +6,7 @@
// Copyright © 2015 Moonlight Stream. All rights reserved.
//
-#import
-#import "Settings.h"
+#import "Settings+CoreDataClass.h"
@interface TemporarySettings : NSObject
@@ -17,8 +16,24 @@
@property (nonatomic, retain) NSNumber * framerate;
@property (nonatomic, retain) NSNumber * height;
@property (nonatomic, retain) NSNumber * width;
+@property (nonatomic, retain) NSNumber * audioConfig;
@property (nonatomic, retain) NSNumber * onscreenControls;
@property (nonatomic, retain) NSString * uniqueId;
+@property (nonatomic) enum {
+ CODEC_PREF_AUTO,
+ CODEC_PREF_H264,
+ CODEC_PREF_HEVC,
+ CODEC_PREF_AV1,
+} preferredCodec;
+@property (nonatomic) BOOL useFramePacing;
+@property (nonatomic) BOOL multiController;
+@property (nonatomic) BOOL swapABXYButtons;
+@property (nonatomic) BOOL playAudioOnPC;
+@property (nonatomic) BOOL optimizeGames;
+@property (nonatomic) BOOL enableHdr;
+@property (nonatomic) BOOL btMouseSupport;
+@property (nonatomic) BOOL absoluteTouchMode;
+@property (nonatomic) BOOL statsOverlay;
- (id) initFromSettings:(Settings*)settings;
diff --git a/Limelight/Database/TemporarySettings.m b/Limelight/Database/TemporarySettings.m
index 4b7437f..e627e0c 100644
--- a/Limelight/Database/TemporarySettings.m
+++ b/Limelight/Database/TemporarySettings.m
@@ -7,6 +7,7 @@
//
#import "TemporarySettings.h"
+#import "OnScreenControls.h"
@implementation TemporarySettings
@@ -15,11 +16,76 @@
self.parent = settings;
+#if TARGET_OS_TV
+ // Apply default values from our Root.plist
+ NSString* settingsBundle = [[NSBundle mainBundle] pathForResource:@"Settings" ofType:@"bundle"];
+ NSDictionary* settingsData = [NSDictionary dictionaryWithContentsOfFile:[settingsBundle stringByAppendingPathComponent:@"Root.plist"]];
+ NSArray* preferences = [settingsData objectForKey:@"PreferenceSpecifiers"];
+ NSMutableDictionary* defaultsToRegister = [[NSMutableDictionary alloc] initWithCapacity:[preferences count]];
+ for (NSDictionary* prefSpecification in preferences) {
+ NSString* key = [prefSpecification objectForKey:@"Key"];
+ if (key != nil) {
+ [defaultsToRegister setObject:[prefSpecification objectForKey:@"DefaultValue"] forKey:key];
+ }
+ }
+ [[NSUserDefaults standardUserDefaults] registerDefaults:defaultsToRegister];
+
+ self.bitrate = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"bitrate"]];
+ assert([self.bitrate intValue] != 0);
+ self.framerate = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"framerate"]];
+ assert([self.framerate intValue] != 0);
+ self.audioConfig = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"audioConfig"]];
+ assert([self.audioConfig intValue] != 0);
+ self.preferredCodec = (typeof(self.preferredCodec))[[NSUserDefaults standardUserDefaults] integerForKey:@"preferredCodec"];
+ self.useFramePacing = [[NSUserDefaults standardUserDefaults] integerForKey:@"useFramePacing"] != 0;
+ self.playAudioOnPC = [[NSUserDefaults standardUserDefaults] boolForKey:@"audioOnPC"];
+ self.enableHdr = [[NSUserDefaults standardUserDefaults] boolForKey:@"enableHdr"];
+ self.optimizeGames = [[NSUserDefaults standardUserDefaults] boolForKey:@"optimizeGames"];
+ self.multiController = [[NSUserDefaults standardUserDefaults] boolForKey:@"multipleControllers"];
+ self.swapABXYButtons = [[NSUserDefaults standardUserDefaults] boolForKey:@"swapABXYButtons"];
+ self.btMouseSupport = [[NSUserDefaults standardUserDefaults] boolForKey:@"btMouseSupport"];
+ self.statsOverlay = [[NSUserDefaults standardUserDefaults] boolForKey:@"statsOverlay"];
+
+ NSInteger _screenSize = [[NSUserDefaults standardUserDefaults] integerForKey:@"streamResolution"];
+ switch (_screenSize) {
+ case 0:
+ self.height = [NSNumber numberWithInteger:720];
+ self.width = [NSNumber numberWithInteger:1280];
+ break;
+ case 1:
+ self.height = [NSNumber numberWithInteger:1080];
+ self.width = [NSNumber numberWithInteger:1920];
+ break;
+ case 2:
+ self.height = [NSNumber numberWithInteger:2160];
+ self.width = [NSNumber numberWithInteger:3840];
+ break;
+ case 3:
+ self.height = [NSNumber numberWithInteger:1440];
+ self.width = [NSNumber numberWithInteger:2560];
+ break;
+ default:
+ abort();
+ }
+ self.onscreenControls = [NSNumber numberWithInteger:OnScreenControlsLevelOff];
+#else
self.bitrate = settings.bitrate;
self.framerate = settings.framerate;
self.height = settings.height;
self.width = settings.width;
+ self.audioConfig = settings.audioConfig;
+ self.preferredCodec = settings.preferredCodec;
+ self.useFramePacing = settings.useFramePacing;
+ self.playAudioOnPC = settings.playAudioOnPC;
+ self.enableHdr = settings.enableHdr;
+ self.optimizeGames = settings.optimizeGames;
+ self.multiController = settings.multiController;
+ self.swapABXYButtons = settings.swapABXYButtons;
self.onscreenControls = settings.onscreenControls;
+ self.btMouseSupport = settings.btMouseSupport;
+ self.absoluteTouchMode = settings.absoluteTouchMode;
+ self.statsOverlay = settings.statsOverlay;
+#endif
self.uniqueId = settings.uniqueId;
return self;
diff --git a/Limelight/Font/Roboto/Apache License.txt b/Limelight/Font/Roboto/Apache License.txt
deleted file mode 100755
index 63d5302..0000000
--- a/Limelight/Font/Roboto/Apache License.txt
+++ /dev/null
@@ -1,203 +0,0 @@
-Font data copyright Google 2012
-
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
\ No newline at end of file
diff --git a/Limelight/Font/Roboto/Roboto-Black.ttf b/Limelight/Font/Roboto/Roboto-Black.ttf
deleted file mode 100755
index 71f01ac..0000000
Binary files a/Limelight/Font/Roboto/Roboto-Black.ttf and /dev/null differ
diff --git a/Limelight/Font/Roboto/Roboto-Bold.ttf b/Limelight/Font/Roboto/Roboto-Bold.ttf
deleted file mode 100755
index aaf374d..0000000
Binary files a/Limelight/Font/Roboto/Roboto-Bold.ttf and /dev/null differ
diff --git a/Limelight/Font/Roboto/Roboto-Light.ttf b/Limelight/Font/Roboto/Roboto-Light.ttf
deleted file mode 100755
index 664e1b2..0000000
Binary files a/Limelight/Font/Roboto/Roboto-Light.ttf and /dev/null differ
diff --git a/Limelight/Font/Roboto/Roboto-Medium.ttf b/Limelight/Font/Roboto/Roboto-Medium.ttf
deleted file mode 100755
index aa00de0..0000000
Binary files a/Limelight/Font/Roboto/Roboto-Medium.ttf and /dev/null differ
diff --git a/Limelight/Font/Roboto/Roboto-Regular.ttf b/Limelight/Font/Roboto/Roboto-Regular.ttf
deleted file mode 100755
index 3e6e2e7..0000000
Binary files a/Limelight/Font/Roboto/Roboto-Regular.ttf and /dev/null differ
diff --git a/Limelight/Font/Roboto/Roboto-Thin.ttf b/Limelight/Font/Roboto/Roboto-Thin.ttf
deleted file mode 100755
index d262d14..0000000
Binary files a/Limelight/Font/Roboto/Roboto-Thin.ttf and /dev/null differ
diff --git a/Limelight/Images.xcassets/AddComputerIcon.imageset/Contents.json b/Limelight/Images.xcassets/AddComputerIcon.imageset/Contents.json
deleted file mode 100644
index 159ec2c..0000000
--- a/Limelight/Images.xcassets/AddComputerIcon.imageset/Contents.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "images" : [
- {
- "idiom" : "universal",
- "scale" : "1x",
- "filename" : "limelight_computer_add_icon_1x.png"
- },
- {
- "idiom" : "universal",
- "scale" : "2x",
- "filename" : "limelight_computer_add_icon_2x.png"
- },
- {
- "idiom" : "universal",
- "scale" : "3x",
- "filename" : "limelight_computer_add_icon_3x.png"
- }
- ],
- "info" : {
- "version" : 1,
- "author" : "xcode"
- }
-}
\ No newline at end of file
diff --git a/Limelight/Images.xcassets/AddComputerIcon.imageset/limelight_computer_add_icon_1x.png b/Limelight/Images.xcassets/AddComputerIcon.imageset/limelight_computer_add_icon_1x.png
deleted file mode 100644
index 1c3d2db..0000000
Binary files a/Limelight/Images.xcassets/AddComputerIcon.imageset/limelight_computer_add_icon_1x.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/AddComputerIcon.imageset/limelight_computer_add_icon_2x.png b/Limelight/Images.xcassets/AddComputerIcon.imageset/limelight_computer_add_icon_2x.png
deleted file mode 100644
index 661eddd..0000000
Binary files a/Limelight/Images.xcassets/AddComputerIcon.imageset/limelight_computer_add_icon_2x.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/AddComputerIcon.imageset/limelight_computer_add_icon_3x.png b/Limelight/Images.xcassets/AddComputerIcon.imageset/limelight_computer_add_icon_3x.png
deleted file mode 100644
index 9323db1..0000000
Binary files a/Limelight/Images.xcassets/AddComputerIcon.imageset/limelight_computer_add_icon_3x.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/Logo.imageset/Contents.json b/Limelight/Images.xcassets/AddOverlayIcon.imageset/Contents.json
similarity index 89%
rename from Limelight/Images.xcassets/Logo.imageset/Contents.json
rename to Limelight/Images.xcassets/AddOverlayIcon.imageset/Contents.json
index 7be9c80..813ddd3 100644
--- a/Limelight/Images.xcassets/Logo.imageset/Contents.json
+++ b/Limelight/Images.xcassets/AddOverlayIcon.imageset/Contents.json
@@ -2,11 +2,11 @@
"images" : [
{
"idiom" : "universal",
+ "filename" : "add.pdf",
"scale" : "1x"
},
{
"idiom" : "universal",
- "filename" : "logo.png",
"scale" : "2x"
},
{
diff --git a/Limelight/Images.xcassets/AddOverlayIcon.imageset/add.pdf b/Limelight/Images.xcassets/AddOverlayIcon.imageset/add.pdf
new file mode 100644
index 0000000..78b6f2f
--- /dev/null
+++ b/Limelight/Images.xcassets/AddOverlayIcon.imageset/add.pdf
@@ -0,0 +1,68 @@
+%PDF-1.5
+%µí®û
+4 0 obj
+<< /Length 5 0 R
+ /Filter /FlateDecode
+>>
+stream
+xœ]P[à ûÏ)r�1}°cìÒº�öcÛý¥AèZ!%q였°/ç"%Èè9oô&áz>_ž—/%qaä!ºyä�§T3ÐjqµîaÚ”t„,~Dû›ÖÚGHýôzQõ
®î:xgµ†I6Âß:gP(( hñ¯-1Îü5ç¶Žn3»!&úÖ"˜B-Õ¨~1–@½ZÒ¹](¡¿d÷ë9ا-žt§å…aL
+endstream
+endobj
+5 0 obj
+ 183
+endobj
+3 0 obj
+<<
+ /ExtGState <<
+ /a0 << /CA 1 /ca 1 >>
+ >>
+>>
+endobj
+2 0 obj
+<< /Type /Page % 1
+ /Parent 1 0 R
+ /MediaBox [ 0 0 150 150 ]
+ /Contents 4 0 R
+ /Group <<
+ /Type /Group
+ /S /Transparency
+ /I true
+ /CS /DeviceRGB
+ >>
+ /Resources 3 0 R
+>>
+endobj
+1 0 obj
+<< /Type /Pages
+ /Kids [ 2 0 R ]
+ /Count 1
+>>
+endobj
+6 0 obj
+<< /Producer (cairo 1.16.0 (https://cairographics.org))
+ /CreationDate (D:20190922201812-07'00)
+>>
+endobj
+7 0 obj
+<< /Type /Catalog
+ /Pages 1 0 R
+>>
+endobj
+xref
+0 8
+0000000000 65535 f
+0000000587 00000 n
+0000000369 00000 n
+0000000297 00000 n
+0000000015 00000 n
+0000000275 00000 n
+0000000652 00000 n
+0000000768 00000 n
+trailer
+<< /Size 8
+ /Root 7 0 R
+ /Info 6 0 R
+>>
+startxref
+820
+%%EOF
diff --git a/Limelight/Images.xcassets/AltIcon.imageset/AltIcon.png b/Limelight/Images.xcassets/AltIcon.imageset/AltIcon.png
new file mode 100644
index 0000000..222f643
Binary files /dev/null and b/Limelight/Images.xcassets/AltIcon.imageset/AltIcon.png differ
diff --git a/Limelight/Images.xcassets/AltIcon.imageset/Contents.json b/Limelight/Images.xcassets/AltIcon.imageset/Contents.json
new file mode 100644
index 0000000..29a9ae7
--- /dev/null
+++ b/Limelight/Images.xcassets/AltIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "AltIcon.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Limelight/Images.xcassets/Computer.imageset/Computer.pdf b/Limelight/Images.xcassets/Computer.imageset/Computer.pdf
new file mode 100644
index 0000000..9aa1572
--- /dev/null
+++ b/Limelight/Images.xcassets/Computer.imageset/Computer.pdf
@@ -0,0 +1,71 @@
+%PDF-1.5
+%µí®û
+4 0 obj
+<< /Length 5 0 R
+ /Filter /FlateDecode
+>>
+stream
+xœ]‘[ƒ Eÿ³Šl )y¸Œ.¡ÃTû¡µûŸi€ Õa9×›�ÑȺ±l.zÌ+|ÀTm›ñþ48a”ä_°äÑ&&ë‘SØ^8cYÕÍÅíl"‹«Æ5ºá‚v¤�’"{
+ò<9¶�j�7Ö!Db ×,Ú�–PÊbåÐjt¤•øzÅÚœÔ:ƒº,'á½â‚ýÒ‡ wRÁù‘Ê|µ8)’w
+-{GÍÑsµ™�æüÞG^’íosû»£MÅkäøà=h_
+endstream
+endobj
+5 0 obj
+ 214
+endobj
+3 0 obj
+<<
+ /ExtGState <<
+ /a0 << /CA 0.3 /ca 0.3 >>
+ /a1 << /CA 1 /ca 1 >>
+ >>
+>>
+endobj
+2 0 obj
+<< /Type /Page % 1
+ /Parent 1 0 R
+ /MediaBox [ 0 0 375 375 ]
+ /Contents 4 0 R
+ /Group <<
+ /Type /Group
+ /S /Transparency
+ /I true
+ /CS /DeviceRGB
+ >>
+ /Resources 3 0 R
+>>
+endobj
+1 0 obj
+<< /Type /Pages
+ /Kids [ 2 0 R ]
+ /Count 1
+>>
+endobj
+6 0 obj
+<< /Producer (cairo 1.16.0 (https://cairographics.org))
+ /CreationDate (D:20190922201805-07'00)
+>>
+endobj
+7 0 obj
+<< /Type /Catalog
+ /Pages 1 0 R
+>>
+endobj
+xref
+0 8
+0000000000 65535 f
+0000000650 00000 n
+0000000432 00000 n
+0000000328 00000 n
+0000000015 00000 n
+0000000306 00000 n
+0000000715 00000 n
+0000000831 00000 n
+trailer
+<< /Size 8
+ /Root 7 0 R
+ /Info 6 0 R
+>>
+startxref
+883
+%%EOF
diff --git a/Limelight/Images.xcassets/Computer.imageset/Contents.json b/Limelight/Images.xcassets/Computer.imageset/Contents.json
index 3a06eed..ef963c3 100644
--- a/Limelight/Images.xcassets/Computer.imageset/Contents.json
+++ b/Limelight/Images.xcassets/Computer.imageset/Contents.json
@@ -2,18 +2,16 @@
"images" : [
{
"idiom" : "universal",
- "scale" : "1x",
- "filename" : "limelight_computer_1x.png"
+ "filename" : "Computer.pdf",
+ "scale" : "1x"
},
{
"idiom" : "universal",
- "scale" : "2x",
- "filename" : "limelight_computer_2x.png"
+ "scale" : "2x"
},
{
"idiom" : "universal",
- "scale" : "3x",
- "filename" : "limelight_computer_3x.png"
+ "scale" : "3x"
}
],
"info" : {
diff --git a/Limelight/Images.xcassets/Computer.imageset/limelight_computer_1x.png b/Limelight/Images.xcassets/Computer.imageset/limelight_computer_1x.png
deleted file mode 100644
index 7c2e71f..0000000
Binary files a/Limelight/Images.xcassets/Computer.imageset/limelight_computer_1x.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/Computer.imageset/limelight_computer_2x.png b/Limelight/Images.xcassets/Computer.imageset/limelight_computer_2x.png
deleted file mode 100644
index 25468de..0000000
Binary files a/Limelight/Images.xcassets/Computer.imageset/limelight_computer_2x.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/Computer.imageset/limelight_computer_3x.png b/Limelight/Images.xcassets/Computer.imageset/limelight_computer_3x.png
deleted file mode 100644
index e93d417..0000000
Binary files a/Limelight/Images.xcassets/Computer.imageset/limelight_computer_3x.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/Contents.json b/Limelight/Images.xcassets/Contents.json
index da4a164..73c0059 100644
--- a/Limelight/Images.xcassets/Contents.json
+++ b/Limelight/Images.xcassets/Contents.json
@@ -1,6 +1,6 @@
{
"info" : {
- "version" : 1,
- "author" : "xcode"
+ "author" : "xcode",
+ "version" : 1
}
-}
\ No newline at end of file
+}
diff --git a/Limelight/Images.xcassets/ControlIcon.imageset/Contents.json b/Limelight/Images.xcassets/ControlIcon.imageset/Contents.json
new file mode 100644
index 0000000..24227b3
--- /dev/null
+++ b/Limelight/Images.xcassets/ControlIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "ControlIcon.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Limelight/Images.xcassets/ControlIcon.imageset/ControlIcon.png b/Limelight/Images.xcassets/ControlIcon.imageset/ControlIcon.png
new file mode 100644
index 0000000..48433dd
Binary files /dev/null and b/Limelight/Images.xcassets/ControlIcon.imageset/ControlIcon.png differ
diff --git a/Limelight/Images.xcassets/DeleteIcon.imageset/Contents.json b/Limelight/Images.xcassets/DeleteIcon.imageset/Contents.json
new file mode 100644
index 0000000..09d0484
--- /dev/null
+++ b/Limelight/Images.xcassets/DeleteIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "DeleteIcon.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Limelight/Images.xcassets/DeleteIcon.imageset/DeleteIcon.png b/Limelight/Images.xcassets/DeleteIcon.imageset/DeleteIcon.png
new file mode 100644
index 0000000..3a3e323
Binary files /dev/null and b/Limelight/Images.xcassets/DeleteIcon.imageset/DeleteIcon.png differ
diff --git a/Limelight/Images.xcassets/DoneIcon.imageset/Contents.json b/Limelight/Images.xcassets/DoneIcon.imageset/Contents.json
new file mode 100644
index 0000000..9229c64
--- /dev/null
+++ b/Limelight/Images.xcassets/DoneIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "doneIcon.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Limelight/Images.xcassets/DoneIcon.imageset/doneIcon.png b/Limelight/Images.xcassets/DoneIcon.imageset/doneIcon.png
new file mode 100644
index 0000000..f4d178f
Binary files /dev/null and b/Limelight/Images.xcassets/DoneIcon.imageset/doneIcon.png differ
diff --git a/Limelight/Images.xcassets/PullArrow.imageset/Contents.json b/Limelight/Images.xcassets/ErrorOverlayIcon.imageset/Contents.json
similarity index 63%
rename from Limelight/Images.xcassets/PullArrow.imageset/Contents.json
rename to Limelight/Images.xcassets/ErrorOverlayIcon.imageset/Contents.json
index 3e235a1..66c6dba 100644
--- a/Limelight/Images.xcassets/PullArrow.imageset/Contents.json
+++ b/Limelight/Images.xcassets/ErrorOverlayIcon.imageset/Contents.json
@@ -2,17 +2,15 @@
"images" : [
{
"idiom" : "universal",
- "filename" : "pull-arrow-light-14x31@1x.png",
+ "filename" : "error.pdf",
"scale" : "1x"
},
{
"idiom" : "universal",
- "filename" : "pull-arrow-light-28x61@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
- "filename" : "pull-arrow-light-42x92@3x.png",
"scale" : "3x"
}
],
diff --git a/Limelight/Images.xcassets/ErrorOverlayIcon.imageset/error.pdf b/Limelight/Images.xcassets/ErrorOverlayIcon.imageset/error.pdf
new file mode 100644
index 0000000..f024de8
--- /dev/null
+++ b/Limelight/Images.xcassets/ErrorOverlayIcon.imageset/error.pdf
@@ -0,0 +1,68 @@
+%PDF-1.5
+%µí®û
+4 0 obj
+<< /Length 5 0 R
+ /Filter /FlateDecode
+>>
+stream
+xœUMA€ »ïý€¸� ßð ÆD=ÈAý"L4š&[»µ›€3ÉE>
+ >>
+>>
+endobj
+2 0 obj
+<< /Type /Page % 1
+ /Parent 1 0 R
+ /MediaBox [ 0 0 150 150 ]
+ /Contents 4 0 R
+ /Group <<
+ /Type /Group
+ /S /Transparency
+ /I true
+ /CS /DeviceRGB
+ >>
+ /Resources 3 0 R
+>>
+endobj
+1 0 obj
+<< /Type /Pages
+ /Kids [ 2 0 R ]
+ /Count 1
+>>
+endobj
+6 0 obj
+<< /Producer (cairo 1.16.0 (https://cairographics.org))
+ /CreationDate (D:20190922201819-07'00)
+>>
+endobj
+7 0 obj
+<< /Type /Catalog
+ /Pages 1 0 R
+>>
+endobj
+xref
+0 8
+0000000000 65535 f
+0000000514 00000 n
+0000000296 00000 n
+0000000224 00000 n
+0000000015 00000 n
+0000000202 00000 n
+0000000579 00000 n
+0000000695 00000 n
+trailer
+<< /Size 8
+ /Root 7 0 R
+ /Info 6 0 R
+>>
+startxref
+747
+%%EOF
diff --git a/Limelight/Images.xcassets/EscapeIcon.imageset/Contents.json b/Limelight/Images.xcassets/EscapeIcon.imageset/Contents.json
new file mode 100644
index 0000000..5533897
--- /dev/null
+++ b/Limelight/Images.xcassets/EscapeIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "EscapeIcon.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Limelight/Images.xcassets/EscapeIcon.imageset/EscapeIcon.png b/Limelight/Images.xcassets/EscapeIcon.imageset/EscapeIcon.png
new file mode 100644
index 0000000..262f024
Binary files /dev/null and b/Limelight/Images.xcassets/EscapeIcon.imageset/EscapeIcon.png differ
diff --git a/Limelight/Images.xcassets/L2.imageset/Contents.json b/Limelight/Images.xcassets/L2.imageset/Contents.json
index b2bba89..edd9020 100644
--- a/Limelight/Images.xcassets/L2.imageset/Contents.json
+++ b/Limelight/Images.xcassets/L2.imageset/Contents.json
@@ -2,18 +2,17 @@
"images" : [
{
"idiom" : "universal",
- "scale" : "1x",
- "filename" : "L2_60x60@1x.png"
+ "filename" : "L2_60x60@1x.png",
+ "scale" : "1x"
},
{
"idiom" : "universal",
- "scale" : "2x",
- "filename" : "L2_120x120@2x.png"
+ "filename" : "L2_120x120@2x.png",
+ "scale" : "2x"
},
{
"idiom" : "universal",
- "scale" : "3x",
- "filename" : "L2_180x180@3x.png"
+ "scale" : "3x"
}
],
"info" : {
diff --git a/Limelight/Images.xcassets/L2.imageset/L2_180x180@3x.png b/Limelight/Images.xcassets/L2.imageset/L2_180x180@3x.png
deleted file mode 100644
index 456589c..0000000
Binary files a/Limelight/Images.xcassets/L2.imageset/L2_180x180@3x.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/LockedOverlayIcon.imageset/Contents.json b/Limelight/Images.xcassets/LockedOverlayIcon.imageset/Contents.json
new file mode 100644
index 0000000..cdaf694
--- /dev/null
+++ b/Limelight/Images.xcassets/LockedOverlayIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "lock.pdf",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Limelight/Images.xcassets/LockedOverlayIcon.imageset/lock.pdf b/Limelight/Images.xcassets/LockedOverlayIcon.imageset/lock.pdf
new file mode 100644
index 0000000..fe5b737
Binary files /dev/null and b/Limelight/Images.xcassets/LockedOverlayIcon.imageset/lock.pdf differ
diff --git a/Limelight/Images.xcassets/Logo.imageset/logo.png b/Limelight/Images.xcassets/Logo.imageset/logo.png
deleted file mode 100644
index 8336822..0000000
Binary files a/Limelight/Images.xcassets/Logo.imageset/logo.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/PullArrow.imageset/pull-arrow-light-14x31@1x.png b/Limelight/Images.xcassets/PullArrow.imageset/pull-arrow-light-14x31@1x.png
deleted file mode 100644
index 6a3a11b..0000000
Binary files a/Limelight/Images.xcassets/PullArrow.imageset/pull-arrow-light-14x31@1x.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/PullArrow.imageset/pull-arrow-light-28x61@2x.png b/Limelight/Images.xcassets/PullArrow.imageset/pull-arrow-light-28x61@2x.png
deleted file mode 100644
index 383b416..0000000
Binary files a/Limelight/Images.xcassets/PullArrow.imageset/pull-arrow-light-28x61@2x.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/PullArrow.imageset/pull-arrow-light-42x92@3x.png b/Limelight/Images.xcassets/PullArrow.imageset/pull-arrow-light-42x92@3x.png
deleted file mode 100644
index e701942..0000000
Binary files a/Limelight/Images.xcassets/PullArrow.imageset/pull-arrow-light-42x92@3x.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/R2.imageset/Contents.json b/Limelight/Images.xcassets/R2.imageset/Contents.json
index bbaf1d6..0c1a895 100644
--- a/Limelight/Images.xcassets/R2.imageset/Contents.json
+++ b/Limelight/Images.xcassets/R2.imageset/Contents.json
@@ -2,18 +2,17 @@
"images" : [
{
"idiom" : "universal",
- "scale" : "1x",
- "filename" : "R2_60x60@1x.png"
+ "filename" : "R2_60x60@1x.png",
+ "scale" : "1x"
},
{
"idiom" : "universal",
- "scale" : "2x",
- "filename" : "R2_120x120@2x.png"
+ "filename" : "R2_120x120@2x.png",
+ "scale" : "2x"
},
{
"idiom" : "universal",
- "scale" : "3x",
- "filename" : "R2_120x120@3x.png"
+ "scale" : "3x"
}
],
"info" : {
diff --git a/Limelight/Images.xcassets/R2.imageset/R2_120x120@3x.png b/Limelight/Images.xcassets/R2.imageset/R2_120x120@3x.png
deleted file mode 100644
index 51a40e5..0000000
Binary files a/Limelight/Images.xcassets/R2.imageset/R2_120x120@3x.png and /dev/null differ
diff --git a/Limelight/Images.xcassets/ShiftIcon.imageset/Contents.json b/Limelight/Images.xcassets/ShiftIcon.imageset/Contents.json
new file mode 100644
index 0000000..12d9131
--- /dev/null
+++ b/Limelight/Images.xcassets/ShiftIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "ShiftIcon.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Limelight/Images.xcassets/ShiftIcon.imageset/ShiftIcon.png b/Limelight/Images.xcassets/ShiftIcon.imageset/ShiftIcon.png
new file mode 100644
index 0000000..3e33f4a
Binary files /dev/null and b/Limelight/Images.xcassets/ShiftIcon.imageset/ShiftIcon.png differ
diff --git a/Limelight/Images.xcassets/TabIcon.imageset/Contents.json b/Limelight/Images.xcassets/TabIcon.imageset/Contents.json
new file mode 100644
index 0000000..fd7d96e
--- /dev/null
+++ b/Limelight/Images.xcassets/TabIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "TabIcon.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Limelight/Images.xcassets/TabIcon.imageset/TabIcon.png b/Limelight/Images.xcassets/TabIcon.imageset/TabIcon.png
new file mode 100644
index 0000000..f562b1d
Binary files /dev/null and b/Limelight/Images.xcassets/TabIcon.imageset/TabIcon.png differ
diff --git a/Limelight/Images.xcassets/UpIcon.imageset/Contents.json b/Limelight/Images.xcassets/UpIcon.imageset/Contents.json
new file mode 100644
index 0000000..0f0fff7
--- /dev/null
+++ b/Limelight/Images.xcassets/UpIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "up_arrow.pdf",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Limelight/Images.xcassets/UpIcon.imageset/up_arrow.pdf b/Limelight/Images.xcassets/UpIcon.imageset/up_arrow.pdf
new file mode 100644
index 0000000..1fa5a70
--- /dev/null
+++ b/Limelight/Images.xcassets/UpIcon.imageset/up_arrow.pdf
@@ -0,0 +1,70 @@
+%PDF-1.5
+%µí®û
+4 0 obj
+<< /Length 5 0 R
+ /Filter /FlateDecode
+>>
+stream
+xœUŽQ
+ƒ@Dÿsй€1ÙÍF=†G(…êÇú¡½?-e`ƒÌ$
+ 5Ö¼ZIqh›Ð¾Ó—4qAî¨ó ÓsTdã¹dl>üØ�H… {º‰n|–KÏâé,Ë}°?‡ê5ôª™ÿ^øÐH;U$}
+endstream
+endobj
+5 0 obj
+ 114
+endobj
+3 0 obj
+<<
+ /ExtGState <<
+ /a0 << /CA 1 /ca 1 >>
+ >>
+>>
+endobj
+2 0 obj
+<< /Type /Page % 1
+ /Parent 1 0 R
+ /MediaBox [ 0 0 75 75 ]
+ /Contents 4 0 R
+ /Group <<
+ /Type /Group
+ /S /Transparency
+ /I true
+ /CS /DeviceRGB
+ >>
+ /Resources 3 0 R
+>>
+endobj
+1 0 obj
+<< /Type /Pages
+ /Kids [ 2 0 R ]
+ /Count 1
+>>
+endobj
+6 0 obj
+<< /Producer (cairo 1.16.0 (https://cairographics.org))
+ /CreationDate (D:20190830203401-07'00)
+>>
+endobj
+7 0 obj
+<< /Type /Catalog
+ /Pages 1 0 R
+>>
+endobj
+xref
+0 8
+0000000000 65535 f
+0000000516 00000 n
+0000000300 00000 n
+0000000228 00000 n
+0000000015 00000 n
+0000000206 00000 n
+0000000581 00000 n
+0000000697 00000 n
+trailer
+<< /Size 8
+ /Root 7 0 R
+ /Info 6 0 R
+>>
+startxref
+749
+%%EOF
diff --git a/Limelight/Images.xcassets/WindowsIcon.imageset/Contents.json b/Limelight/Images.xcassets/WindowsIcon.imageset/Contents.json
new file mode 100644
index 0000000..778eea3
--- /dev/null
+++ b/Limelight/Images.xcassets/WindowsIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "WindowsIcon.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Limelight/Images.xcassets/WindowsIcon.imageset/WindowsIcon.png b/Limelight/Images.xcassets/WindowsIcon.imageset/WindowsIcon.png
new file mode 100644
index 0000000..60dec54
Binary files /dev/null and b/Limelight/Images.xcassets/WindowsIcon.imageset/WindowsIcon.png differ
diff --git a/Limelight/Input/AbsoluteTouchHandler.h b/Limelight/Input/AbsoluteTouchHandler.h
new file mode 100644
index 0000000..cbdb455
--- /dev/null
+++ b/Limelight/Input/AbsoluteTouchHandler.h
@@ -0,0 +1,19 @@
+//
+// AbsoluteTouchHandler.h
+// Moonlight
+//
+// Created by Cameron Gutman on 11/1/20.
+// Copyright © 2020 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#import "StreamView.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface AbsoluteTouchHandler : UIResponder
+
+-(id)initWithView:(StreamView*)view;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Limelight/Input/AbsoluteTouchHandler.m b/Limelight/Input/AbsoluteTouchHandler.m
new file mode 100644
index 0000000..b9b8c59
--- /dev/null
+++ b/Limelight/Input/AbsoluteTouchHandler.m
@@ -0,0 +1,120 @@
+//
+// AbsoluteTouchHandler.m
+// Moonlight
+//
+// Created by Cameron Gutman on 11/1/20.
+// Copyright © 2020 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#import "AbsoluteTouchHandler.h"
+
+#include
+
+// How long the fingers must be stationary to start a right click
+#define LONG_PRESS_ACTIVATION_DELAY 0.650f
+
+// How far the finger can move before it cancels a right click
+#define LONG_PRESS_ACTIVATION_DELTA 0.01f
+
+// How long the double tap deadzone stays in effect between touch up and touch down
+#define DOUBLE_TAP_DEAD_ZONE_DELAY 0.250f
+
+// How far the finger can move before it can override the double tap deadzone
+#define DOUBLE_TAP_DEAD_ZONE_DELTA 0.025f
+
+@implementation AbsoluteTouchHandler {
+ StreamView* view;
+
+ NSTimer* longPressTimer;
+ UITouch* lastTouchDown;
+ CGPoint lastTouchDownLocation;
+ UITouch* lastTouchUp;
+ CGPoint lastTouchUpLocation;
+}
+
+- (id)initWithView:(StreamView*)view {
+ self = [self init];
+ self->view = view;
+ return self;
+}
+
+- (void)onLongPressStart:(NSTimer*)timer {
+ // Raise the left click and start a right click
+ LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
+ LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_RIGHT);
+}
+
+- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
+ // Ignore touch down events with more than one finger
+ if ([[event allTouches] count] > 1) {
+ return;
+ }
+
+ UITouch* touch = [touches anyObject];
+ CGPoint touchLocation = [touch locationInView:view];
+
+ // Don't reposition for finger down events within the deadzone. This makes double-clicking easier.
+ if (touch.timestamp - lastTouchUp.timestamp > DOUBLE_TAP_DEAD_ZONE_DELAY ||
+ sqrt(pow((touchLocation.x / view.bounds.size.width) - (lastTouchUpLocation.x / view.bounds.size.width), 2) +
+ pow((touchLocation.y / view.bounds.size.height) - (lastTouchUpLocation.y / view.bounds.size.height), 2)) > DOUBLE_TAP_DEAD_ZONE_DELTA) {
+ [view updateCursorLocation:touchLocation isMouse:NO];
+ }
+
+ // Press the left button down
+ LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
+
+ // Start the long press timer
+ longPressTimer = [NSTimer scheduledTimerWithTimeInterval:LONG_PRESS_ACTIVATION_DELAY
+ target:self
+ selector:@selector(onLongPressStart:)
+ userInfo:nil
+ repeats:NO];
+
+ lastTouchDown = touch;
+ lastTouchDownLocation = touchLocation;
+}
+
+- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
+ // Ignore touch move events with more than one finger
+ if ([[event allTouches] count] > 1) {
+ return;
+ }
+
+ UITouch* touch = [touches anyObject];
+ CGPoint touchLocation = [touch locationInView:view];
+
+ if (sqrt(pow((touchLocation.x / view.bounds.size.width) - (lastTouchDownLocation.x / view.bounds.size.width), 2) +
+ pow((touchLocation.y / view.bounds.size.height) - (lastTouchDownLocation.y / view.bounds.size.height), 2)) > LONG_PRESS_ACTIVATION_DELTA) {
+ // Moved too far since touch down. Cancel the long press timer.
+ [longPressTimer invalidate];
+ longPressTimer = nil;
+ }
+
+ [view updateCursorLocation:[[touches anyObject] locationInView:view] isMouse:NO];
+}
+
+- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
+ // Only fire this logic if all touches have ended
+ if ([[event allTouches] count] == [touches count]) {
+ // Cancel the long press timer
+ [longPressTimer invalidate];
+ longPressTimer = nil;
+
+ // Left button up on finger up
+ LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
+
+ // Raise right button too in case we triggered a long press gesture
+ LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
+
+ // Remember this last touch for touch-down deadzoning
+ lastTouchUp = [touches anyObject];
+ lastTouchUpLocation = [lastTouchUp locationInView:view];
+ }
+}
+
+- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
+ // Treat this as a normal touchesEnded event
+ [self touchesEnded:touches withEvent:event];
+}
+
+@end
diff --git a/Limelight/Input/Controller.h b/Limelight/Input/Controller.h
new file mode 100644
index 0000000..6e4511c
--- /dev/null
+++ b/Limelight/Input/Controller.h
@@ -0,0 +1,53 @@
+//
+// Controller.h
+// Moonlight
+//
+// Created by Cameron Gutman on 2/11/19.
+// Copyright © 2019 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#import "HapticContext.h"
+
+@import GameController;
+@import CoreHaptics;
+
+@interface Controller : NSObject
+
+typedef struct {
+ float lastX;
+ float lastY;
+} controller_touch_context_t;
+
+@property (nullable, nonatomic, retain) GCController* gamepad;
+@property (nonatomic) int playerIndex;
+@property (nonatomic) int lastButtonFlags;
+@property (nonatomic) int emulatingButtonFlags;
+@property (nonatomic) int supportedEmulationFlags;
+@property (nonatomic) unsigned char lastLeftTrigger;
+@property (nonatomic) unsigned char lastRightTrigger;
+@property (nonatomic) short lastLeftStickX;
+@property (nonatomic) short lastLeftStickY;
+@property (nonatomic) short lastRightStickX;
+@property (nonatomic) short lastRightStickY;
+
+@property (nonatomic) controller_touch_context_t primaryTouch;
+@property (nonatomic) controller_touch_context_t secondaryTouch;
+
+@property (nonatomic) HapticContext* _Nullable lowFreqMotor;
+@property (nonatomic) HapticContext* _Nullable highFreqMotor;
+@property (nonatomic) HapticContext* _Nullable leftTriggerMotor;
+@property (nonatomic) HapticContext* _Nullable rightTriggerMotor;
+
+@property (nonatomic) NSTimer* _Nullable accelTimer;
+@property (nonatomic) GCAcceleration lastAccelSample;
+@property (nonatomic) NSTimer* _Nullable gyroTimer;
+@property (nonatomic) GCRotationRate lastGyroSample;
+
+@property (nonatomic) NSTimer* _Nullable batteryTimer;
+@property (nonatomic) GCDeviceBatteryState lastBatteryState;
+@property (nonatomic) float lastBatteryLevel;
+
+@property (nonatomic) BOOL reportedArrival;
+@property (nonatomic) Controller* _Nullable mergedWithController;
+
+@end
diff --git a/Limelight/Input/Controller.m b/Limelight/Input/Controller.m
new file mode 100644
index 0000000..7587928
--- /dev/null
+++ b/Limelight/Input/Controller.m
@@ -0,0 +1,13 @@
+//
+// Controller.m
+// Moonlight
+//
+// Created by Cameron Gutman on 2/11/19.
+// Copyright © 2019 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#include "Controller.h"
+
+@implementation Controller
+
+@end
diff --git a/Limelight/Input/Controller.swift b/Limelight/Input/Controller.swift
deleted file mode 100644
index 9e3d86c..0000000
--- a/Limelight/Input/Controller.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// Controller.swift
-// Moonlight
-//
-// Created by David Aghassi on 4/11/16.
-// Copyright © 2016 Moonlight Stream. All rights reserved.
-//
-
-import Foundation
-
-@objc
-/**
- Defines a controller layout
- */
-class Controller: NSObject {
- // Swift requires initial properties
- var playerIndex: CInt = 0 // Controller number (e.g. 1, 2 ,3 etc)
- var lastButtonFlags: CInt = 0
- var emulatingButtonFlags: CInt = 0
- var lastLeftTrigger: CChar = 0 // Last left trigger pressed
- var lastRightTrigger: CChar = 0 // Last right trigger pressed
- var lastLeftStickX: CShort = 0 // Last X direction the left joystick went
- var lastLeftStickY: CShort = 0 // Last Y direction the left joystick went
- var lastRightStickX: CShort = 0 // Last X direction the right joystick went
- var lastRightStickY: CShort = 0 // Last Y direction the right joystick went
-}
diff --git a/Limelight/Input/ControllerSupport.h b/Limelight/Input/ControllerSupport.h
index d6432f8..a805c67 100644
--- a/Limelight/Input/ControllerSupport.h
+++ b/Limelight/Input/ControllerSupport.h
@@ -6,35 +6,48 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
-
-// Swift
-#import "Moonlight-Swift.h"
-@class Controller;
+#import "StreamConfiguration.h"
+#import "Controller.h"
@class OnScreenControls;
+@protocol ControllerSupportDelegate
+
+- (void) gamepadPresenceChanged;
+- (void) mousePresenceChanged;
+- (void) streamExitRequested;
+
+@end
+
@interface ControllerSupport : NSObject
--(id) init;
+-(id) initWithConfig:(StreamConfiguration*)streamConfig delegate:(id)delegate;
+-(void) connectionEstablished;
+
-(void) initAutoOnScreenControlMode:(OnScreenControls*)osc;
-(void) cleanup;
+-(Controller*) getOscController;
-(void) updateLeftStick:(Controller*)controller x:(short)x y:(short)y;
-(void) updateRightStick:(Controller*)controller x:(short)x y:(short)y;
--(void) updateLeftTrigger:(Controller*)controller left:(char)left;
--(void) updateRightTrigger:(Controller*)controller right:(char)right;
--(void) updateTriggers:(Controller*)controller left:(char)left right:(char)right;
+-(void) updateLeftTrigger:(Controller*)controller left:(unsigned char)left;
+-(void) updateRightTrigger:(Controller*)controller right:(unsigned char)right;
+-(void) updateTriggers:(Controller*)controller left:(unsigned char)left right:(unsigned char)right;
-(void) updateButtonFlags:(Controller*)controller flags:(int)flags;
-(void) setButtonFlag:(Controller*)controller flags:(int)flags;
-(void) clearButtonFlag:(Controller*)controller flags:(int)flags;
-(void) updateFinished:(Controller*)controller;
--(Controller*) getOscController;
-@property (nonatomic, strong) id connectObserver;
-@property (nonatomic, strong) id disconnectObserver;
+-(void) rumble:(unsigned short)controllerNumber lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor;
+-(void) rumbleTriggers:(uint16_t)controllerNumber leftTrigger:(uint16_t)leftTrigger rightTrigger:(uint16_t)rightTrigger;
+-(void) setMotionEventState:(uint16_t)controllerNumber motionType:(uint8_t)motionType reportRateHz:(uint16_t)reportRateHz;
+-(void) setControllerLed:(uint16_t)controllerNumber r:(uint8_t)r g:(uint8_t)g b:(uint8_t)b;
+
++(int) getConnectedGamepadMask:(StreamConfiguration*)streamConfig;
+
+-(NSUInteger) getConnectedGamepadCount;
@end
diff --git a/Limelight/Input/ControllerSupport.m b/Limelight/Input/ControllerSupport.m
index 186e601..19d1315 100644
--- a/Limelight/Input/ControllerSupport.m
+++ b/Limelight/Input/ControllerSupport.m
@@ -7,35 +7,188 @@
//
#import "ControllerSupport.h"
+#import "Controller.h"
+
#import "OnScreenControls.h"
+
+#import "DataManager.h"
#include "Limelight.h"
-// Swift
-#import "Moonlight-Swift.h"
-@class Controller;
-
@import GameController;
+@import AudioToolbox;
+
+static const double MOUSE_SPEED_DIVISOR = 1.25;
@implementation ControllerSupport {
+ id _controllerConnectObserver;
+ id _controllerDisconnectObserver;
+ id _mouseConnectObserver;
+ id _mouseDisconnectObserver;
+ id _keyboardConnectObserver;
+ id _keyboardDisconnectObserver;
+
NSLock *_controllerStreamLock;
NSMutableDictionary *_controllers;
+ id _delegate;
+
+ float accumulatedDeltaX;
+ float accumulatedDeltaY;
+ float accumulatedScrollX;
+ float accumulatedScrollY;
OnScreenControls *_osc;
-
- // This controller object is shared between on-screen controls
- // and player 0
- Controller *_player0osc;
-
- char _controllerNumbers;
+ Controller *_oscController;
#define EMULATING_SELECT 0x1
#define EMULATING_SPECIAL 0x2
+
+ bool _oscEnabled;
+ char _controllerNumbers;
+ bool _multiController;
+ bool _swapABXYButtons;
}
// UPDATE_BUTTON_FLAG(controller, flag, pressed)
#define UPDATE_BUTTON_FLAG(controller, x, y) \
((y) ? [self setButtonFlag:controller flags:x] : [self clearButtonFlag:controller flags:x])
+#define MAX_MAGNITUDE(x, y) (abs(x) > abs(y) ? (x) : (y))
+
+-(void) rumble:(unsigned short)controllerNumber lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor
+{
+ Controller* controller = [_controllers objectForKey:[NSNumber numberWithInteger:controllerNumber]];
+ if (controller == nil && controllerNumber == 0 && _oscEnabled) {
+ // TODO: Rumble emulation for OSC
+ }
+ if (controller == nil) {
+ // No connected controller for this player
+ return;
+ }
+
+ [controller.lowFreqMotor setMotorAmplitude:lowFreqMotor];
+ [controller.highFreqMotor setMotorAmplitude:highFreqMotor];
+}
+
+-(void) rumbleTriggers:(uint16_t)controllerNumber leftTrigger:(uint16_t)leftTrigger rightTrigger:(uint16_t)rightTrigger
+{
+ Controller* controller = [_controllers objectForKey:[NSNumber numberWithInteger:controllerNumber]];
+ if (controller == nil && controllerNumber == 0 && _oscEnabled) {
+ // TODO: Trigger rumble emulation for OSC
+ }
+ if (controller == nil) {
+ // No connected controller for this player
+ return;
+ }
+
+ [controller.leftTriggerMotor setMotorAmplitude:leftTrigger];
+ [controller.rightTriggerMotor setMotorAmplitude:rightTrigger];
+}
+
+- (void) setMotionEventState:(uint16_t)controllerNumber motionType:(uint8_t)motionType reportRateHz:(uint16_t)reportRateHz
+{
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ Controller* controller = [_controllers objectForKey:[NSNumber numberWithInteger:controllerNumber]];
+ if (controller == nil) {
+ // No connected controller for this player
+ return;
+ }
+
+ if (controller.gamepad.motion == nil) {
+ // No motion supported for this controller
+ return;
+ }
+
+ switch (motionType) {
+ case LI_MOTION_TYPE_ACCEL:
+ [controller.accelTimer invalidate];
+ controller.accelTimer = nil;
+
+ if (reportRateHz && controller.gamepad.motion.hasGravityAndUserAcceleration) {
+ // Reset the last motion sample
+ GCAcceleration emptyAccelSample = {};
+ controller.lastAccelSample = emptyAccelSample;
+
+ dispatch_sync(dispatch_get_main_queue(), ^{
+ controller.accelTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 / reportRateHz repeats:YES block:^(NSTimer *timer) {
+ // Don't send duplicate samples
+ GCAcceleration lastAccelSample = controller.lastAccelSample;
+ GCAcceleration accelSample = controller.gamepad.motion.acceleration;
+ if (memcmp(&accelSample, &lastAccelSample, sizeof(accelSample)) == 0) {
+ return;
+ }
+ controller.lastAccelSample = accelSample;
+
+ // Convert g to m/s^2
+ LiSendControllerMotionEvent((uint8_t)controllerNumber,
+ LI_MOTION_TYPE_ACCEL,
+ accelSample.x * -9.80665f,
+ accelSample.y * -9.80665f,
+ accelSample.z * -9.80665f);
+ }];
+ });
+ }
+ break;
+
+ case LI_MOTION_TYPE_GYRO:
+ [controller.gyroTimer invalidate];
+ controller.gyroTimer = nil;
+
+ if (reportRateHz && controller.gamepad.motion.hasRotationRate) {
+ // Reset the last motion sample
+ GCRotationRate emptyGyroSample = {};
+ controller.lastGyroSample = emptyGyroSample;
+
+ dispatch_sync(dispatch_get_main_queue(), ^{
+ controller.gyroTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 / reportRateHz repeats:YES block:^(NSTimer *timer) {
+ // Don't send duplicate samples
+ GCRotationRate lastGyroSample = controller.lastGyroSample;
+ GCRotationRate gyroSample = controller.gamepad.motion.rotationRate;
+ if (memcmp(&gyroSample, &lastGyroSample, sizeof(gyroSample)) == 0) {
+ return;
+ }
+ controller.lastGyroSample = gyroSample;
+
+ // Convert rad/s to deg/s
+ LiSendControllerMotionEvent((uint8_t)controllerNumber,
+ LI_MOTION_TYPE_GYRO,
+ gyroSample.x * 57.2957795f,
+ gyroSample.z * 57.2957795f,
+ gyroSample.y * -57.2957795f);
+ }];
+ });
+ }
+ break;
+ }
+
+ // Set the motion sensor state if they require manual activation
+ if (controller.gamepad.motion.sensorsRequireManualActivation) {
+ if (controller.gyroTimer || controller.accelTimer) {
+ controller.gamepad.motion.sensorsActive = YES;
+ }
+ else {
+ controller.gamepad.motion.sensorsActive = NO;
+ }
+ }
+ }
+}
+
+-(void) setControllerLed:(uint16_t)controllerNumber r:(uint8_t)r g:(uint8_t)g b:(uint8_t)b {
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ Controller* controller = [_controllers objectForKey:[NSNumber numberWithInteger:controllerNumber]];
+ if (controller == nil) {
+ // No connected controller for this player
+ return;
+ }
+
+ if (controller.gamepad.light == nil) {
+ // No LED control supported for this controller
+ return;
+ }
+
+ controller.gamepad.light.color = [[GCColor alloc] initWithRed:(r / 255.0f) green:(g / 255.0f) blue:(b / 255.0f)];
+ }
+}
+
-(void) updateLeftStick:(Controller*)controller x:(short)x y:(short)y
{
@synchronized(controller) {
@@ -52,21 +205,21 @@
}
}
--(void) updateLeftTrigger:(Controller*)controller left:(char)left
+-(void) updateLeftTrigger:(Controller*)controller left:(unsigned char)left
{
@synchronized(controller) {
controller.lastLeftTrigger = left;
}
}
--(void) updateRightTrigger:(Controller*)controller right:(char)right
+-(void) updateRightTrigger:(Controller*)controller right:(unsigned char)right
{
@synchronized(controller) {
controller.lastRightTrigger = right;
}
}
--(void) updateTriggers:(Controller*) controller left:(char)left right:(char)right
+-(void) updateTriggers:(Controller*) controller left:(unsigned char)left right:(unsigned char)right
{
@synchronized(controller) {
controller.lastLeftTrigger = left;
@@ -76,17 +229,26 @@
-(void) handleSpecialCombosReleased:(Controller*)controller releasedButtons:(int)releasedButtons
{
- if ((controller.emulatingButtonFlags & EMULATING_SELECT) &&
- ((releasedButtons & LB_FLAG) || (releasedButtons & PLAY_FLAG))) {
+ if ((controller.emulatingButtonFlags & EMULATING_SELECT) && (releasedButtons & (LB_FLAG | PLAY_FLAG))) {
controller.lastButtonFlags &= ~BACK_FLAG;
controller.emulatingButtonFlags &= ~EMULATING_SELECT;
}
- if ((controller.emulatingButtonFlags & EMULATING_SPECIAL) &&
- ((releasedButtons & RB_FLAG) || (releasedButtons & PLAY_FLAG) ||
- (releasedButtons & BACK_FLAG))) {
- controller.lastButtonFlags &= ~SPECIAL_FLAG;
- controller.emulatingButtonFlags &= ~EMULATING_SPECIAL;
+
+ if (controller.emulatingButtonFlags & EMULATING_SPECIAL) {
+ // If Select is emulated, we use RB+Start to emulate special, otherwise we use Start+Select
+ if (controller.supportedEmulationFlags & EMULATING_SELECT) {
+ if (releasedButtons & (RB_FLAG | PLAY_FLAG)) {
+ controller.lastButtonFlags &= ~SPECIAL_FLAG;
+ controller.emulatingButtonFlags &= ~EMULATING_SPECIAL;
+ }
}
+ else {
+ if (releasedButtons & (BACK_FLAG | PLAY_FLAG)) {
+ controller.lastButtonFlags &= ~SPECIAL_FLAG;
+ controller.emulatingButtonFlags &= ~EMULATING_SPECIAL;
+ }
+ }
+ }
}
-(void) handleSpecialCombosPressed:(Controller*)controller pressedButtons:(int)pressedButtons
@@ -95,34 +257,46 @@
if (controller.lastButtonFlags & PLAY_FLAG) {
// If LB and start are down, trigger select
if (controller.lastButtonFlags & LB_FLAG) {
- controller.lastButtonFlags |= BACK_FLAG;
- controller.lastButtonFlags &= ~(pressedButtons & (PLAY_FLAG | LB_FLAG));
- controller.emulatingButtonFlags |= EMULATING_SELECT;
+ if (controller.supportedEmulationFlags & EMULATING_SELECT) {
+ controller.lastButtonFlags |= BACK_FLAG;
+ controller.lastButtonFlags &= ~(pressedButtons & (PLAY_FLAG | LB_FLAG));
+ controller.emulatingButtonFlags |= EMULATING_SELECT;
+ }
}
- // If (RB or select) and start are down, trigger special
- else if ((controller.lastButtonFlags & RB_FLAG) || (controller.lastButtonFlags & BACK_FLAG)) {
- controller.lastButtonFlags |= SPECIAL_FLAG;
- controller.lastButtonFlags &= ~(pressedButtons & (PLAY_FLAG | RB_FLAG | BACK_FLAG));
- controller.emulatingButtonFlags |= EMULATING_SPECIAL;
+ else if (controller.supportedEmulationFlags & EMULATING_SPECIAL) {
+ // If Select is emulated too, use RB+Start to emulate special
+ if (controller.supportedEmulationFlags & EMULATING_SELECT) {
+ if (controller.lastButtonFlags & RB_FLAG) {
+ controller.lastButtonFlags |= SPECIAL_FLAG;
+ controller.lastButtonFlags &= ~(pressedButtons & (PLAY_FLAG | RB_FLAG));
+ controller.emulatingButtonFlags |= EMULATING_SPECIAL;
+ }
+ }
+ else {
+ // If Select is physical, use Start+Select to emulate special
+ if (controller.lastButtonFlags & BACK_FLAG) {
+ controller.lastButtonFlags |= SPECIAL_FLAG;
+ controller.lastButtonFlags &= ~(pressedButtons & (PLAY_FLAG | BACK_FLAG));
+ controller.emulatingButtonFlags |= EMULATING_SPECIAL;
+ }
+ }
}
}
-
}
-(void) updateButtonFlags:(Controller*)controller flags:(int)flags
{
@synchronized(controller) {
- int releasedButtons = (controller.lastButtonFlags ^ flags) & ~flags;
- int pressedButtons = (controller.lastButtonFlags ^ flags) & flags;
-
controller.lastButtonFlags = flags;
// This must be called before handleSpecialCombosPressed
// because we clear the original button flags there
+ int releasedButtons = (controller.lastButtonFlags ^ flags) & ~flags;
+ int pressedButtons = (controller.lastButtonFlags ^ flags) & flags;
+
[self handleSpecialCombosReleased:controller releasedButtons:releasedButtons];
[self handleSpecialCombosPressed:controller pressedButtons:pressedButtons];
-
}
}
@@ -142,56 +316,428 @@
}
}
+-(uint16_t) getActiveGamepadMask
+{
+ return (_multiController ? _controllerNumbers : 1) | (_oscEnabled ? 1 : 0);
+}
+
-(void) updateFinished:(Controller*)controller
{
+ BOOL exitRequested = NO;
+
[_controllerStreamLock lock];
@synchronized(controller) {
- // Player 1 is always present for OSC
- LiSendMultiControllerEvent(controller.playerIndex, _controllerNumbers | 1, controller.lastButtonFlags, controller.lastLeftTrigger, controller.lastRightTrigger, controller.lastLeftStickX, controller.lastLeftStickY, controller.lastRightStickX, controller.lastRightStickY);
+ // Handle Start+Select+L1+R1 gamepad quit combo
+ if (controller.lastButtonFlags == (PLAY_FLAG | BACK_FLAG | LB_FLAG | RB_FLAG)) {
+ controller.lastButtonFlags = 0;
+ exitRequested = YES;
+ }
+
+ // Only send controller events if we successfully reported controller arrival
+ if ([self reportControllerArrival:controller]) {
+ uint32_t buttonFlags = controller.lastButtonFlags;
+ uint8_t leftTrigger = controller.lastLeftTrigger;
+ uint8_t rightTrigger = controller.lastRightTrigger;
+ int16_t leftStickX = controller.lastLeftStickX;
+ int16_t leftStickY = controller.lastLeftStickY;
+ int16_t rightStickX = controller.lastRightStickX;
+ int16_t rightStickY = controller.lastRightStickY;
+
+ // If this is merged with another controller, combine the inputs
+ if (controller.mergedWithController) {
+ buttonFlags |= controller.mergedWithController.lastButtonFlags;
+ leftTrigger = MAX(leftTrigger, controller.mergedWithController.lastLeftTrigger);
+ rightTrigger = MAX(rightTrigger, controller.mergedWithController.lastRightTrigger);
+ leftStickX = MAX_MAGNITUDE(leftStickX, controller.mergedWithController.lastLeftStickX);
+ leftStickY = MAX_MAGNITUDE(leftStickY, controller.mergedWithController.lastLeftStickY);
+ rightStickX = MAX_MAGNITUDE(rightStickX, controller.mergedWithController.lastRightStickX);
+ rightStickY = MAX_MAGNITUDE(rightStickY, controller.mergedWithController.lastRightStickY);
+ }
+
+ // Player 1 is always present for OSC
+ LiSendMultiControllerEvent(_multiController ? controller.playerIndex : 0, [self getActiveGamepadMask],
+ buttonFlags, leftTrigger, rightTrigger,
+ leftStickX, leftStickY, rightStickX, rightStickY);
+ }
}
[_controllerStreamLock unlock];
+
+ if (exitRequested) {
+ // Invoke the delegate callback on the main thread
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self->_delegate streamExitRequested];
+ });
+ }
}
++(BOOL) hasKeyboardOrMouse {
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ return GCMouse.mice.count > 0 || GCKeyboard.coalescedKeyboard != nil;
+ }
+ else {
+ return NO;
+ }
+}
+
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wdeprecated-declarations"
+
-(void) unregisterControllerCallbacks:(GCController*) controller
{
if (controller != NULL) {
controller.controllerPausedHandler = NULL;
if (controller.extendedGamepad != NULL) {
+ // Re-enable system gestures on the gamepad buttons now
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ for (GCControllerElement* element in controller.physicalInputProfile.allElements) {
+ element.preferredSystemGestureState = GCSystemGestureStateEnabled;
+ }
+ }
+
controller.extendedGamepad.valueChangedHandler = NULL;
}
- else if (controller.gamepad != NULL) {
- controller.gamepad.valueChangedHandler = NULL;
+ }
+}
+
+-(void) initializeControllerHaptics:(Controller*) controller
+{
+ controller.lowFreqMotor = [HapticContext createContextForLowFreqMotor:controller.gamepad];
+ controller.highFreqMotor = [HapticContext createContextForHighFreqMotor:controller.gamepad];
+ controller.leftTriggerMotor = [HapticContext createContextForLeftTrigger:controller.gamepad];
+ controller.rightTriggerMotor = [HapticContext createContextForRightTrigger:controller.gamepad];
+}
+
+-(void) cleanupControllerHaptics:(Controller*) controller
+{
+ [controller.lowFreqMotor cleanup];
+ [controller.highFreqMotor cleanup];
+ [controller.leftTriggerMotor cleanup];
+ [controller.rightTriggerMotor cleanup];
+}
+
+-(void) cleanupControllerMotion:(Controller*) controller
+{
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ // Stop sensor sampling timers
+ [controller.gyroTimer invalidate];
+ [controller.accelTimer invalidate];
+
+ // Disable motion sensors if they require manual activation
+ if (controller.gamepad && controller.gamepad.motion && controller.gamepad.motion.sensorsRequireManualActivation) {
+ controller.gamepad.motion.sensorsActive = NO;
}
}
}
+-(void) initializeControllerBattery:(Controller*) controller
+{
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ if (controller.gamepad.battery) {
+ // Poll for updated battery status every 30 seconds
+ controller.batteryTimer = [NSTimer scheduledTimerWithTimeInterval:30 repeats:YES block:^(NSTimer *timer) {
+ if (controller.lastBatteryState != controller.gamepad.battery.batteryState ||
+ controller.lastBatteryLevel != controller.gamepad.battery.batteryLevel) {
+ uint8_t batteryState;
+
+ switch (controller.gamepad.battery.batteryState) {
+ case GCDeviceBatteryStateFull:
+ batteryState = LI_BATTERY_STATE_FULL;
+ break;
+ case GCDeviceBatteryStateCharging:
+ batteryState = LI_BATTERY_STATE_CHARGING;
+ break;
+ case GCDeviceBatteryStateDischarging:
+ batteryState = LI_BATTERY_STATE_DISCHARGING;
+ break;
+ case GCDeviceBatteryStateUnknown:
+ default:
+ batteryState = LI_BATTERY_STATE_UNKNOWN;
+ break;
+ }
+
+ LiSendControllerBatteryEvent(controller.playerIndex, batteryState, (uint8_t)(controller.gamepad.battery.batteryLevel * 100));
+
+ controller.lastBatteryState = controller.gamepad.battery.batteryState;
+ controller.lastBatteryLevel = controller.gamepad.battery.batteryLevel;
+ }
+ }];
+
+ // Fire the timer immediately to send the initial battery state
+ [controller.batteryTimer fire];
+ }
+ }
+}
+
+-(void) cleanupControllerBattery:(Controller*) controller
+{
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ [controller.batteryTimer invalidate];
+ }
+}
+
+-(BOOL) reportControllerArrival:(Controller*) limeController
+{
+ // Only report arrival once
+ if (limeController.reportedArrival) {
+ return YES;
+ }
+
+ uint8_t type = LI_CTYPE_UNKNOWN;
+ uint16_t capabilities = 0;
+ uint32_t supportedButtonFlags = 0;
+
+ GCController *controller = limeController.gamepad;
+ if (controller) {
+ // This is a physical controller with a corresponding GCController object
+
+ // Start is always present
+ supportedButtonFlags |= PLAY_FLAG;
+
+ // Detect buttons present in the GCExtendedGamepad profile
+ if (controller.extendedGamepad.dpad) {
+ supportedButtonFlags |= UP_FLAG | DOWN_FLAG | LEFT_FLAG | RIGHT_FLAG;
+ }
+ if (controller.extendedGamepad.leftShoulder) {
+ supportedButtonFlags |= LB_FLAG;
+ }
+ if (controller.extendedGamepad.rightShoulder) {
+ supportedButtonFlags |= RB_FLAG;
+ }
+ if (@available(iOS 13.0, tvOS 13.0, *)) {
+ if (controller.extendedGamepad.buttonOptions) {
+ supportedButtonFlags |= BACK_FLAG;
+ }
+ }
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ if (controller.extendedGamepad.buttonHome) {
+ supportedButtonFlags |= SPECIAL_FLAG;
+ }
+ }
+ if (controller.extendedGamepad.buttonA) {
+ supportedButtonFlags |= A_FLAG;
+ }
+ if (controller.extendedGamepad.buttonB) {
+ supportedButtonFlags |= B_FLAG;
+ }
+ if (controller.extendedGamepad.buttonX) {
+ supportedButtonFlags |= X_FLAG;
+ }
+ if (controller.extendedGamepad.buttonY) {
+ supportedButtonFlags |= Y_FLAG;
+ }
+ if (@available(iOS 12.1, tvOS 12.1, *)) {
+ if (controller.extendedGamepad.leftThumbstickButton) {
+ supportedButtonFlags |= LS_CLK_FLAG;
+ }
+ if (controller.extendedGamepad.rightThumbstickButton) {
+ supportedButtonFlags |= RS_CLK_FLAG;
+ }
+ }
+
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ // Xbox One/Series controller
+ if (controller.physicalInputProfile.buttons[GCInputXboxPaddleOne]) {
+ supportedButtonFlags |= PADDLE1_FLAG;
+ }
+ if (controller.physicalInputProfile.buttons[GCInputXboxPaddleTwo]) {
+ supportedButtonFlags |= PADDLE2_FLAG;
+ }
+ if (controller.physicalInputProfile.buttons[GCInputXboxPaddleThree]) {
+ supportedButtonFlags |= PADDLE3_FLAG;
+ }
+ if (controller.physicalInputProfile.buttons[GCInputXboxPaddleFour]) {
+ supportedButtonFlags |= PADDLE4_FLAG;
+ }
+ if (@available(iOS 15.0, tvOS 15.0, *)) {
+ if (controller.physicalInputProfile.buttons[GCInputButtonShare]) {
+ supportedButtonFlags |= MISC_FLAG;
+ }
+ }
+
+ // DualShock/DualSense controller
+ if (controller.physicalInputProfile.buttons[GCInputDualShockTouchpadButton]) {
+ supportedButtonFlags |= TOUCHPAD_FLAG;
+ }
+ if (controller.physicalInputProfile.dpads[GCInputDualShockTouchpadOne]) {
+ capabilities |= LI_CCAP_TOUCHPAD;
+ }
+
+ if ([controller.extendedGamepad isKindOfClass:[GCXboxGamepad class]]) {
+ type = LI_CTYPE_XBOX;
+ }
+ else if ([controller.extendedGamepad isKindOfClass:[GCDualShockGamepad class]]) {
+ type = LI_CTYPE_PS;
+ }
+
+ if (@available(iOS 14.5, tvOS 14.5, *)) {
+ if ([controller.extendedGamepad isKindOfClass:[GCDualSenseGamepad class]]) {
+ type = LI_CTYPE_PS;
+ }
+ }
+
+ // Detect supported haptics localities
+ if (controller.haptics) {
+ if ([controller.haptics.supportedLocalities containsObject:GCHapticsLocalityHandles]) {
+ capabilities |= LI_CCAP_RUMBLE;
+ }
+ if ([controller.haptics.supportedLocalities containsObject:GCHapticsLocalityTriggers]) {
+ capabilities |= LI_CCAP_TRIGGER_RUMBLE;
+ }
+ }
+
+ // Detect supported motion sensors
+ if (controller.motion) {
+ if (controller.motion.hasGravityAndUserAcceleration) {
+ capabilities |= LI_CCAP_ACCEL;
+ }
+ if (controller.motion.hasRotationRate) {
+ capabilities |= LI_CCAP_GYRO;
+ }
+ }
+
+ // Detect RGB LED support
+ if (controller.light) {
+ capabilities |= LI_CCAP_RGB_LED;
+ }
+
+ // Detect battery support
+ if (controller.battery) {
+ capabilities |= LI_CCAP_BATTERY_STATE;
+ }
+ }
+ else {
+ // This is a virtual controller corresponding to our OSC
+
+ // TODO: Support various layouts and button labels on the OSC
+ type = LI_CTYPE_XBOX;
+ capabilities = 0;
+ supportedButtonFlags =
+ PLAY_FLAG | BACK_FLAG | UP_FLAG | DOWN_FLAG | LEFT_FLAG | RIGHT_FLAG |
+ LB_FLAG | RB_FLAG | LS_CLK_FLAG | RS_CLK_FLAG | A_FLAG | B_FLAG | X_FLAG | Y_FLAG;
+ }
+ }
+
+ // Report the new controller to the host
+ // NB: This will fail if the connection hasn't been fully established yet
+ // and we will try again later.
+ if (LiSendControllerArrivalEvent(controller.playerIndex,
+ [self getActiveGamepadMask],
+ type,
+ supportedButtonFlags,
+ capabilities) != 0) {
+ return NO;
+ }
+
+ // Begin polling for battery status
+ [self initializeControllerBattery:limeController];
+
+ // Remember that we've reported arrival already
+ limeController.reportedArrival = YES;
+ return YES;
+}
+
+-(void) handleControllerTouchpad:(Controller*)controller touch:(GCControllerDirectionPad*)touch index:(int)index
+{
+ controller_touch_context_t context = index == 0 ? controller.primaryTouch : controller.secondaryTouch;
+
+ // This magic is courtesy of SDL
+ float normalizedX = (1.0f + touch.xAxis.value) * 0.5f;
+ float normalizedY = 1.0f - (1.0f + touch.yAxis.value) * 0.5f;
+
+ // If we went from a touch to no touch, generate a touch up event
+ if ((context.lastX || context.lastY) && (!touch.xAxis.value && !touch.yAxis.value)) {
+ LiSendControllerTouchEvent(controller.playerIndex, LI_TOUCH_EVENT_UP, index, normalizedX, normalizedY, 1.0f);
+ }
+ else if (touch.xAxis.value || touch.yAxis.value) {
+ // If we went from no touch to a touch, generate a touch down event
+ if (!context.lastX && !context.lastY) {
+ LiSendControllerTouchEvent(controller.playerIndex, LI_TOUCH_EVENT_DOWN, index, normalizedX, normalizedY, 1.0f);
+ }
+ else if (context.lastX != touch.xAxis.value || context.lastY != touch.yAxis.value) {
+ // Otherwise it's just a move
+ LiSendControllerTouchEvent(controller.playerIndex, LI_TOUCH_EVENT_MOVE, index, normalizedX, normalizedY, 1.0f);
+ }
+ }
+
+ // We have to assign the whole struct because this is a property rather than a standard
+ // field that we could modify through a pointer.
+ if (index == 0) {
+ controller.primaryTouch = (controller_touch_context_t) {
+ touch.xAxis.value,
+ touch.yAxis.value
+ };
+ }
+ else {
+ controller.secondaryTouch = (controller_touch_context_t) {
+ touch.xAxis.value,
+ touch.yAxis.value
+ };
+ }
+}
+
-(void) registerControllerCallbacks:(GCController*) controller
{
if (controller != NULL) {
- controller.controllerPausedHandler = ^(GCController *controller) {
- Controller* limeController = [_controllers objectForKey:[NSNumber numberWithInteger:controller.playerIndex]];
- [self setButtonFlag:limeController flags:PLAY_FLAG];
- [self updateFinished:limeController];
-
- // Pause for 100 ms
- usleep(100 * 1000);
-
- [self clearButtonFlag:limeController flags:PLAY_FLAG];
- [self updateFinished:limeController];
- };
+ // iOS 13 allows the Start button to behave like a normal button, however
+ // older MFi controllers can send an instant down+up event for the start button
+ // which means the button will not be down long enough to register on the PC.
+ // To work around this issue, use the old controllerPausedHandler if the controller
+ // doesn't have a Select button (which indicates it probably doesn't have a proper
+ // Start button either).
+ BOOL useLegacyPausedHandler = YES;
+ if (@available(iOS 13.0, tvOS 13.0, *)) {
+ if (controller.extendedGamepad != nil &&
+ controller.extendedGamepad.buttonOptions != nil) {
+ useLegacyPausedHandler = NO;
+ }
+ }
+
+ if (useLegacyPausedHandler) {
+ controller.controllerPausedHandler = ^(GCController *controller) {
+ Controller* limeController = [self->_controllers objectForKey:[NSNumber numberWithInteger:controller.playerIndex]];
+
+ // Get off the main thread
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
+ [self setButtonFlag:limeController flags:PLAY_FLAG];
+ [self updateFinished:limeController];
+
+ // Pause for 100 ms
+ usleep(100 * 1000);
+
+ [self clearButtonFlag:limeController flags:PLAY_FLAG];
+ [self updateFinished:limeController];
+ });
+ };
+ }
if (controller.extendedGamepad != NULL) {
+ // Disable system gestures on the gamepad to avoid interfering
+ // with in-game controller actions
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ for (GCControllerElement* element in controller.physicalInputProfile.allElements) {
+ element.preferredSystemGestureState = GCSystemGestureStateDisabled;
+ }
+ }
+
controller.extendedGamepad.valueChangedHandler = ^(GCExtendedGamepad *gamepad, GCControllerElement *element) {
- Controller* limeController = [_controllers objectForKey:[NSNumber numberWithInteger:gamepad.controller.playerIndex]];
+ Controller* limeController = [self->_controllers objectForKey:[NSNumber numberWithInteger:gamepad.controller.playerIndex]];
short leftStickX, leftStickY;
short rightStickX, rightStickY;
- char leftTrigger, rightTrigger;
+ unsigned char leftTrigger, rightTrigger;
- UPDATE_BUTTON_FLAG(limeController, A_FLAG, gamepad.buttonA.pressed);
- UPDATE_BUTTON_FLAG(limeController, B_FLAG, gamepad.buttonB.pressed);
- UPDATE_BUTTON_FLAG(limeController, X_FLAG, gamepad.buttonX.pressed);
- UPDATE_BUTTON_FLAG(limeController, Y_FLAG, gamepad.buttonY.pressed);
+ if (self->_swapABXYButtons) {
+ UPDATE_BUTTON_FLAG(limeController, B_FLAG, gamepad.buttonA.pressed);
+ UPDATE_BUTTON_FLAG(limeController, A_FLAG, gamepad.buttonB.pressed);
+ UPDATE_BUTTON_FLAG(limeController, Y_FLAG, gamepad.buttonX.pressed);
+ UPDATE_BUTTON_FLAG(limeController, X_FLAG, gamepad.buttonY.pressed);
+ }
+ else {
+ UPDATE_BUTTON_FLAG(limeController, A_FLAG, gamepad.buttonA.pressed);
+ UPDATE_BUTTON_FLAG(limeController, B_FLAG, gamepad.buttonB.pressed);
+ UPDATE_BUTTON_FLAG(limeController, X_FLAG, gamepad.buttonX.pressed);
+ UPDATE_BUTTON_FLAG(limeController, Y_FLAG, gamepad.buttonY.pressed);
+ }
UPDATE_BUTTON_FLAG(limeController, UP_FLAG, gamepad.dpad.up.pressed);
UPDATE_BUTTON_FLAG(limeController, DOWN_FLAG, gamepad.dpad.down.pressed);
@@ -201,6 +747,68 @@
UPDATE_BUTTON_FLAG(limeController, LB_FLAG, gamepad.leftShoulder.pressed);
UPDATE_BUTTON_FLAG(limeController, RB_FLAG, gamepad.rightShoulder.pressed);
+ // Yay, iOS 12.1 now supports analog stick buttons
+ if (@available(iOS 12.1, tvOS 12.1, *)) {
+ if (gamepad.leftThumbstickButton != nil) {
+ UPDATE_BUTTON_FLAG(limeController, LS_CLK_FLAG, gamepad.leftThumbstickButton.pressed);
+ }
+ if (gamepad.rightThumbstickButton != nil) {
+ UPDATE_BUTTON_FLAG(limeController, RS_CLK_FLAG, gamepad.rightThumbstickButton.pressed);
+ }
+ }
+
+ if (@available(iOS 13.0, tvOS 13.0, *)) {
+ // Options button is optional (only present on Xbox One S and PS4 gamepads)
+ if (gamepad.buttonOptions != nil) {
+ UPDATE_BUTTON_FLAG(limeController, BACK_FLAG, gamepad.buttonOptions.pressed);
+
+ // For older MFi gamepads, the menu button will already be handled by
+ // the controllerPausedHandler.
+ UPDATE_BUTTON_FLAG(limeController, PLAY_FLAG, gamepad.buttonMenu.pressed);
+ }
+ }
+
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ // Home/Guide button is optional (only present on Xbox One S and PS4 gamepads)
+ if (gamepad.buttonHome != nil) {
+ UPDATE_BUTTON_FLAG(limeController, SPECIAL_FLAG, gamepad.buttonHome.pressed);
+ }
+
+ // Xbox One/Series controllers
+ if (gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleOne]) {
+ UPDATE_BUTTON_FLAG(limeController, PADDLE1_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleOne].pressed);
+ }
+ if (gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleTwo]) {
+ UPDATE_BUTTON_FLAG(limeController, PADDLE2_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleTwo].pressed);
+ }
+ if (gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleThree]) {
+ UPDATE_BUTTON_FLAG(limeController, PADDLE3_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleThree].pressed);
+ }
+ if (gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleFour]) {
+ UPDATE_BUTTON_FLAG(limeController, PADDLE4_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleFour].pressed);
+ }
+ if (@available(iOS 15.0, tvOS 15.0, *)) {
+ if (gamepad.controller.physicalInputProfile.buttons[GCInputButtonShare]) {
+ UPDATE_BUTTON_FLAG(limeController, MISC_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputButtonShare].pressed);
+ }
+ }
+
+ // DualShock/DualSense controllers
+ if (gamepad.controller.physicalInputProfile.buttons[GCInputDualShockTouchpadButton]) {
+ UPDATE_BUTTON_FLAG(limeController, TOUCHPAD_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputDualShockTouchpadButton].pressed);
+ }
+ if (gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadOne]) {
+ [self handleControllerTouchpad:limeController
+ touch:gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadOne]
+ index:0];
+ }
+ if (gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo]) {
+ [self handleControllerTouchpad:limeController
+ touch:gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo]
+ index:1];
+ }
+ }
+
leftStickX = gamepad.leftThumbstick.xAxis.value * 0x7FFE;
leftStickY = gamepad.leftThumbstick.yAxis.value * 0x7FFE;
@@ -216,30 +824,98 @@
[self updateFinished:limeController];
};
}
- else if (controller.gamepad != NULL) {
- controller.gamepad.valueChangedHandler = ^(GCGamepad *gamepad, GCControllerElement *element) {
- Controller* limeController = [_controllers objectForKey:[NSNumber numberWithInteger:gamepad.controller.playerIndex]];
- UPDATE_BUTTON_FLAG(limeController, A_FLAG, gamepad.buttonA.pressed);
- UPDATE_BUTTON_FLAG(limeController, B_FLAG, gamepad.buttonB.pressed);
- UPDATE_BUTTON_FLAG(limeController, X_FLAG, gamepad.buttonX.pressed);
- UPDATE_BUTTON_FLAG(limeController, Y_FLAG, gamepad.buttonY.pressed);
-
- UPDATE_BUTTON_FLAG(limeController, UP_FLAG, gamepad.dpad.up.pressed);
- UPDATE_BUTTON_FLAG(limeController, DOWN_FLAG, gamepad.dpad.down.pressed);
- UPDATE_BUTTON_FLAG(limeController, LEFT_FLAG, gamepad.dpad.left.pressed);
- UPDATE_BUTTON_FLAG(limeController, RIGHT_FLAG, gamepad.dpad.right.pressed);
-
- UPDATE_BUTTON_FLAG(limeController, LB_FLAG, gamepad.leftShoulder.pressed);
- UPDATE_BUTTON_FLAG(limeController, RB_FLAG, gamepad.rightShoulder.pressed);
-
- [self updateFinished:limeController];
- };
- }
} else {
Log(LOG_W, @"Tried to register controller callbacks on NULL controller");
}
}
+-(void) unregisterMouseCallbacks:(GCMouse*)mouse API_AVAILABLE(ios(14.0)) {
+ mouse.mouseInput.mouseMovedHandler = nil;
+
+ mouse.mouseInput.leftButton.pressedChangedHandler = nil;
+ mouse.mouseInput.middleButton.pressedChangedHandler = nil;
+ mouse.mouseInput.rightButton.pressedChangedHandler = nil;
+
+ for (GCControllerButtonInput* auxButton in mouse.mouseInput.auxiliaryButtons) {
+ auxButton.pressedChangedHandler = nil;
+ }
+
+#if TARGET_OS_TV
+ mouse.mouseInput.scroll.xAxis.valueChangedHandler = nil;
+ mouse.mouseInput.scroll.yAxis.valueChangedHandler = nil;
+#endif
+}
+
+-(void) registerMouseCallbacks:(GCMouse*) mouse API_AVAILABLE(ios(14.0)) {
+ mouse.mouseInput.mouseMovedHandler = ^(GCMouseInput * _Nonnull mouse, float deltaX, float deltaY) {
+ self->accumulatedDeltaX += deltaX / MOUSE_SPEED_DIVISOR;
+ self->accumulatedDeltaY += -deltaY / MOUSE_SPEED_DIVISOR;
+
+ short truncatedDeltaX = (short)self->accumulatedDeltaX;
+ short truncatedDeltaY = (short)self->accumulatedDeltaY;
+
+ if (truncatedDeltaX != 0 || truncatedDeltaY != 0) {
+ LiSendMouseMoveEvent(truncatedDeltaX, truncatedDeltaY);
+
+ self->accumulatedDeltaX -= truncatedDeltaX;
+ self->accumulatedDeltaY -= truncatedDeltaY;
+ }
+ };
+
+ mouse.mouseInput.leftButton.pressedChangedHandler = ^(GCControllerButtonInput * _Nonnull button, float value, BOOL pressed) {
+ LiSendMouseButtonEvent(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_LEFT);
+ };
+ mouse.mouseInput.middleButton.pressedChangedHandler = ^(GCControllerButtonInput * _Nonnull button, float value, BOOL pressed) {
+ LiSendMouseButtonEvent(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_MIDDLE);
+ };
+ mouse.mouseInput.rightButton.pressedChangedHandler = ^(GCControllerButtonInput * _Nonnull button, float value, BOOL pressed) {
+ LiSendMouseButtonEvent(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
+ };
+
+ if (mouse.mouseInput.auxiliaryButtons != nil) {
+ if (mouse.mouseInput.auxiliaryButtons.count >= 1) {
+ mouse.mouseInput.auxiliaryButtons[0].pressedChangedHandler = ^(GCControllerButtonInput * _Nonnull button, float value, BOOL pressed) {
+ LiSendMouseButtonEvent(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_X1);
+ };
+ }
+ if (mouse.mouseInput.auxiliaryButtons.count >= 2) {
+ mouse.mouseInput.auxiliaryButtons[1].pressedChangedHandler = ^(GCControllerButtonInput * _Nonnull button, float value, BOOL pressed) {
+ LiSendMouseButtonEvent(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_X2);
+ };
+ }
+ }
+
+ // We use UIPanGestureRecognizer on iPadOS because it allows us to distinguish
+ // between discrete and continuous scroll events and also works around a bug
+ // in iPadOS 15 where discrete scroll events are dropped. tvOS only supports
+ // GCMouse for mice, so we will have to just use it and hope for the best.
+#if TARGET_OS_TV
+ mouse.mouseInput.scroll.xAxis.valueChangedHandler = ^(GCControllerAxisInput * _Nonnull axis, float value) {
+ self->accumulatedScrollX += value;
+
+ short truncatedScrollX = (short)self->accumulatedScrollX;
+
+ if (truncatedScrollX != 0) {
+ // Direction is reversed from vertical scrolling
+ LiSendHighResHScrollEvent(-truncatedScrollX * 20);
+
+ self->accumulatedScrollX -= truncatedScrollX;
+ }
+ };
+ mouse.mouseInput.scroll.yAxis.valueChangedHandler = ^(GCControllerAxisInput * _Nonnull axis, float value) {
+ self->accumulatedScrollY += value;
+
+ short truncatedScrollY = (short)self->accumulatedScrollY;
+
+ if (truncatedScrollY != 0) {
+ LiSendHighResScrollEvent(truncatedScrollY * 20);
+
+ self->accumulatedScrollY -= truncatedScrollY;
+ }
+ };
+#endif
+}
+
-(void) updateAutoOnScreenControlMode
{
// Auto on-screen control support may not be enabled
@@ -257,15 +933,34 @@
if (controller != NULL) {
if (controller.extendedGamepad != NULL) {
level = OnScreenControlsLevelAutoGCExtendedGamepad;
- break;
- }
- else if (controller.gamepad != NULL) {
- level = OnScreenControlsLevelAutoGCGamepad;
+ if (@available(iOS 12.1, tvOS 12.1, *)) {
+ if (controller.extendedGamepad.leftThumbstickButton != nil &&
+ controller.extendedGamepad.rightThumbstickButton != nil) {
+ level = OnScreenControlsLevelAutoGCExtendedGamepadWithStickButtons;
+ if (@available(iOS 13.0, tvOS 13.0, *)) {
+ if (controller.extendedGamepad.buttonOptions != nil) {
+ // Has L3/R3 and Select, so we can show nothing :)
+ level = OnScreenControlsLevelOff;
+ }
+ }
+ }
+ }
break;
}
}
}
+ // If we didn't find a gamepad present and we have a keyboard or mouse, turn
+ // the on-screen controls off to get the overlays out of the way.
+ if (level == OnScreenControlsLevelFull && [ControllerSupport hasKeyboardOrMouse]) {
+ level = OnScreenControlsLevelOff;
+
+ // Ensure the virtual gamepad disappears to avoid confusing some games.
+ // If the mouse and keyboard disconnect later, it will reappear when the
+ // first OSC input is received.
+ LiSendMultiControllerEvent(0, 0, 0, 0, 0, 0, 0, 0, 0);
+ }
+
[_osc setLevel:level];
}
@@ -276,86 +971,301 @@
[self updateAutoOnScreenControlMode];
}
--(void) assignController:(GCController*)controller {
+-(Controller*) assignController:(GCController*)controller {
for (int i = 0; i < 4; i++) {
if (!(_controllerNumbers & (1 << i))) {
_controllerNumbers |= (1 << i);
controller.playerIndex = i;
- Controller* limeController;
- if (i == 0) {
- // Player 0 shares a controller object with the on-screen controls
- limeController = _player0osc;
- } else {
- limeController = [[Controller alloc] init];
- limeController.playerIndex = i;
+ Controller* limeController = [[Controller alloc] init];
+ limeController.playerIndex = i;
+ limeController.supportedEmulationFlags = EMULATING_SPECIAL | EMULATING_SELECT;
+ limeController.gamepad = controller;
+
+ // If this is player 0, it shares state with the OSC
+ limeController.mergedWithController = _oscController;
+ _oscController.mergedWithController = limeController;
+
+ if (@available(iOS 13.0, tvOS 13.0, *)) {
+ if (controller.extendedGamepad != nil &&
+ controller.extendedGamepad.buttonOptions != nil) {
+ // Disable select button emulation since we have a physical select button
+ limeController.supportedEmulationFlags &= ~EMULATING_SELECT;
+ }
}
+
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ if (controller.extendedGamepad != nil &&
+ controller.extendedGamepad.buttonHome != nil) {
+ // Disable special button emulation since we have a physical special button
+ limeController.supportedEmulationFlags &= ~EMULATING_SPECIAL;
+ }
+ }
+
+ // Prepare controller haptics for use
+ [self initializeControllerHaptics:limeController];
[_controllers setObject:limeController forKey:[NSNumber numberWithInteger:controller.playerIndex]];
Log(LOG_I, @"Assigning controller index: %d", i);
- break;
+ return limeController;
}
}
+
+ return nil;
}
-(Controller*) getOscController {
- return _player0osc;
+ return _oscController;
}
--(id) init
++(bool) isSupportedGamepad:(GCController*) controller {
+ return controller.extendedGamepad != nil;
+}
+
+#pragma clang diagnostic pop
+
++(int) getGamepadCount {
+ int count = 0;
+
+ for (GCController* controller in [GCController controllers]) {
+ if ([ControllerSupport isSupportedGamepad:controller]) {
+ count++;
+ }
+ }
+
+ return count;
+}
+
++(int) getConnectedGamepadMask:(StreamConfiguration*)streamConfig {
+ int mask = 0;
+
+ if (streamConfig.multiController) {
+ int i = 0;
+ for (GCController* controller in [GCController controllers]) {
+ if ([ControllerSupport isSupportedGamepad:controller]) {
+ mask |= 1 << i++;
+ }
+ }
+ }
+ else {
+ // Some games don't deal with having controller reconnected
+ // properly so always report controller 1 if not in MC mode
+ mask = 0x1;
+ }
+
+ DataManager* dataMan = [[DataManager alloc] init];
+ TemporarySettings* settings = [dataMan getSettings];
+ OnScreenControlsLevel level = (OnScreenControlsLevel)[settings.onscreenControls integerValue];
+
+ // Even if no gamepads are present, we will always count one if OSC is enabled,
+ // or it's set to auto and no keyboard or mouse is present. Absolute touch mode
+ // disables the OSC.
+ if (level != OnScreenControlsLevelOff && (![ControllerSupport hasKeyboardOrMouse] || level != OnScreenControlsLevelAuto) && !settings.absoluteTouchMode) {
+ mask |= 0x1;
+ }
+
+ return mask;
+}
+
+-(NSUInteger) getConnectedGamepadCount
+{
+ return _controllers.count;
+}
+
+-(id) initWithConfig:(StreamConfiguration*)streamConfig delegate:(id)delegate
{
self = [super init];
_controllerStreamLock = [[NSLock alloc] init];
_controllers = [[NSMutableDictionary alloc] init];
_controllerNumbers = 0;
- _player0osc = [[Controller alloc] init];
- _player0osc.playerIndex = 0;
+ _multiController = streamConfig.multiController;
+ _swapABXYButtons = streamConfig.swapABXYButtons;
+ _delegate = delegate;
+
+ _oscController = [[Controller alloc] init];
+ _oscController.playerIndex = 0;
+
+ DataManager* dataMan = [[DataManager alloc] init];
+ _oscEnabled = (OnScreenControlsLevel)[[dataMan getSettings].onscreenControls integerValue] != OnScreenControlsLevelOff;
+
+ Log(LOG_I, @"Number of supported controllers connected: %d", [ControllerSupport getGamepadCount]);
+ Log(LOG_I, @"Multi-controller: %d", _multiController);
- Log(LOG_I, @"Number of controllers connected: %ld", (long)[[GCController controllers] count]);
for (GCController* controller in [GCController controllers]) {
- [self assignController:controller];
- [self registerControllerCallbacks:controller];
- [self updateAutoOnScreenControlMode];
+ if ([ControllerSupport isSupportedGamepad:controller]) {
+ [self assignController:controller];
+ [self registerControllerCallbacks:controller];
+
+ // Note: We cannot report controller arrival to the host here,
+ // because the connection has not been established yet.
+ }
}
- self.connectObserver = [[NSNotificationCenter defaultCenter] addObserverForName:GCControllerDidConnectNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) {
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ for (GCMouse* mouse in [GCMouse mice]) {
+ [self registerMouseCallbacks:mouse];
+ }
+ }
+
+ _controllerConnectObserver = [[NSNotificationCenter defaultCenter] addObserverForName:GCControllerDidConnectNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) {
Log(LOG_I, @"Controller connected!");
GCController* controller = note.object;
- [self assignController:controller];
- // Register callbacks on the new controller
- [self registerControllerCallbacks:controller];
+ if (![ControllerSupport isSupportedGamepad:controller]) {
+ // Ignore micro gamepads and motion controllers
+ return;
+ }
- // Re-evaluate the on-screen control mode
- [self updateAutoOnScreenControlMode];
+ Controller* limeController = [self assignController:controller];
+ if (limeController) {
+ // Register callbacks on the new controller
+ [self registerControllerCallbacks:controller];
+
+ // Report the controller arrival to the host if we're connected
+ [self reportControllerArrival:limeController];
+
+ // Re-evaluate the on-screen control mode
+ [self updateAutoOnScreenControlMode];
+
+ // Notify the delegate
+ [self->_delegate gamepadPresenceChanged];
+ }
}];
- self.disconnectObserver = [[NSNotificationCenter defaultCenter] addObserverForName:GCControllerDidDisconnectNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) {
+ _controllerDisconnectObserver = [[NSNotificationCenter defaultCenter] addObserverForName:GCControllerDidDisconnectNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) {
Log(LOG_I, @"Controller disconnected!");
GCController* controller = note.object;
+
+ if (![ControllerSupport isSupportedGamepad:controller]) {
+ // Ignore micro gamepads and motion controllers
+ return;
+ }
+
[self unregisterControllerCallbacks:controller];
- [_controllers removeObjectForKey:[NSNumber numberWithInteger:controller.playerIndex]];
- _controllerNumbers &= ~(1 << controller.playerIndex);
+ self->_controllerNumbers &= ~(1 << controller.playerIndex);
Log(LOG_I, @"Unassigning controller index: %ld", (long)controller.playerIndex);
- // Re-evaluate the on-screen control mode
- [self updateAutoOnScreenControlMode];
+ Controller* limeController = [self->_controllers objectForKey:[NSNumber numberWithInteger:controller.playerIndex]];
+ if (limeController) {
+ // Stop haptics on this controller
+ [self cleanupControllerHaptics:limeController];
+
+ // Stop motion reports on this controller
+ [self cleanupControllerMotion:limeController];
+
+ // Stop battery reports on this controller
+ [self cleanupControllerBattery:limeController];
+
+ // Disassociate this controller from any controllers merged with it
+ if (limeController.mergedWithController) {
+ assert(limeController.mergedWithController.mergedWithController == limeController);
+ limeController.mergedWithController.mergedWithController = nil;
+ }
+
+ // Inform the server of the updated active gamepads before removing this controller
+ [self updateFinished:limeController];
+ [self->_controllers removeObjectForKey:[NSNumber numberWithInteger:controller.playerIndex]];
+
+ // Re-evaluate the on-screen control mode
+ [self updateAutoOnScreenControlMode];
+
+ // Notify the delegate
+ [self->_delegate gamepadPresenceChanged];
+ }
}];
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ _mouseConnectObserver = [[NSNotificationCenter defaultCenter] addObserverForName:GCMouseDidConnectNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) {
+ Log(LOG_I, @"Mouse connected!");
+
+ GCMouse* mouse = note.object;
+
+ // Register for mouse events
+ [self registerMouseCallbacks: mouse];
+
+ // Re-evaluate the on-screen control mode
+ [self updateAutoOnScreenControlMode];
+
+ // Notify the delegate
+ [self->_delegate mousePresenceChanged];
+ }];
+ _mouseDisconnectObserver = [[NSNotificationCenter defaultCenter] addObserverForName:GCMouseDidDisconnectNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) {
+ Log(LOG_I, @"Mouse disconnected!");
+
+ GCMouse* mouse = note.object;
+
+ // Unregister for mouse events
+ [self unregisterMouseCallbacks: mouse];
+
+ // Re-evaluate the on-screen control mode
+ [self updateAutoOnScreenControlMode];
+
+ // Notify the delegate
+ [self->_delegate mousePresenceChanged];
+ }];
+ _keyboardConnectObserver = [[NSNotificationCenter defaultCenter] addObserverForName:GCKeyboardDidConnectNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) {
+ Log(LOG_I, @"Keyboard connected!");
+
+ // Re-evaluate the on-screen control mode
+ [self updateAutoOnScreenControlMode];
+ }];
+ _keyboardDisconnectObserver = [[NSNotificationCenter defaultCenter] addObserverForName:GCKeyboardDidDisconnectNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) {
+ Log(LOG_I, @"Keyboard disconnected!");
+
+ // Re-evaluate the on-screen control mode
+ [self updateAutoOnScreenControlMode];
+ }];
+ }
+
return self;
}
+-(void) connectionEstablished
+{
+ for (Controller* controller in [_controllers allValues]) {
+ // Report the controller arrival to the host if we haven't done so yet
+ [self reportControllerArrival:controller];
+ }
+}
+
-(void) cleanup
{
- [[NSNotificationCenter defaultCenter] removeObserver:self.connectObserver];
- [[NSNotificationCenter defaultCenter] removeObserver:self.disconnectObserver];
- [_controllers removeAllObjects];
+ [[NSNotificationCenter defaultCenter] removeObserver:_controllerConnectObserver];
+ [[NSNotificationCenter defaultCenter] removeObserver:_controllerDisconnectObserver];
+ [[NSNotificationCenter defaultCenter] removeObserver:_mouseConnectObserver];
+ [[NSNotificationCenter defaultCenter] removeObserver:_mouseDisconnectObserver];
+ [[NSNotificationCenter defaultCenter] removeObserver:_keyboardConnectObserver];
+ [[NSNotificationCenter defaultCenter] removeObserver:_keyboardDisconnectObserver];
+
+ _controllerConnectObserver = nil;
+ _controllerDisconnectObserver = nil;
+ _mouseConnectObserver = nil;
+ _mouseDisconnectObserver = nil;
+ _keyboardConnectObserver = nil;
+ _keyboardDisconnectObserver = nil;
+
_controllerNumbers = 0;
+
+ for (Controller* controller in [_controllers allValues]) {
+ [self cleanupControllerHaptics:controller];
+ [self cleanupControllerMotion:controller];
+ [self cleanupControllerBattery:controller];
+ }
+ [_controllers removeAllObjects];
+
for (GCController* controller in [GCController controllers]) {
- [self unregisterControllerCallbacks:controller];
+ if ([ControllerSupport isSupportedGamepad:controller]) {
+ [self unregisterControllerCallbacks:controller];
+ }
+ }
+
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ for (GCMouse* mouse in [GCMouse mice]) {
+ [self unregisterMouseCallbacks:mouse];
+ }
}
}
diff --git a/Limelight/Input/HapticContext.h b/Limelight/Input/HapticContext.h
new file mode 100644
index 0000000..b8f2b61
--- /dev/null
+++ b/Limelight/Input/HapticContext.h
@@ -0,0 +1,22 @@
+//
+// HapticContext.h
+// Moonlight
+//
+// Created by Cameron Gutman on 9/17/20.
+// Copyright © 2020 Moonlight Game Streaming Project. All rights reserved.
+//
+
+@import CoreHaptics;
+@import GameController;
+
+@interface HapticContext : NSObject
+
+-(void)setMotorAmplitude:(unsigned short)amplitude;
+-(void)cleanup;
+
++(HapticContext*) createContextForHighFreqMotor:(GCController*)gamepad;
++(HapticContext*) createContextForLowFreqMotor:(GCController*)gamepad;
++(HapticContext*) createContextForLeftTrigger:(GCController*)gamepad;
++(HapticContext*) createContextForRightTrigger:(GCController*)gamepad;
+
+@end
diff --git a/Limelight/Input/HapticContext.m b/Limelight/Input/HapticContext.m
new file mode 100644
index 0000000..b61124c
--- /dev/null
+++ b/Limelight/Input/HapticContext.m
@@ -0,0 +1,170 @@
+//
+// HapticContext.m
+// Moonlight
+//
+// Created by Cameron Gutman on 9/17/20.
+// Copyright © 2020 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#import "HapticContext.h"
+
+@import CoreHaptics;
+@import GameController;
+
+@implementation HapticContext {
+ GCControllerPlayerIndex _playerIndex;
+ CHHapticEngine* _hapticEngine API_AVAILABLE(ios(13.0), tvos(14.0));
+ id _hapticPlayer API_AVAILABLE(ios(13.0), tvos(14.0));
+ BOOL _playing;
+}
+
+-(void)cleanup API_AVAILABLE(ios(14.0), tvos(14.0)) {
+ if (_hapticPlayer != nil) {
+ [_hapticPlayer cancelAndReturnError:nil];
+ _hapticPlayer = nil;
+ }
+ if (_hapticEngine != nil) {
+ [_hapticEngine stopWithCompletionHandler:nil];
+ _hapticEngine = nil;
+ }
+}
+
+-(void)setMotorAmplitude:(unsigned short)amplitude API_AVAILABLE(ios(14.0), tvos(14.0)) {
+ NSError* error;
+
+ // Check if the haptic engine died
+ if (_hapticEngine == nil) {
+ return;
+ }
+
+ // Stop the effect entirely if the amplitude is 0
+ if (amplitude == 0) {
+ if (_playing) {
+ [_hapticPlayer stopAtTime:0 error:&error];
+ _playing = NO;
+ }
+
+ return;
+ }
+
+ if (_hapticPlayer == nil) {
+ // We must initialize the intensity to 1.0f because the dynamic parameters are multiplied by this value before being applied
+ CHHapticEventParameter* intensityParameter = [[CHHapticEventParameter alloc] initWithParameterID:CHHapticEventParameterIDHapticIntensity value:1.0f];
+ CHHapticEvent* hapticEvent = [[CHHapticEvent alloc] initWithEventType:CHHapticEventTypeHapticContinuous parameters:[NSArray arrayWithObject:intensityParameter] relativeTime:0 duration:GCHapticDurationInfinite];
+ CHHapticPattern* hapticPattern = [[CHHapticPattern alloc] initWithEvents:[NSArray arrayWithObject:hapticEvent] parameters:[[NSArray alloc] init] error:&error];
+ if (error != nil) {
+ Log(LOG_W, @"Controller %d: Haptic pattern creation failed: %@", _playerIndex, error);
+ return;
+ }
+
+ _hapticPlayer = [_hapticEngine createPlayerWithPattern:hapticPattern error:&error];
+ if (error != nil) {
+ Log(LOG_W, @"Controller %d: Haptic player creation failed: %@", _playerIndex, error);
+ return;
+ }
+ }
+
+ CHHapticDynamicParameter* intensityParameter = [[CHHapticDynamicParameter alloc] initWithParameterID:CHHapticDynamicParameterIDHapticIntensityControl value:amplitude / 65535.0f relativeTime:0];
+ [_hapticPlayer sendParameters:[NSArray arrayWithObject:intensityParameter] atTime:CHHapticTimeImmediate error:&error];
+ if (error != nil) {
+ Log(LOG_W, @"Controller %d: Haptic player parameter update failed: %@", _playerIndex, error);
+ return;
+ }
+
+ if (!_playing) {
+ [_hapticPlayer startAtTime:0 error:&error];
+ if (error != nil) {
+ _hapticPlayer = nil;
+ Log(LOG_W, @"Controller %d: Haptic playback start failed: %@", _playerIndex, error);
+ return;
+ }
+
+ _playing = YES;
+ }
+}
+
+-(id) initWithGamepad:(GCController*)gamepad locality:(GCHapticsLocality)locality API_AVAILABLE(ios(14.0), tvos(14.0)) {
+ if (gamepad.haptics == nil) {
+ Log(LOG_W, @"Controller %d does not support haptics", gamepad.playerIndex);
+ return nil;
+ }
+
+ if (![[gamepad.haptics supportedLocalities] containsObject:locality]) {
+ Log(LOG_W, @"Controller %d does not support haptic locality: %@", gamepad.playerIndex, locality);
+ return nil;
+ }
+
+ _playerIndex = gamepad.playerIndex;
+ _hapticEngine = [gamepad.haptics createEngineWithLocality:locality];
+
+ NSError* error;
+ [_hapticEngine startAndReturnError:&error];
+ if (error != nil) {
+ Log(LOG_W, @"Controller %d: Haptic engine failed to start: %@", gamepad.playerIndex, error);
+ return nil;
+ }
+
+ __weak typeof(self) weakSelf = self;
+ _hapticEngine.stoppedHandler = ^(CHHapticEngineStoppedReason stoppedReason) {
+ HapticContext* me = weakSelf;
+ if (me == nil) {
+ return;
+ }
+
+ Log(LOG_W, @"Controller %d: Haptic engine stopped: %p", me->_playerIndex, stoppedReason);
+ me->_hapticPlayer = nil;
+ me->_hapticEngine = nil;
+ me->_playing = NO;
+ };
+ _hapticEngine.resetHandler = ^{
+ HapticContext* me = weakSelf;
+ if (me == nil) {
+ return;
+ }
+
+ Log(LOG_W, @"Controller %d: Haptic engine reset", me->_playerIndex);
+ me->_hapticPlayer = nil;
+ me->_playing = NO;
+ [me->_hapticEngine startAndReturnError:nil];
+ };
+
+ return self;
+}
+
++(HapticContext*) createContextForHighFreqMotor:(GCController*)gamepad {
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ return [[HapticContext alloc] initWithGamepad:gamepad locality:GCHapticsLocalityRightHandle];
+ }
+ else {
+ return nil;
+ }
+}
+
++(HapticContext*) createContextForLowFreqMotor:(GCController*)gamepad {
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ return [[HapticContext alloc] initWithGamepad:gamepad locality:GCHapticsLocalityLeftHandle];
+ }
+ else {
+ return nil;
+ }
+}
+
++(HapticContext*) createContextForLeftTrigger:(GCController*)gamepad {
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ return [[HapticContext alloc] initWithGamepad:gamepad locality:GCHapticsLocalityLeftTrigger];
+ }
+ else {
+ return nil;
+ }
+}
+
++(HapticContext*) createContextForRightTrigger:(GCController*)gamepad {
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ return [[HapticContext alloc] initWithGamepad:gamepad locality:GCHapticsLocalityRightTrigger];
+ }
+ else {
+ return nil;
+ }
+}
+
+@end
diff --git a/Limelight/Input/KeyboardInputField.h b/Limelight/Input/KeyboardInputField.h
new file mode 100644
index 0000000..eff86eb
--- /dev/null
+++ b/Limelight/Input/KeyboardInputField.h
@@ -0,0 +1,18 @@
+//
+// KeyboardInputField.h
+// Moonlight
+//
+// Created by Cameron Gutman on 12/2/22.
+// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface KeyboardInputField : UITextField
+
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Limelight/Input/KeyboardInputField.m b/Limelight/Input/KeyboardInputField.m
new file mode 100644
index 0000000..2fb0e73
--- /dev/null
+++ b/Limelight/Input/KeyboardInputField.m
@@ -0,0 +1,18 @@
+//
+// KeyboardInputField.m
+// Moonlight
+//
+// Created by Cameron Gutman on 12/2/22.
+// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#import "KeyboardInputField.h"
+
+@implementation KeyboardInputField
+
+- (UIEditingInteractionConfiguration) editingInteractionConfiguration {
+ // Suppress the Undo menu that appears with a 3 finger tap
+ return UIEditingInteractionConfigurationNone;
+}
+
+@end
diff --git a/Limelight/Input/KeyboardSupport.h b/Limelight/Input/KeyboardSupport.h
new file mode 100644
index 0000000..6cecb4d
--- /dev/null
+++ b/Limelight/Input/KeyboardSupport.h
@@ -0,0 +1,23 @@
+//
+// KeyboardSupport.h
+// Moonlight
+//
+// Created by Diego Waxemberg on 8/25/18.
+// Copyright © 2018 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#import
+
+@interface KeyboardSupport : NSObject
+
+struct KeyEvent {
+ u_short keycode;
+ u_short modifierKeycode;
+ u_char modifier;
+};
+
++ (BOOL)sendKeyEventForPress:(UIPress*)press down:(BOOL)down API_AVAILABLE(ios(13.4));
++ (BOOL)sendKeyEvent:(UIKey*)key down:(BOOL)down API_AVAILABLE(ios(13.4));
++ (struct KeyEvent) translateKeyEvent:(unichar) inputChar withModifierFlags:(UIKeyModifierFlags)modifierFlags;
+
+@end
diff --git a/Limelight/Input/KeyboardSupport.m b/Limelight/Input/KeyboardSupport.m
new file mode 100644
index 0000000..f4b8f5a
--- /dev/null
+++ b/Limelight/Input/KeyboardSupport.m
@@ -0,0 +1,452 @@
+//
+// KeyboardSupport.m
+// Moonlight
+//
+// Created by Diego Waxemberg on 8/25/18.
+// Copyright © 2018 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#import "KeyboardSupport.h"
+#include
+
+@implementation KeyboardSupport
+
++ (BOOL)sendKeyEventForPress:(UIPress*)press down:(BOOL)down API_AVAILABLE(ios(13.4)) {
+ if (press.key != nil) {
+ return [KeyboardSupport sendKeyEvent:press.key down:down];
+ }
+ else {
+ short keyCode;
+
+ switch (press.type) {
+ case UIPressTypeUpArrow:
+ keyCode = 0x26;
+ break;
+ case UIPressTypeDownArrow:
+ keyCode = 0x28;
+ break;
+ case UIPressTypeLeftArrow:
+ keyCode = 0x25;
+ break;
+ case UIPressTypeRightArrow:
+ keyCode = 0x27;
+ break;
+ default:
+ // Unhandled press type
+ return NO;
+ }
+
+ LiSendKeyboardEvent(0x8000 | keyCode,
+ down ? KEY_ACTION_DOWN : KEY_ACTION_UP,
+ 0);
+
+ return YES;
+ }
+}
+
++ (BOOL)sendKeyEvent:(UIKey*)key down:(BOOL)down API_AVAILABLE(ios(13.4)) {
+ char modifierFlags = 0;
+ short keyCode = 0;
+
+ if (key.modifierFlags & UIKeyModifierShift) {
+ modifierFlags |= MODIFIER_SHIFT;
+ }
+ if (key.modifierFlags & UIKeyModifierAlternate) {
+ modifierFlags |= MODIFIER_ALT;
+ }
+ if (key.modifierFlags & UIKeyModifierControl) {
+ modifierFlags |= MODIFIER_CTRL;
+ }
+ if (key.modifierFlags & UIKeyModifierCommand) {
+ modifierFlags |= MODIFIER_META;
+ }
+
+ // This converts UIKeyboardHIDUsage values to Win32 VK_* values
+ // https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
+ if (key.keyCode >= UIKeyboardHIDUsageKeyboardA &&
+ key.keyCode <= UIKeyboardHIDUsageKeyboardZ) {
+ keyCode = (key.keyCode - UIKeyboardHIDUsageKeyboardA) + 0x41;
+ }
+ else if (key.keyCode == UIKeyboardHIDUsageKeyboard0) {
+ // This key is at the beginning of the VK_ range but the end
+ // of the UIKeyboardHIDUsageKeyboard range.
+ keyCode = 0x30;
+ }
+ else if (key.keyCode >= UIKeyboardHIDUsageKeyboard1 &&
+ key.keyCode <= UIKeyboardHIDUsageKeyboard9) {
+ keyCode = (key.keyCode - UIKeyboardHIDUsageKeyboard1) + 0x31;
+ }
+ else if (key.keyCode == UIKeyboardHIDUsageKeypad0) {
+ // This key is at the beginning of the VK_ range but the end
+ // of the UIKeyboardHIDUsageKeypad range.
+ keyCode = 0x60;
+ }
+ else if (key.keyCode >= UIKeyboardHIDUsageKeypad1 &&
+ key.keyCode <= UIKeyboardHIDUsageKeypad9) {
+ keyCode = (key.keyCode - UIKeyboardHIDUsageKeypad1) + 0x61;
+ }
+ else if (key.keyCode >= UIKeyboardHIDUsageKeyboardF1 &&
+ key.keyCode <= UIKeyboardHIDUsageKeyboardF12) {
+ keyCode = (key.keyCode - UIKeyboardHIDUsageKeyboardF1) + 0x70;
+ }
+ else if (key.keyCode >= UIKeyboardHIDUsageKeyboardF13 &&
+ key.keyCode <= UIKeyboardHIDUsageKeyboardF24) {
+ keyCode = (key.keyCode - UIKeyboardHIDUsageKeyboardF13) + 0x7C;
+ }
+ else {
+ switch (key.keyCode) {
+ case UIKeyboardHIDUsageKeyboardReturnOrEnter:
+ keyCode = 0x0D;
+ break;
+ case UIKeyboardHIDUsageKeyboardEscape:
+ keyCode = 0x1B;
+ break;
+ case UIKeyboardHIDUsageKeyboardDeleteOrBackspace:
+ keyCode = 0x08;
+ break;
+ case UIKeyboardHIDUsageKeyboardTab:
+ keyCode = 0x09;
+ break;
+ case UIKeyboardHIDUsageKeyboardSpacebar:
+ keyCode = 0x20;
+ break;
+ case UIKeyboardHIDUsageKeyboardHyphen:
+ keyCode = 0xBD;
+ break;
+ case UIKeyboardHIDUsageKeyboardEqualSign:
+ keyCode = 0xBB;
+ break;
+ case UIKeyboardHIDUsageKeyboardOpenBracket:
+ keyCode = 0xDB;
+ break;
+ case UIKeyboardHIDUsageKeyboardCloseBracket:
+ keyCode = 0xDD;
+ break;
+ case UIKeyboardHIDUsageKeyboardBackslash:
+ keyCode = 0xDC;
+ break;
+ case UIKeyboardHIDUsageKeyboardSemicolon:
+ keyCode = 0xBA;
+ break;
+ case UIKeyboardHIDUsageKeyboardQuote:
+ keyCode = 0xDE;
+ break;
+ case UIKeyboardHIDUsageKeyboardGraveAccentAndTilde:
+ keyCode = 0xC0;
+ break;
+ case UIKeyboardHIDUsageKeyboardComma:
+ keyCode = 0xBC;
+ break;
+ case UIKeyboardHIDUsageKeyboardPeriod:
+ keyCode = 0xBE;
+ break;
+ case UIKeyboardHIDUsageKeyboardSlash:
+ keyCode = 0xBF;
+ break;
+ case UIKeyboardHIDUsageKeyboardCapsLock:
+ keyCode = 0x14;
+ break;
+ case UIKeyboardHIDUsageKeyboardPrintScreen:
+ keyCode = 0x2A;
+ break;
+ case UIKeyboardHIDUsageKeyboardScrollLock:
+ keyCode = 0x91;
+ break;
+ case UIKeyboardHIDUsageKeyboardPause:
+ keyCode = 0x13;
+ break;
+ case UIKeyboardHIDUsageKeyboardInsert:
+ keyCode = 0x2D;
+ break;
+ case UIKeyboardHIDUsageKeyboardHome:
+ keyCode = 0x24;
+ break;
+ case UIKeyboardHIDUsageKeyboardPageUp:
+ keyCode = 0x21;
+ break;
+ case UIKeyboardHIDUsageKeyboardDeleteForward:
+ keyCode = 0x2E;
+ break;
+ case UIKeyboardHIDUsageKeyboardEnd:
+ keyCode = 0x23;
+ break;
+ case UIKeyboardHIDUsageKeyboardPageDown:
+ keyCode = 0x22;
+ break;
+ case UIKeyboardHIDUsageKeyboardRightArrow:
+ keyCode = 0x27;
+ break;
+ case UIKeyboardHIDUsageKeyboardLeftArrow:
+ keyCode = 0x25;
+ break;
+ case UIKeyboardHIDUsageKeyboardDownArrow:
+ keyCode = 0x28;
+ break;
+ case UIKeyboardHIDUsageKeyboardUpArrow:
+ keyCode = 0x26;
+ break;
+ case UIKeyboardHIDUsageKeypadNumLock:
+ keyCode = 0x90;
+ break;
+ case UIKeyboardHIDUsageKeypadSlash:
+ keyCode = 0x6F;
+ break;
+ case UIKeyboardHIDUsageKeypadAsterisk:
+ keyCode = 0x6A;
+ break;
+ case UIKeyboardHIDUsageKeypadHyphen:
+ keyCode = 0x6D;
+ break;
+ case UIKeyboardHIDUsageKeypadPlus:
+ keyCode = 0x6B;
+ break;
+ case UIKeyboardHIDUsageKeypadEnter:
+ keyCode = 0x0D;
+ modifierFlags |= MODIFIER_EXTENDED;
+ break;
+ case UIKeyboardHIDUsageKeypadPeriod:
+ keyCode = 0x6E;
+ break;
+ case UIKeyboardHIDUsageKeyboardNonUSBackslash:
+ keyCode = 0xE2;
+ break;
+ case UIKeyboardHIDUsageKeypadComma:
+ keyCode = 0x6C;
+ break;
+ case UIKeyboardHIDUsageKeyboardCancel:
+ keyCode = 0x03;
+ break;
+ case UIKeyboardHIDUsageKeyboardClear:
+ keyCode = 0x0C;
+ break;
+ case UIKeyboardHIDUsageKeyboardCrSelOrProps:
+ keyCode = 0xF7;
+ break;
+ case UIKeyboardHIDUsageKeyboardExSel:
+ keyCode = 0xF8;
+ break;
+ case UIKeyboardHIDUsageKeyboardLeftGUI:
+ keyCode = 0x5B;
+ break;
+ case UIKeyboardHIDUsageKeyboardLeftControl:
+ keyCode = 0xA2;
+ break;
+ case UIKeyboardHIDUsageKeyboardLeftShift:
+ keyCode = 0xA0;
+ break;
+ case UIKeyboardHIDUsageKeyboardLeftAlt:
+ keyCode = 0xA4;
+ break;
+ case UIKeyboardHIDUsageKeyboardRightGUI:
+ keyCode = 0x5C;
+ break;
+ case UIKeyboardHIDUsageKeyboardRightControl:
+ keyCode = 0xA3;
+ break;
+ case UIKeyboardHIDUsageKeyboardRightShift:
+ keyCode = 0xA1;
+ break;
+ case UIKeyboardHIDUsageKeyboardRightAlt:
+ keyCode = 0xA5;
+ break;
+ case 669: // This value corresponds to the "Globe" or "Language" key on most Apple branded iPad keyboards.
+ keyCode = 0x1B; // This value corresponds to "Escape", which is missing from most Apple branded iPad keyboards.
+ break;
+ default:
+ NSLog(@"Unhandled HID usage: %lu", (unsigned long)key.keyCode);
+ assert(0);
+ return false;
+ }
+ }
+
+ LiSendKeyboardEvent(0x8000 | keyCode,
+ down ? KEY_ACTION_DOWN : KEY_ACTION_UP,
+ modifierFlags);
+ return true;
+}
+
++ (struct KeyEvent)translateKeyEvent:(unichar)inputChar withModifierFlags:(UIKeyModifierFlags)modifierFlags {
+ struct KeyEvent event;
+ event.keycode = 0;
+ event.modifier = 0;
+ event.modifierKeycode = 0;
+
+ switch (modifierFlags) {
+ case UIKeyModifierAlphaShift:
+ case UIKeyModifierShift:
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case UIKeyModifierControl:
+ [KeyboardSupport addControlModifier:&event];
+ break;
+ case UIKeyModifierCommand:
+ [KeyboardSupport addMetaModifier:&event];
+ break;
+ case UIKeyModifierAlternate:
+ [KeyboardSupport addAltModifier:&event];
+ break;
+ case UIKeyModifierNumericPad:
+ break;
+ }
+ if (inputChar >= 0x30 && inputChar <= 0x39) {
+ // Numbers 0-9
+ event.keycode = inputChar;
+ } else if (inputChar >= 0x41 && inputChar <= 0x5A) {
+ // Capital letters
+ event.keycode = inputChar;
+ [KeyboardSupport addShiftModifier:&event];
+ } else if (inputChar >= 0x61 && inputChar <= 0x7A) {
+ // Lower case letters
+ event.keycode = inputChar - (0x61 - 0x41);
+ } switch (inputChar) {
+ case ' ': // Spacebar
+ event.keycode = 0x20;
+ break;
+ case '-': // Hyphen '-'
+ event.keycode = 0xBD;
+ break;
+ case '/': // Forward slash '/'
+ event.keycode = 0xBF;
+ break;
+ case ':': // Colon ':'
+ event.keycode = 0xBA;
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case ';': // Semi-colon ';'
+ event.keycode = 0xBA;
+ break;
+ case '(': // Open parenthesis '('
+ event.keycode = 0x39; // '9'
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case ')': // Close parenthesis ')'
+ event.keycode = 0x30; // '0'
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '$': // Dollar sign '$'
+ event.keycode = 0x34; // '4'
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '&': // Ampresand '&'
+ event.keycode = 0x37; // '7'
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '@': // At-sign '@'
+ event.keycode = 0x32; // '2'
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '"':
+ event.keycode = 0xDE;
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '\'':
+ event.keycode = 0xDE;
+ break;
+ case '!':
+ event.keycode = 0x31; // '1'
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '?':
+ event.keycode = 0xBF; // '/'
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case ',':
+ event.keycode = 0xBC;
+ break;
+ case '<':
+ event.keycode = 0xBC;
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '.':
+ event.keycode = 0xBE;
+ break;
+ case '>':
+ event.keycode = 0xBE;
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '[':
+ event.keycode = 0xDB;
+ break;
+ case ']':
+ event.keycode = 0xDD;
+ break;
+ case '{':
+ event.keycode = 0xDB;
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '}':
+ event.keycode = 0xDD;
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '#':
+ event.keycode = 0x33; // '3'
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '%':
+ event.keycode = 0x35; // '5'
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '^':
+ event.keycode = 0x36; // '6'
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '*':
+ event.keycode = 0x38; // '8'
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '+':
+ event.keycode = 0xBB;
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '=':
+ event.keycode = 0xBB;
+ break;
+ case '_':
+ event.keycode = 0xBD;
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '\\':
+ event.keycode = 0xDC;
+ break;
+ case '|':
+ event.keycode = 0xDC;
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '~':
+ event.keycode = 0xC0;
+ [KeyboardSupport addShiftModifier:&event];
+ break;
+ case '`':
+ event.keycode = 0xC0;
+ break;
+ case '\t':
+ event.keycode = 0x09;
+ break;
+ default:
+ break;
+ }
+
+ return event;
+}
+
++ (void) addShiftModifier:(struct KeyEvent*)event {
+ event->modifier = MODIFIER_SHIFT;
+ event->modifierKeycode = 0x10;
+}
+
++ (void) addControlModifier:(struct KeyEvent*)event {
+ event->modifier = MODIFIER_CTRL;
+ event->modifierKeycode = 0x11;
+}
+
++ (void) addMetaModifier:(struct KeyEvent*)event {
+ event->modifier = MODIFIER_META;
+ event->modifierKeycode = 0x5B;
+}
+
++ (void) addAltModifier:(struct KeyEvent*)event {
+ event->modifier = MODIFIER_ALT;
+ event->modifierKeycode = 0x12;
+}
+
+@end
diff --git a/Limelight/Input/OnScreenControls.h b/Limelight/Input/OnScreenControls.h
index 23ee57b..29997de 100644
--- a/Limelight/Input/OnScreenControls.h
+++ b/Limelight/Input/OnScreenControls.h
@@ -7,9 +7,9 @@
//
#import
-#import "StreamView.h"
@class ControllerSupport;
+@class StreamConfiguration;
@interface OnScreenControls : NSObject
@@ -22,12 +22,15 @@ typedef NS_ENUM(NSInteger, OnScreenControlsLevel) {
// Internal levels selected by ControllerSupport
OnScreenControlsLevelAutoGCGamepad,
OnScreenControlsLevelAutoGCExtendedGamepad,
+ OnScreenControlsLevelAutoGCExtendedGamepadWithStickButtons
};
-- (id) initWithView:(UIView*)view controllerSup:(ControllerSupport*)controllerSupport swipeDelegate:(id)edgeDelegate;
+- (id) initWithView:(UIView*)view controllerSup:(ControllerSupport*)controllerSupport streamConfig:(StreamConfiguration*)streamConfig;
- (BOOL) handleTouchDownEvent:(NSSet*)touches;
- (BOOL) handleTouchUpEvent:(NSSet*)touches;
- (BOOL) handleTouchMovedEvent:(NSSet*)touches;
- (void) setLevel:(OnScreenControlsLevel)level;
+- (OnScreenControlsLevel) getLevel;
+- (void) show;
@end
diff --git a/Limelight/Input/OnScreenControls.m b/Limelight/Input/OnScreenControls.m
index c02729f..846492a 100644
--- a/Limelight/Input/OnScreenControls.m
+++ b/Limelight/Input/OnScreenControls.m
@@ -7,13 +7,11 @@
//
#import "OnScreenControls.h"
+#import "StreamView.h"
#import "ControllerSupport.h"
-//#import "Controller.h"
+#import "Controller.h"
#include "Limelight.h"
-#import "Moonlight-Swift.h"
-@class Controller;
-
#define UPDATE_BUTTON(x, y) (buttonFlags = \
(y) ? (buttonFlags | (x)) : (buttonFlags & ~(x)))
@@ -38,7 +36,6 @@
CALayer* _l1Button;
CALayer* _l2Button;
CALayer* _l3Button;
- CALayer* _edge;
UITouch* _aTouch;
UITouch* _bTouch;
@@ -55,7 +52,6 @@
UITouch* _l1Touch;
UITouch* _l2Touch;
UITouch* _l3Touch;
- UITouch* _edgeTouch;
NSDate* l3TouchStart;
NSDate* r3TouchStart;
@@ -67,11 +63,12 @@
CGRect _controlArea;
UIView* _view;
OnScreenControlsLevel _level;
+ BOOL _visible;
ControllerSupport *_controllerSupport;
Controller *_controller;
- id _edgeDelegate;
NSMutableArray* _deadTouches;
+ BOOL _swapABXY;
}
static const float EDGE_WIDTH = .05;
@@ -115,15 +112,15 @@ static float L2_Y;
static float L3_X;
static float L3_Y;
-- (id) initWithView:(UIView*)view controllerSup:(ControllerSupport*)controllerSupport swipeDelegate:(id)swipeDelegate {
+- (id) initWithView:(UIView*)view controllerSup:(ControllerSupport*)controllerSupport streamConfig:(StreamConfiguration*)streamConfig {
self = [self init];
_view = view;
_controllerSupport = controllerSupport;
_controller = [controllerSupport getOscController];
- _edgeDelegate = swipeDelegate;
_deadTouches = [[NSMutableArray alloc] init];
+ _swapABXY = streamConfig.swapABXYButtons;
- _iPad = (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad);
+ _iPad = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad);
_controlArea = CGRectMake(0, 0, _view.frame.size.width, _view.frame.size.height);
if (_iPad)
{
@@ -157,16 +154,28 @@ static float L3_Y;
_rightStickBackground = [CALayer layer];
_leftStick = [CALayer layer];
_rightStick = [CALayer layer];
- _edge = [CALayer layer];
-
- [self setupEdgeDetection];
return self;
}
+- (void) show {
+ _visible = YES;
+
+ [self updateControls];
+}
+
- (void) setLevel:(OnScreenControlsLevel)level {
_level = level;
- [self updateControls];
+
+ // Only update controls if we're showing, otherwise
+ // show will do it for us.
+ if (_visible) {
+ [self updateControls];
+ }
+}
+
+- (OnScreenControlsLevel) getLevel {
+ return _level;
}
- (void) updateControls {
@@ -202,15 +211,27 @@ static float L3_Y;
[self hideSticks];
[self drawL3R3];
break;
+ case OnScreenControlsLevelAutoGCExtendedGamepadWithStickButtons:
+ // This variant of GCExtendedGamepad has L3 and R3 but
+ // is still missing Select
+ [self setupExtendedGamepadControls];
+
+ [self hideButtons];
+ [self hideBumpers];
+ [self hideTriggers];
+ [self hideL3R3];
+ [self drawStartSelect];
+ [self hideSticks];
+ break;
case OnScreenControlsLevelSimple:
[self setupSimpleControls];
- [self drawTriggers];
- [self drawStartSelect];
- [self drawL3R3];
- [self hideButtons];
+ [self hideTriggers];
+ [self hideL3R3];
[self hideBumpers];
[self hideSticks];
+ [self drawStartSelect];
+ [self drawButtons];
break;
case OnScreenControlsLevelFull:
[self setupComplexControls];
@@ -228,18 +249,27 @@ static float L3_Y;
}
}
-- (void) setupEdgeDetection {
- _edge.frame = CGRectMake(0, 0, _view.frame.size.width * EDGE_WIDTH, _view.frame.size.height);
- [_view.layer addSublayer:_edge];
-}
-
// For GCExtendedGamepad controls we move start, select, L3, and R3 to the button
- (void) setupExtendedGamepadControls {
// Start with the default complex layout
[self setupComplexControls];
+ START_X = _controlArea.size.width * .95 + _controlArea.origin.x;
START_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
+ SELECT_X = _controlArea.size.width * .05 + _controlArea.origin.x;
SELECT_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
+
+ L3_Y = _controlArea.size.height * .85 + _controlArea.origin.y;
+ R3_Y = _controlArea.size.height * .85 + _controlArea.origin.y;
+
+ if (_iPad) {
+ L3_X = _controlArea.size.width * .15 + _controlArea.origin.x;
+ R3_X = _controlArea.size.width * .85 + _controlArea.origin.x;
+ }
+ else {
+ L3_X = _controlArea.size.width * .25 + _controlArea.origin.x;
+ R3_X = _controlArea.size.width * .75 + _controlArea.origin.x;
+ }
}
// For GCGamepad controls we move triggers, start, and select
@@ -248,41 +278,21 @@ static float L3_Y;
// Start with the default complex layout
[self setupComplexControls];
- START_X = _controlArea.size.width * .6 + _controlArea.origin.x;
- START_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
+ L2_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
+ L2_X = _controlArea.size.width * .05 + _controlArea.origin.x;
- SELECT_X = _controlArea.size.width * .4 + _controlArea.origin.x;
- SELECT_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
+ R2_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
+ R2_X = _controlArea.size.width * .95 + _controlArea.origin.x;
- L2_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
- L2_X = _controlArea.size.width * .1 + _controlArea.origin.x;
-
- R2_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
- R2_X = _controlArea.size.width * .9 + _controlArea.origin.x;
+ START_X = _controlArea.size.width * .95 + _controlArea.origin.x;
+ START_Y = _controlArea.size.height * .95 + _controlArea.origin.y;
+ SELECT_X = _controlArea.size.width * .05 + _controlArea.origin.x;
+ SELECT_Y = _controlArea.size.height * .95 + _controlArea.origin.y;
if (_iPad) {
- START_X = _controlArea.size.width * .75 + _controlArea.origin.x;
- START_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
-
- SELECT_X = _controlArea.size.width * .25 + _controlArea.origin.x;
- SELECT_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
-
// The analog sticks are kept closer to the sides on iPad
- LS_CENTER_X = _controlArea.size.width * .18 + _controlArea.origin.x;
- LS_CENTER_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
- RS_CENTER_X = _controlArea.size.width * .82 + _controlArea.origin.x;
- RS_CENTER_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
- } else {
- START_X = _controlArea.size.width * .6 + _controlArea.origin.x;
- START_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
-
- SELECT_X = _controlArea.size.width * .4 + _controlArea.origin.x;
- SELECT_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
-
- LS_CENTER_X = _controlArea.size.width * .25 + _controlArea.origin.x;
- LS_CENTER_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
- RS_CENTER_X = _controlArea.size.width * .75 + _controlArea.origin.x;
- RS_CENTER_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
+ LS_CENTER_X = _controlArea.size.width * .15 + _controlArea.origin.x;
+ RS_CENTER_X = _controlArea.size.width * .85 + _controlArea.origin.x;
}
}
@@ -291,10 +301,7 @@ static float L3_Y;
// Start with the default complex layout
[self setupComplexControls];
- START_X = _controlArea.size.width * .6 + _controlArea.origin.x;
START_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
-
- SELECT_X = _controlArea.size.width * .4 + _controlArea.origin.x;
SELECT_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
L2_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
@@ -302,6 +309,20 @@ static float L3_Y;
R2_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
R2_X = _controlArea.size.width * .9 + _controlArea.origin.x;
+
+ if (_iPad) {
+ // Lower the D-pad and buttons on iPad
+ D_PAD_CENTER_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
+ BUTTON_CENTER_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
+
+ // Move Start and Select closer to sides
+ SELECT_X = _controlArea.size.width * .2 + _controlArea.origin.x;
+ START_X = _controlArea.size.width * .8 + _controlArea.origin.x;
+ }
+ else {
+ SELECT_X = _controlArea.size.width * .4 + _controlArea.origin.x;
+ START_X = _controlArea.size.width * .6 + _controlArea.origin.x;
+ }
}
- (void) setupComplexControls
@@ -315,9 +336,9 @@ static float L3_Y;
{
// The analog sticks are kept closer to the sides on iPad
LS_CENTER_X = _controlArea.size.width * .22 + _controlArea.origin.x;
- LS_CENTER_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
+ LS_CENTER_Y = _controlArea.size.height * .80 + _controlArea.origin.y;
RS_CENTER_X = _controlArea.size.width * .77 + _controlArea.origin.x;
- RS_CENTER_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
+ RS_CENTER_Y = _controlArea.size.height * .80 + _controlArea.origin.y;
}
else
{
@@ -332,42 +353,54 @@ static float L3_Y;
SELECT_X = _controlArea.size.width * .1 + _controlArea.origin.x;
SELECT_Y = _controlArea.size.height * .9 + _controlArea.origin.y;
- L1_X = _controlArea.size.width * .1 + _controlArea.origin.x;
L1_Y = _controlArea.size.height * .27 + _controlArea.origin.y;
- L2_X = _controlArea.size.width * .1 + _controlArea.origin.x;
L2_Y = _controlArea.size.height * .1 + _controlArea.origin.y;
- R1_X = _controlArea.size.width * .9 + _controlArea.origin.x;
R1_Y = _controlArea.size.height * .27 + _controlArea.origin.y;
- R2_X = _controlArea.size.width * .9 + _controlArea.origin.x;
R2_Y = _controlArea.size.height * .1 + _controlArea.origin.y;
- L3_X = _controlArea.size.width * .25 + _controlArea.origin.x;
- L3_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
- R3_X = _controlArea.size.width * .75 + _controlArea.origin.x;
- R3_Y = _controlArea.size.height * .75 + _controlArea.origin.y;
+
+ if (_iPad) {
+ // Move L/R buttons closer to the side on iPad
+ L1_X = _controlArea.size.width * .05 + _controlArea.origin.x;
+ L2_X = _controlArea.size.width * .05 + _controlArea.origin.x;
+ R1_X = _controlArea.size.width * .95 + _controlArea.origin.x;
+ R2_X = _controlArea.size.width * .95 + _controlArea.origin.x;
+ }
+ else {
+ L1_X = _controlArea.size.width * .1 + _controlArea.origin.x;
+ L2_X = _controlArea.size.width * .1 + _controlArea.origin.x;
+ R1_X = _controlArea.size.width * .9 + _controlArea.origin.x;
+ R2_X = _controlArea.size.width * .9 + _controlArea.origin.x;
+ }
}
- (void) drawButtons {
- // create A button
UIImage* aButtonImage = [UIImage imageNamed:@"AButton"];
+ UIImage* bButtonImage = [UIImage imageNamed:@"BButton"];
+ UIImage* xButtonImage = [UIImage imageNamed:@"XButton"];
+ UIImage* yButtonImage = [UIImage imageNamed:@"YButton"];
+
+ CGRect aButtonFrame = CGRectMake(BUTTON_CENTER_X - aButtonImage.size.width / 2, BUTTON_CENTER_Y + BUTTON_DIST, aButtonImage.size.width, aButtonImage.size.height);
+ CGRect bButtonFrame = CGRectMake(BUTTON_CENTER_X + BUTTON_DIST, BUTTON_CENTER_Y - bButtonImage.size.height / 2, bButtonImage.size.width, bButtonImage.size.height);
+ CGRect xButtonFrame = CGRectMake(BUTTON_CENTER_X - BUTTON_DIST - xButtonImage.size.width, BUTTON_CENTER_Y - xButtonImage.size.height/ 2, xButtonImage.size.width, xButtonImage.size.height);
+ CGRect yButtonFrame = CGRectMake(BUTTON_CENTER_X - yButtonImage.size.width / 2, BUTTON_CENTER_Y - BUTTON_DIST - yButtonImage.size.height, yButtonImage.size.width, yButtonImage.size.height);
+
+ // create A button
_aButton.contents = (id) aButtonImage.CGImage;
- _aButton.frame = CGRectMake(BUTTON_CENTER_X - aButtonImage.size.width / 2, BUTTON_CENTER_Y + BUTTON_DIST, aButtonImage.size.width, aButtonImage.size.height);
+ _aButton.frame = _swapABXY ? bButtonFrame : aButtonFrame;
[_view.layer addSublayer:_aButton];
// create B button
- UIImage* bButtonImage = [UIImage imageNamed:@"BButton"];
- _bButton.frame = CGRectMake(BUTTON_CENTER_X + BUTTON_DIST, BUTTON_CENTER_Y - bButtonImage.size.height / 2, bButtonImage.size.width, bButtonImage.size.height);
+ _bButton.frame = _swapABXY ? aButtonFrame : bButtonFrame;
_bButton.contents = (id) bButtonImage.CGImage;
[_view.layer addSublayer:_bButton];
// create X Button
- UIImage* xButtonImage = [UIImage imageNamed:@"XButton"];
- _xButton.frame = CGRectMake(BUTTON_CENTER_X - BUTTON_DIST - xButtonImage.size.width, BUTTON_CENTER_Y - xButtonImage.size.height/ 2, xButtonImage.size.width, xButtonImage.size.height);
+ _xButton.frame = _swapABXY ? yButtonFrame : xButtonFrame;
_xButton.contents = (id) xButtonImage.CGImage;
[_view.layer addSublayer:_xButton];
// create Y Button
- UIImage* yButtonImage = [UIImage imageNamed:@"YButton"];
- _yButton.frame = CGRectMake(BUTTON_CENTER_X - yButtonImage.size.width / 2, BUTTON_CENTER_Y - BUTTON_DIST - yButtonImage.size.height, yButtonImage.size.width, yButtonImage.size.height);
+ _yButton.frame = _swapABXY ? xButtonFrame : yButtonFrame;
_yButton.contents = (id) yButtonImage.CGImage;
[_view.layer addSublayer:_yButton];
@@ -621,7 +654,7 @@ static float L3_Y;
if (updated) {
[_controllerSupport updateFinished:_controller];
}
- return updated || buttonTouch;
+ return updated || buttonTouch;
}
- (BOOL)handleTouchDownEvent:touches {
@@ -630,63 +663,63 @@ static float L3_Y;
for (UITouch* touch in touches) {
CGPoint touchLocation = [touch locationInView:_view];
- if ([_aButton.presentationLayer hitTest:touchLocation]) {
+ if (_aButton.superlayer != nil && [_aButton.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:A_FLAG];
_aTouch = touch;
updated = true;
- } else if ([_bButton.presentationLayer hitTest:touchLocation]) {
+ } else if (_bButton.superlayer != nil && [_bButton.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:B_FLAG];
_bTouch = touch;
updated = true;
- } else if ([_xButton.presentationLayer hitTest:touchLocation]) {
+ } else if (_xButton.superlayer != nil && [_xButton.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:X_FLAG];
_xTouch = touch;
updated = true;
- } else if ([_yButton.presentationLayer hitTest:touchLocation]) {
+ } else if (_yButton.superlayer != nil && [_yButton.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:Y_FLAG];
_yTouch = touch;
updated = true;
- } else if ([_upButton.presentationLayer hitTest:touchLocation]) {
+ } else if (_upButton.superlayer != nil && [_upButton.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:UP_FLAG];
_dpadTouch = touch;
updated = true;
- } else if ([_downButton.presentationLayer hitTest:touchLocation]) {
+ } else if (_downButton.superlayer != nil && [_downButton.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:DOWN_FLAG];
_dpadTouch = touch;
updated = true;
- } else if ([_leftButton.presentationLayer hitTest:touchLocation]) {
+ } else if (_leftButton.superlayer != nil && [_leftButton.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:LEFT_FLAG];
_dpadTouch = touch;
updated = true;
- } else if ([_rightButton.presentationLayer hitTest:touchLocation]) {
+ } else if (_rightButton.superlayer != nil && [_rightButton.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:RIGHT_FLAG];
_dpadTouch = touch;
updated = true;
- } else if ([_startButton.presentationLayer hitTest:touchLocation]) {
+ } else if (_startButton.superlayer != nil && [_startButton.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:PLAY_FLAG];
_startTouch = touch;
updated = true;
- } else if ([_selectButton.presentationLayer hitTest:touchLocation]) {
+ } else if (_selectButton.superlayer != nil && [_selectButton.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:BACK_FLAG];
_selectTouch = touch;
updated = true;
- } else if ([_l1Button.presentationLayer hitTest:touchLocation]) {
+ } else if (_l1Button.superlayer != nil && [_l1Button.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:LB_FLAG];
_l1Touch = touch;
updated = true;
- } else if ([_r1Button.presentationLayer hitTest:touchLocation]) {
+ } else if (_r1Button.superlayer != nil && [_r1Button.presentationLayer hitTest:touchLocation]) {
[_controllerSupport setButtonFlag:_controller flags:RB_FLAG];
_r1Touch = touch;
updated = true;
- } else if ([_l2Button.presentationLayer hitTest:touchLocation]) {
+ } else if (_l2Button.superlayer != nil && [_l2Button.presentationLayer hitTest:touchLocation]) {
[_controllerSupport updateLeftTrigger:_controller left:0xFF];
_l2Touch = touch;
updated = true;
- } else if ([_r2Button.presentationLayer hitTest:touchLocation]) {
+ } else if (_r2Button.superlayer != nil && [_r2Button.presentationLayer hitTest:touchLocation]) {
[_controllerSupport updateRightTrigger:_controller right:0xFF];
_r2Touch = touch;
updated = true;
- } else if ([_l3Button.presentationLayer hitTest:touchLocation]) {
+ } else if (_l3Button.superlayer != nil && [_l3Button.presentationLayer hitTest:touchLocation]) {
if (l3Set) {
[_controllerSupport clearButtonFlag:_controller flags:LS_CLK_FLAG];
_l3Button.borderWidth = 0.0f;
@@ -697,7 +730,7 @@ static float L3_Y;
l3Set = !l3Set;
_l3Touch = touch;
updated = true;
- } else if ([_r3Button.presentationLayer hitTest:touchLocation]) {
+ } else if (_r3Button.superlayer != nil && [_r3Button.presentationLayer hitTest:touchLocation]) {
if (r3Set) {
[_controllerSupport clearButtonFlag:_controller flags:RS_CLK_FLAG];
_r3Button.borderWidth = 0.0f;
@@ -708,7 +741,7 @@ static float L3_Y;
r3Set = !r3Set;
_r3Touch = touch;
updated = true;
- } else if ([_leftStick.presentationLayer hitTest:touchLocation]) {
+ } else if (_leftStick.superlayer != nil && [_leftStick.presentationLayer hitTest:touchLocation]) {
if (l3TouchStart != nil) {
// Find elapsed time and convert to milliseconds
// Use (-) modifier to conversion since receiver is earlier than now
@@ -720,7 +753,7 @@ static float L3_Y;
}
_lsTouch = touch;
stickTouch = true;
- } else if ([_rightStick.presentationLayer hitTest:touchLocation]) {
+ } else if (_rightStick.superlayer != nil && [_rightStick.presentationLayer hitTest:touchLocation]) {
if (r3TouchStart != nil) {
// Find elapsed time and convert to milliseconds
// Use (-) modifier to conversion since receiver is earlier than now
@@ -732,8 +765,6 @@ static float L3_Y;
}
_rsTouch = touch;
stickTouch = true;
- } else if ([_edge.presentationLayer hitTest:touchLocation]) {
- _edgeTouch = touch;
}
if (!updated && !stickTouch && [self isInDeadZone:touch]) {
[_deadTouches addObject:touch];
@@ -817,11 +848,6 @@ static float L3_Y;
else if (touch == _r3Touch) {
_r3Touch = nil;
touched = true;
- } else if (touch == _edgeTouch) {
- _edgeTouch = nil;
- if ([touch locationInView:_view].x >= _view.frame.size.width / 4) {
- [_edgeDelegate edgeSwiped];
- }
}
if ([_deadTouches containsObject:touch]) {
[_deadTouches removeObject:touch];
@@ -836,26 +862,31 @@ static float L3_Y;
}
- (BOOL) isInDeadZone:(UITouch*) touch {
- switch (_level) {
- case OnScreenControlsLevelFull:
- return [self isDpadDeadZone:touch]
- || [self isAbxyDeadZone:touch]
- || [self isTriggerDeadZone:touch]
- || [self isBumperDeadZone:touch]
- || [self isStartSelectDeadZone:touch];
- case OnScreenControlsLevelSimple:
- return [self isTriggerDeadZone:touch]
- || [self isStartSelectDeadZone:touch]
- || [self isL3R3DeadZone:touch];
- case OnScreenControlsLevelAutoGCExtendedGamepad:
- return [self isL3R3DeadZone:touch]
- || [self isStartSelectDeadZone:touch];
- case OnScreenControlsLevelAutoGCGamepad:
- return [self isTriggerDeadZone:touch]
- || [self isStartSelectDeadZone:touch];
- default:
- return false;
+ // Dynamically evaluate deadzones based on the controls
+ // on screen at the time
+ if (_leftButton.superlayer != nil && [self isDpadDeadZone:touch]) {
+ return true;
}
+ else if (_aButton.superlayer != nil && [self isAbxyDeadZone:touch]) {
+ return true;
+ }
+ else if (_l2Button.superlayer != nil && [self isTriggerDeadZone:touch]) {
+ return true;
+ }
+ else if (_l1Button.superlayer != nil && [self isBumperDeadZone:touch]) {
+ return true;
+ }
+ else if (_startButton.superlayer != nil && [self isStartSelectDeadZone:touch]) {
+ return true;
+ }
+ else if (_l3Button.superlayer != nil && [self isL3R3DeadZone:touch]) {
+ return true;
+ }
+ else if (_leftStickBackground.superlayer != nil && [self isStickDeadZone:touch]) {
+ return true;
+ }
+
+ return false;
}
- (BOOL) isDpadDeadZone:(UITouch*) touch {
@@ -926,6 +957,19 @@ static float L3_Y;
endY:_view.frame.origin.y + _view.frame.size.height];
}
+- (BOOL) isStickDeadZone:(UITouch*) touch {
+ return [self isDeadZone:touch
+ startX:_leftStickBackground.frame.origin.x - 15
+ startY:_leftStickBackground.frame.origin.y - 15
+ endX:_leftStickBackground.frame.origin.x + _leftStickBackground.frame.size.width + 15
+ endY:_view.frame.origin.y + _view.frame.size.height]
+ || [self isDeadZone:touch
+ startX:_rightStickBackground.frame.origin.x - 15
+ startY:_rightStickBackground.frame.origin.y - 15
+ endX:_rightStickBackground.frame.origin.x + _rightStickBackground.frame.size.width + 15
+ endY:_view.frame.origin.y + _view.frame.size.height];
+}
+
- (BOOL) isDeadZone:(UITouch*) touch startX:(float)deadZoneStartX startY:(float)deadZoneStartY endX:(float)deadZoneEndX endY:(float)deadZoneEndY {
deadZoneStartX -= DEAD_ZONE_PADDING;
deadZoneStartY -= DEAD_ZONE_PADDING;
diff --git a/Limelight/Input/RelativeTouchHandler.h b/Limelight/Input/RelativeTouchHandler.h
new file mode 100644
index 0000000..2f8d9db
--- /dev/null
+++ b/Limelight/Input/RelativeTouchHandler.h
@@ -0,0 +1,19 @@
+//
+// RelativeTouchHandler.h
+// Moonlight
+//
+// Created by Cameron Gutman on 11/1/20.
+// Copyright © 2020 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#import "StreamView.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface RelativeTouchHandler : UIResponder
+
+-(id)initWithView:(StreamView*)view;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Limelight/Input/RelativeTouchHandler.m b/Limelight/Input/RelativeTouchHandler.m
new file mode 100644
index 0000000..64d7da4
--- /dev/null
+++ b/Limelight/Input/RelativeTouchHandler.m
@@ -0,0 +1,207 @@
+//
+// RelativeTouchHandler.m
+// Moonlight
+//
+// Created by Cameron Gutman on 11/1/20.
+// Copyright © 2020 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#import "RelativeTouchHandler.h"
+
+#include
+
+static const int REFERENCE_WIDTH = 1280;
+static const int REFERENCE_HEIGHT = 720;
+
+@implementation RelativeTouchHandler {
+ CGPoint touchLocation, originalLocation;
+ BOOL touchMoved;
+ BOOL isDragging;
+ NSTimer* dragTimer;
+ NSUInteger peakTouchCount;
+
+#if TARGET_OS_TV
+ UIGestureRecognizer* remotePressRecognizer;
+ UIGestureRecognizer* remoteLongPressRecognizer;
+#endif
+
+ UIView* view;
+}
+
+- (id)initWithView:(StreamView*)view {
+ self = [self init];
+ self->view = view;
+
+#if TARGET_OS_TV
+ remotePressRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(remoteButtonPressed:)];
+ remotePressRecognizer.allowedPressTypes = @[@(UIPressTypeSelect)];
+
+ remoteLongPressRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(remoteButtonLongPressed:)];
+ remoteLongPressRecognizer.allowedPressTypes = @[@(UIPressTypeSelect)];
+
+ [self->view addGestureRecognizer:remotePressRecognizer];
+ [self->view addGestureRecognizer:remoteLongPressRecognizer];
+#endif
+
+ return self;
+}
+
+- (BOOL)isConfirmedMove:(CGPoint)currentPoint from:(CGPoint)originalPoint {
+ // Movements of greater than 5 pixels are considered confirmed
+ return hypotf(originalPoint.x - currentPoint.x, originalPoint.y - currentPoint.y) >= 5;
+}
+
+- (void)onDragStart:(NSTimer*)timer {
+ if (!touchMoved && !isDragging){
+ isDragging = true;
+ LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
+ }
+}
+
+- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
+ touchMoved = false;
+ peakTouchCount = [[event allTouches] count];
+ if ([[event allTouches] count] == 1) {
+ UITouch *touch = [[event allTouches] anyObject];
+ originalLocation = touchLocation = [touch locationInView:view];
+ if (!isDragging) {
+ dragTimer = [NSTimer scheduledTimerWithTimeInterval:0.650
+ target:self
+ selector:@selector(onDragStart:)
+ userInfo:nil
+ repeats:NO];
+ }
+ }
+ else if ([[event allTouches] count] == 2) {
+ CGPoint firstLocation = [[[[event allTouches] allObjects] objectAtIndex:0] locationInView:view];
+ CGPoint secondLocation = [[[[event allTouches] allObjects] objectAtIndex:1] locationInView:view];
+
+ originalLocation = touchLocation = CGPointMake((firstLocation.x + secondLocation.x) / 2, (firstLocation.y + secondLocation.y) / 2);
+ }
+}
+
+- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
+ if ([[event allTouches] count] == 1) {
+ UITouch *touch = [[event allTouches] anyObject];
+ CGPoint currentLocation = [touch locationInView:view];
+
+ if (touchLocation.x != currentLocation.x ||
+ touchLocation.y != currentLocation.y)
+ {
+ int deltaX = (currentLocation.x - touchLocation.x) * (REFERENCE_WIDTH / view.bounds.size.width);
+ int deltaY = (currentLocation.y - touchLocation.y) * (REFERENCE_HEIGHT / view.bounds.size.height);
+
+ if (deltaX != 0 || deltaY != 0) {
+ LiSendMouseMoveEvent(deltaX, deltaY);
+ touchLocation = currentLocation;
+
+ // If we've moved far enough to confirm this wasn't just human/machine error,
+ // mark it as such.
+ if ([self isConfirmedMove:touchLocation from:originalLocation]) {
+ touchMoved = true;
+ }
+ }
+ }
+ } else if ([[event allTouches] count] == 2) {
+ CGPoint firstLocation = [[[[event allTouches] allObjects] objectAtIndex:0] locationInView:view];
+ CGPoint secondLocation = [[[[event allTouches] allObjects] objectAtIndex:1] locationInView:view];
+
+ CGPoint avgLocation = CGPointMake((firstLocation.x + secondLocation.x) / 2, (firstLocation.y + secondLocation.y) / 2);
+ if (touchLocation.y != avgLocation.y) {
+ LiSendHighResScrollEvent((avgLocation.y - touchLocation.y) * 10);
+ }
+
+ // If we've moved far enough to confirm this wasn't just human/machine error,
+ // mark it as such.
+ if ([self isConfirmedMove:firstLocation from:originalLocation]) {
+ touchMoved = true;
+ }
+
+ touchLocation = avgLocation;
+ }
+}
+
+- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
+ [dragTimer invalidate];
+ dragTimer = nil;
+ if (isDragging) {
+ isDragging = false;
+ LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
+ } else if (!touchMoved) {
+ if (peakTouchCount == 2) {
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
+ Log(LOG_D, @"Sending right mouse button press");
+
+ LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_RIGHT);
+
+ // Wait 100 ms to simulate a real button press
+ usleep(100 * 1000);
+
+ LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
+ });
+ } else if (peakTouchCount == 1) {
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
+ if (!self->isDragging){
+ Log(LOG_D, @"Sending left mouse button press");
+
+ LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
+
+ // Wait 100 ms to simulate a real button press
+ usleep(100 * 1000);
+ }
+ self->isDragging = false;
+ LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
+ });
+ }
+ }
+
+ // We we're moving from 2+ touches to 1. Synchronize the current position
+ // of the active finger so we don't jump unexpectedly on the next touchesMoved
+ // callback when finger 1 switches on us.
+ if ([[event allTouches] count] - [touches count] == 1) {
+ NSMutableSet *activeSet = [[NSMutableSet alloc] initWithCapacity:[[event allTouches] count]];
+ [activeSet unionSet:[event allTouches]];
+ [activeSet minusSet:touches];
+ touchLocation = [[activeSet anyObject] locationInView:view];
+
+ // Mark this touch as moved so we don't send a left mouse click if the user
+ // right clicks without moving their other finger.
+ touchMoved = true;
+ }
+}
+
+- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
+ [dragTimer invalidate];
+ dragTimer = nil;
+ if (isDragging) {
+ isDragging = false;
+ LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
+ }
+ peakTouchCount = 0;
+}
+
+#if TARGET_OS_TV
+- (void)remoteButtonPressed:(id)sender {
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
+ Log(LOG_D, @"Sending left mouse button press");
+
+ // Mark this as touchMoved to avoid a duplicate press on touch up
+ self->touchMoved = true;
+
+ LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
+
+ // Wait 100 ms to simulate a real button press
+ usleep(100 * 1000);
+
+ LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
+ });
+}
+- (void)remoteButtonLongPressed:(id)sender {
+ Log(LOG_D, @"Holding left mouse button");
+
+ isDragging = true;
+ LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
+}
+#endif
+
+@end
diff --git a/Limelight/Input/StreamView.h b/Limelight/Input/StreamView.h
index b18c813..9f50010 100644
--- a/Limelight/Input/StreamView.h
+++ b/Limelight/Input/StreamView.h
@@ -6,18 +6,32 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
#import "ControllerSupport.h"
+#import "OnScreenControls.h"
+#import "Moonlight-Swift.h"
+#import "StreamConfiguration.h"
-@protocol EdgeDetectionDelegate
+@protocol UserInteractionDelegate
-- (void) edgeSwiped;
+- (void) userInteractionBegan;
+- (void) userInteractionEnded;
@end
-@interface StreamView : UIView
+#if TARGET_OS_TV
+@interface StreamView : UIView
+#else
+@interface StreamView : UIView
+#endif
-- (void) setupOnScreenControls:(ControllerSupport*)controllerSupport swipeDelegate:(id)swipeDelegate;
-- (void) setMouseDeltaFactors:(float)x y:(float)y;
+- (void) setupStreamView:(ControllerSupport*)controllerSupport
+ interactionDelegate:(id)interactionDelegate
+ config:(StreamConfiguration*)streamConfig;
+- (void) showOnScreenControls;
+- (OnScreenControlsLevel) getCurrentOscState;
+
+#if !TARGET_OS_TV
+- (void) updateCursorLocation:(CGPoint)location isMouse:(BOOL)isMouse;
+#endif
@end
diff --git a/Limelight/Input/StreamView.m b/Limelight/Input/StreamView.m
index 1a7a370..7dedeb3 100644
--- a/Limelight/Input/StreamView.m
+++ b/Limelight/Input/StreamView.m
@@ -8,172 +8,951 @@
#import "StreamView.h"
#include
-#import "OnScreenControls.h"
#import "DataManager.h"
#import "ControllerSupport.h"
+#import "KeyboardSupport.h"
+#import "RelativeTouchHandler.h"
+#import "AbsoluteTouchHandler.h"
+#import "KeyboardInputField.h"
+
+static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
@implementation StreamView {
- CGPoint touchLocation, originalLocation;
- BOOL touchMoved;
OnScreenControls* onScreenControls;
- BOOL isDragging;
- NSTimer* dragTimer;
+ KeyboardInputField* keyInputField;
+ BOOL isInputingText;
+ NSMutableSet* keysDown;
- float xDeltaFactor;
- float yDeltaFactor;
- float screenFactor;
+ float streamAspectRatio;
+
+ // iOS 13.4 mouse support
+ NSInteger lastMouseButtonMask;
+ float lastMouseX;
+ float lastMouseY;
+ CGPoint lastScrollTranslation;
+
+ // Citrix X1 mouse support
+ X1Mouse* x1mouse;
+ double accumulatedMouseDeltaX;
+ double accumulatedMouseDeltaY;
+
+ UIResponder* touchHandler;
+
+ id interactionDelegate;
+ NSTimer* interactionTimer;
+ BOOL hasUserInteracted;
+
+ NSDictionary *dictCodes;
}
-- (void) setMouseDeltaFactors:(float)x y:(float)y {
- xDeltaFactor = x;
- yDeltaFactor = y;
+- (void) setupStreamView:(ControllerSupport*)controllerSupport
+ interactionDelegate:(id)interactionDelegate
+ config:(StreamConfiguration*)streamConfig {
+ self->interactionDelegate = interactionDelegate;
+ self->streamAspectRatio = (float)streamConfig.width / (float)streamConfig.height;
- screenFactor = [[UIScreen mainScreen] scale];
-}
-
-- (void) setupOnScreenControls:(ControllerSupport*)controllerSupport swipeDelegate:(id)swipeDelegate {
- onScreenControls = [[OnScreenControls alloc] initWithView:self controllerSup:controllerSupport swipeDelegate:swipeDelegate];
- DataManager* dataMan = [[DataManager alloc] init];
- OnScreenControlsLevel level = (OnScreenControlsLevel)[[dataMan getSettings].onscreenControls integerValue];
+ TemporarySettings* settings = [[[DataManager alloc] init] getSettings];
- if (level == OnScreenControlsLevelAuto) {
+ keysDown = [[NSMutableSet alloc] init];
+ keyInputField = [[KeyboardInputField alloc] initWithFrame:CGRectZero];
+ [keyInputField setKeyboardType:UIKeyboardTypeDefault];
+ [keyInputField setAutocorrectionType:UITextAutocorrectionTypeNo];
+ [keyInputField setAutocapitalizationType:UITextAutocapitalizationTypeNone];
+ [keyInputField setSpellCheckingType:UITextSpellCheckingTypeNo];
+ [self addSubview:keyInputField];
+
+#if TARGET_OS_TV
+ // tvOS requires RelativeTouchHandler to manage Apple Remote input
+ self->touchHandler = [[RelativeTouchHandler alloc] initWithView:self];
+#else
+ // iOS uses RelativeTouchHandler or AbsoluteTouchHandler depending on user preference
+ if (settings.absoluteTouchMode) {
+ self->touchHandler = [[AbsoluteTouchHandler alloc] initWithView:self];
+ }
+ else {
+ self->touchHandler = [[RelativeTouchHandler alloc] initWithView:self];
+ }
+
+ onScreenControls = [[OnScreenControls alloc] initWithView:self controllerSup:controllerSupport streamConfig:streamConfig];
+ OnScreenControlsLevel level = (OnScreenControlsLevel)[settings.onscreenControls integerValue];
+ if (settings.absoluteTouchMode) {
+ Log(LOG_I, @"On-screen controls disabled in absolute touch mode");
+ [onScreenControls setLevel:OnScreenControlsLevelOff];
+ }
+ else if (level == OnScreenControlsLevelAuto) {
[controllerSupport initAutoOnScreenControlMode:onScreenControls];
}
else {
Log(LOG_I, @"Setting manual on-screen controls level: %d", (int)level);
[onScreenControls setLevel:level];
}
+
+ // It would be nice to just use GCMouse on iOS 14+ and the older API on iOS 13
+ // but unfortunately that isn't possible today. GCMouse doesn't recognize many
+ // mice correctly, but UIKit does. We will register for both and ignore UIKit
+ // events if a GCMouse is connected.
+ if (@available(iOS 13.4, *)) {
+ [self addInteraction:[[UIPointerInteraction alloc] initWithDelegate:self]];
+
+ UIPanGestureRecognizer *discreteMouseWheelRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(mouseWheelMovedDiscrete:)];
+ discreteMouseWheelRecognizer.maximumNumberOfTouches = 0;
+ discreteMouseWheelRecognizer.allowedScrollTypesMask = UIScrollTypeMaskDiscrete;
+ discreteMouseWheelRecognizer.allowedTouchTypes = @[@(UITouchTypeIndirectPointer)];
+ [self addGestureRecognizer:discreteMouseWheelRecognizer];
+
+ UIPanGestureRecognizer *continuousMouseWheelRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(mouseWheelMovedContinuous:)];
+ continuousMouseWheelRecognizer.maximumNumberOfTouches = 0;
+ continuousMouseWheelRecognizer.allowedScrollTypesMask = UIScrollTypeMaskContinuous;
+ continuousMouseWheelRecognizer.allowedTouchTypes = @[@(UITouchTypeIndirectPointer)];
+ [self addGestureRecognizer:continuousMouseWheelRecognizer];
+ }
+
+#if defined(__IPHONE_16_1) || defined(__TVOS_16_1)
+ if (@available(iOS 16.1, *)) {
+ UIHoverGestureRecognizer *stylusHoverRecognizer = [[UIHoverGestureRecognizer alloc] initWithTarget:self action:@selector(sendStylusHoverEvent:)];
+ stylusHoverRecognizer.allowedTouchTypes = @[@(UITouchTypePencil)];
+ [self addGestureRecognizer:stylusHoverRecognizer];
+ }
+#endif
+#endif
+
+ x1mouse = [[X1Mouse alloc] init];
+ x1mouse.delegate = self;
+
+ if (settings.btMouseSupport) {
+ [x1mouse start];
+ }
+
+ // This is critical to ensure keyboard events are delivered to this
+ // StreamView and not our parent UIView, especially on tvOS.
+ [self becomeFirstResponder];
}
-- (Boolean)isConfirmedMove:(CGPoint)currentPoint from:(CGPoint)originalPoint {
- // Movements of greater than 20 pixels are considered confirmed
- return hypotf(originalPoint.x - currentPoint.x, originalPoint.y - currentPoint.y) >= 20;
+- (void)startInteractionTimer {
+ // Restart user interaction tracking
+ hasUserInteracted = NO;
+
+ BOOL timerAlreadyRunning = interactionTimer != nil;
+
+ // Start/restart the timer
+ [interactionTimer invalidate];
+ interactionTimer = [NSTimer scheduledTimerWithTimeInterval:2.0
+ target:self
+ selector:@selector(interactionTimerExpired:)
+ userInfo:nil
+ repeats:NO];
+
+ // Notify the delegate if this was a new user interaction
+ if (!timerAlreadyRunning) {
+ [interactionDelegate userInteractionBegan];
+ }
}
+- (void)interactionTimerExpired:(NSTimer *)timer {
+ if (!hasUserInteracted) {
+ // User has finished touching the screen
+ interactionTimer = nil;
+ [interactionDelegate userInteractionEnded];
+ }
+ else {
+ // User is still touching the screen. Restart the timer.
+ [self startInteractionTimer];
+ }
+}
+
+- (void) showOnScreenControls {
+#if !TARGET_OS_TV
+ [onScreenControls show];
+#endif
+}
+
+- (OnScreenControlsLevel) getCurrentOscState {
+ if (onScreenControls == nil) {
+ return OnScreenControlsLevelOff;
+ }
+ else {
+ return [onScreenControls getLevel];
+ }
+}
+
+- (CGSize) getVideoAreaSize {
+ if (self.bounds.size.width > self.bounds.size.height * streamAspectRatio) {
+ return CGSizeMake(self.bounds.size.height * streamAspectRatio, self.bounds.size.height);
+ } else {
+ return CGSizeMake(self.bounds.size.width, self.bounds.size.width / streamAspectRatio);
+ }
+}
+
+- (CGPoint) adjustCoordinatesForVideoArea:(CGPoint)point {
+ // These are now relative to the StreamView, however we need to scale them
+ // further to make them relative to the actual video portion.
+ float x = point.x - self.bounds.origin.x;
+ float y = point.y - self.bounds.origin.y;
+
+ // For some reason, we don't seem to always get to the bounds of the window
+ // so we'll subtract 1 pixel if we're to the left/below of the origin and
+ // and add 1 pixel if we're to the right/above. It should be imperceptible
+ // to the user but it will allow activation of gestures that require contact
+ // with the edge of the screen (like Aero Snap).
+ if (x < self.bounds.size.width / 2) {
+ x--;
+ }
+ else {
+ x++;
+ }
+ if (y < self.bounds.size.height / 2) {
+ y--;
+ }
+ else {
+ y++;
+ }
+
+ // This logic mimics what iOS does with AVLayerVideoGravityResizeAspect
+ CGSize videoSize = [self getVideoAreaSize];
+ CGPoint videoOrigin = CGPointMake(self.bounds.size.width / 2 - videoSize.width / 2,
+ self.bounds.size.height / 2 - videoSize.height / 2);
+
+ // Confine the cursor to the video region. We don't just discard events outside
+ // the region because we won't always get one exactly when the mouse leaves the region.
+ return CGPointMake(MIN(MAX(x, videoOrigin.x), videoOrigin.x + videoSize.width) - videoOrigin.x,
+ MIN(MAX(y, videoOrigin.y), videoOrigin.y + videoSize.height) - videoOrigin.y);
+}
+
+#if !TARGET_OS_TV
+
+- (uint16_t)getRotationFromAzimuthAngle:(float)azimuthAngle {
+ // iOS reports azimuth of 0 when the stylus is pointing west, but Moonlight expects
+ // rotation of 0 to mean the stylus is pointing north. Rotate the azimuth angle
+ // clockwise by 90 degrees to convert from iOS to Moonlight rotation conventions.
+ int32_t rotationAngle = (azimuthAngle - M_PI_2) * (180.f / M_PI);
+ if (rotationAngle < 0) {
+ rotationAngle += 360;
+ }
+ return (uint16_t)rotationAngle;
+}
+
+- (uint8_t)getTiltFromAltitudeAngle:(float)altitudeAngle {
+ // iOS reports an altitude of 0 when the stylus is parallel to the touch surface,
+ // while Moonlight expects a tilt of 0 when the stylus is perpendicular to the surface.
+ // Subtract the tilt angle from 90 to convert from iOS to Moonlight tilt conventions.
+ uint8_t altitudeDegs = abs((int16_t)(altitudeAngle * (180.f / M_PI)));
+ return 90 - MIN(90, altitudeDegs);
+}
+
+- (BOOL)sendStylusEvent:(UITouch*)event {
+ uint8_t type;
+
+ // Don't touch stylus events if the host doesn't support them. We want to pass
+ // them as normal touches for legacy hosts that don't understand pen events.
+ if (!(LiGetHostFeatureFlags() & LI_FF_PEN_TOUCH_EVENTS)) {
+ return NO;
+ }
+
+ switch (event.phase) {
+ case UITouchPhaseBegan:
+ type = LI_TOUCH_EVENT_DOWN;
+ break;
+ case UITouchPhaseMoved:
+ type = LI_TOUCH_EVENT_MOVE;
+ break;
+ case UITouchPhaseEnded:
+ type = LI_TOUCH_EVENT_UP;
+ break;
+ case UITouchPhaseCancelled:
+ type = LI_TOUCH_EVENT_CANCEL;
+ break;
+ default:
+ return YES;
+ }
+
+ CGPoint location = [self adjustCoordinatesForVideoArea:[event locationInView:self]];
+ CGSize videoSize = [self getVideoAreaSize];
+
+ return LiSendPenEvent(type, LI_TOOL_TYPE_PEN, 0, location.x / videoSize.width, location.y / videoSize.height,
+ (event.force / event.maximumPossibleForce) / sin(event.altitudeAngle),
+ 0.0f, 0.0f,
+ [self getRotationFromAzimuthAngle:[event azimuthAngleInView:self]],
+ [self getTiltFromAltitudeAngle:event.altitudeAngle]) != LI_ERR_UNSUPPORTED;
+}
+
+- (void)sendStylusHoverEvent:(UIHoverGestureRecognizer*)gesture API_AVAILABLE(ios(13.0)) {
+ uint8_t type;
+
+ switch (gesture.state) {
+ case UIGestureRecognizerStateBegan:
+ case UIGestureRecognizerStateChanged:
+ type = LI_TOUCH_EVENT_HOVER;
+ break;
+
+ case UIGestureRecognizerStateEnded:
+ type = LI_TOUCH_EVENT_HOVER_LEAVE;
+ break;
+
+ default:
+ return;
+ }
+
+ CGPoint location = [self adjustCoordinatesForVideoArea:[gesture locationInView:self]];
+ CGSize videoSize = [self getVideoAreaSize];
+
+ float distance = 0.0f;
+#if defined(__IPHONE_16_1) || defined(__TVOS_16_1)
+ if (@available(iOS 16.1, *)) {
+ distance = gesture.zOffset;
+ }
+#endif
+
+ uint16_t rotationAngle = LI_ROT_UNKNOWN;
+ uint8_t tiltAngle = LI_TILT_UNKNOWN;
+#if defined(__IPHONE_16_4) || defined(__TVOS_16_4)
+ if (@available(iOS 16.4, *)) {
+ rotationAngle = [self getRotationFromAzimuthAngle:[gesture azimuthAngleInView:self]];
+ tiltAngle = [self getTiltFromAltitudeAngle:gesture.altitudeAngle];
+ }
+#endif
+
+ LiSendPenEvent(type, LI_TOOL_TYPE_PEN, 0, location.x / videoSize.width, location.y / videoSize.height,
+ distance, 0.0f, 0.0f, rotationAngle, tiltAngle);
+}
+
+#endif
+
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
+ if ([self handleMouseButtonEvent:BUTTON_ACTION_PRESS
+ forTouches:touches
+ withEvent:event]) {
+ // If it's a mouse event, we're done
+ return;
+ }
+
Log(LOG_D, @"Touch down");
+
+ // Notify of user interaction and start expiration timer
+ [self startInteractionTimer];
+
+#if !TARGET_OS_TV
+ if (@available(iOS 13.4, *)) {
+ for (UITouch* touch in touches) {
+ if (touch.type == UITouchTypePencil) {
+ if ([self sendStylusEvent:touch]) {
+ return;
+ }
+ }
+ }
+ }
+#endif
+
if (![onScreenControls handleTouchDownEvent:touches]) {
- UITouch *touch = [[event allTouches] anyObject];
- originalLocation = touchLocation = [touch locationInView:self];
- touchMoved = false;
- if ([[event allTouches] count] == 1 && !isDragging) {
- dragTimer = [NSTimer scheduledTimerWithTimeInterval:0.650
- target:self
- selector:@selector(onDragStart:)
- userInfo:nil
- repeats:NO];
+ // We still inform the touch handler even if we're going trigger the
+ // keyboard activation gesture. This is important to ensure the touch
+ // handler has a consistent view of touch events to correctly suppress
+ // activation of one or two finger gestures when a three finger gesture
+ // is triggered.
+ [touchHandler touchesBegan:touches withEvent:event];
+
+ if ([[event allTouches] count] == 3) {
+ if (isInputingText) {
+ Log(LOG_D, @"Closing the keyboard");
+ [keyInputField resignFirstResponder];
+ isInputingText = false;
+ } else {
+ Log(LOG_D, @"Opening the keyboard");
+ // Prepare the textbox used to capture keyboard events.
+ keyInputField.delegate = self;
+ keyInputField.text = @"0";
+#if !TARGET_OS_TV
+ // Prepare the toolbar above the keyboard for more options
+ UIToolbar *customToolbarView = [[UIToolbar alloc] initWithFrame:CGRectMake(0, 0, self.bounds.size.width, 44)];
+
+ UIBarButtonItem *doneBarButton = [self createButtonWithImageNamed:@"DoneIcon.png" backgroundColor:[UIColor clearColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x00 isToggleable:NO];
+ UIBarButtonItem *windowsBarButton = [self createButtonWithImageNamed:@"WindowsIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x5B isToggleable:YES];
+ UIBarButtonItem *tabBarButton = [self createButtonWithImageNamed:@"TabIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x09 isToggleable:NO];
+ UIBarButtonItem *shiftBarButton = [self createButtonWithImageNamed:@"ShiftIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0xA0 isToggleable:YES];
+ UIBarButtonItem *escapeBarButton = [self createButtonWithImageNamed:@"EscapeIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x1B isToggleable:NO];
+ UIBarButtonItem *controlBarButton = [self createButtonWithImageNamed:@"ControlIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0xA2 isToggleable:YES];
+ UIBarButtonItem *altBarButton = [self createButtonWithImageNamed:@"AltIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0xA4 isToggleable:YES];
+ UIBarButtonItem *deleteBarButton = [self createButtonWithImageNamed:@"DeleteIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x2E isToggleable:NO];
+ UIBarButtonItem *flexibleSpace = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
+
+ [customToolbarView setItems:[NSArray arrayWithObjects:doneBarButton, windowsBarButton, escapeBarButton, tabBarButton, shiftBarButton, controlBarButton, altBarButton, deleteBarButton, flexibleSpace, nil]];
+ keyInputField.inputAccessoryView = customToolbarView;
+#endif
+ [keyInputField becomeFirstResponder];
+ [keyInputField addTarget:self action:@selector(onKeyboardPressed:) forControlEvents:UIControlEventEditingChanged];
+
+ // Undo causes issues for our state management, so turn it off
+ [keyInputField.undoManager disableUndoRegistration];
+
+ isInputingText = true;
+ }
}
}
}
-- (void)onDragStart:(NSTimer*)timer {
- if (!touchMoved && !isDragging){
- isDragging = true;
- LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
+- (UIBarButtonItem *)createButtonWithImageNamed:(NSString *)imageName backgroundColor:(UIColor *)backgroundColor target:(id)target action:(SEL)action keyCode:(NSInteger)keyCode isToggleable:(BOOL)isToggleable {
+ UIImage *image = [UIImage imageNamed:imageName];
+ UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
+ [button setImage:image forState:UIControlStateNormal];
+ button.frame = CGRectMake(0, 0, 30, 30);
+ button.imageView.contentMode = UIViewContentModeScaleAspectFit;
+ button.imageView.backgroundColor = backgroundColor;
+ button.imageView.layer.cornerRadius = 10.0;
+ button.imageEdgeInsets = UIEdgeInsetsMake(6, 6, 6, 6);
+ [button addTarget:target action:action forControlEvents:UIControlEventTouchUpInside];
+ objc_setAssociatedObject(button, "keyCode", @(keyCode), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ objc_setAssociatedObject(button, "isToggleable", @(isToggleable), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ objc_setAssociatedObject(button, "isOn", @(NO), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ UIBarButtonItem *barButton = [[UIBarButtonItem alloc] initWithCustomView:button];
+ return barButton;
+}
+
+- (void)toolbarButtonClicked:(UIButton *)sender {
+ BOOL isToggleable = [objc_getAssociatedObject(sender, "isToggleable") boolValue];
+ BOOL isOn = [objc_getAssociatedObject(sender, "isOn") boolValue];
+ if (isToggleable){
+ isOn = !isOn;
+ // Update the button's appearance based on its new state
+ if (isOn) {
+ sender.imageView.backgroundColor = [UIColor lightGrayColor];
+ } else {
+ sender.imageView.backgroundColor = [UIColor blackColor];
+ }
}
+ // Update the new on/off state of the button
+ objc_setAssociatedObject(sender, "isOn", @(isOn), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ // Get the keyCode parameter and convert to short for key press event
+ short keyCode = [objc_getAssociatedObject(sender, "keyCode") shortValue];
+ // Close keyboard if done button clicked
+ if (!keyCode) {
+ [keyInputField resignFirstResponder];
+ isInputingText = false;
+ }
+ else {
+ // Send key press event using keyCode parameter, toggle if necessary
+ if (isToggleable){
+ if (isOn){
+ LiSendKeyboardEvent(keyCode, KEY_ACTION_DOWN, 0);
+ [keysDown addObject:@(keyCode)];
+ } else {
+ LiSendKeyboardEvent(keyCode, KEY_ACTION_UP, 0);
+ [keysDown removeObject:@(keyCode)];
+ }
+ }
+ else {
+ LiSendKeyboardEvent(keyCode, KEY_ACTION_DOWN, 0);
+ usleep(50 * 1000);
+ LiSendKeyboardEvent(keyCode, KEY_ACTION_UP, 0);
+ }
+ }
+}
+
+- (BOOL)handleMouseButtonEvent:(int)buttonAction forTouches:(NSSet *)touches withEvent:(UIEvent *)event {
+#if !TARGET_OS_TV
+ if (@available(iOS 13.4, *)) {
+ UITouch* touch = [touches anyObject];
+ if (touch.type == UITouchTypeIndirectPointer) {
+ if (@available(iOS 14.0, *)) {
+ if ([GCMouse current] != nil) {
+ // We'll handle this with GCMouse. Do nothing here.
+ return YES;
+ }
+ }
+
+ UIEventButtonMask normalizedButtonMask;
+
+ // iOS 14 includes the released button in the buttonMask for the release
+ // event, while iOS 13 does not. Normalize that behavior here.
+ if (@available(iOS 14.0, *)) {
+ if (buttonAction == BUTTON_ACTION_RELEASE) {
+ normalizedButtonMask = lastMouseButtonMask & ~event.buttonMask;
+ }
+ else {
+ normalizedButtonMask = event.buttonMask;
+ }
+ }
+ else {
+ normalizedButtonMask = event.buttonMask;
+ }
+
+ UIEventButtonMask changedButtons = lastMouseButtonMask ^ normalizedButtonMask;
+
+ for (int i = BUTTON_LEFT; i <= BUTTON_X2; i++) {
+ UIEventButtonMask buttonFlag;
+
+ switch (i) {
+ // Right and Middle are reversed from what iOS uses
+ case BUTTON_RIGHT:
+ buttonFlag = UIEventButtonMaskForButtonNumber(2);
+ break;
+ case BUTTON_MIDDLE:
+ buttonFlag = UIEventButtonMaskForButtonNumber(3);
+ break;
+
+ default:
+ buttonFlag = UIEventButtonMaskForButtonNumber(i);
+ break;
+ }
+
+ if (changedButtons & buttonFlag) {
+ LiSendMouseButtonEvent(buttonAction, i);
+ }
+ }
+
+ lastMouseButtonMask = normalizedButtonMask;
+ return YES;
+ }
+ }
+#endif
+
+ return NO;
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
- if (![onScreenControls handleTouchMovedEvent:touches]) {
- [dragTimer invalidate];
- dragTimer = nil;
- if ([[event allTouches] count] == 1) {
- UITouch *touch = [[event allTouches] anyObject];
- CGPoint currentLocation = [touch locationInView:self];
-
- if (touchLocation.x != currentLocation.x ||
- touchLocation.y != currentLocation.y)
- {
- int deltaX = currentLocation.x - touchLocation.x;
- int deltaY = currentLocation.y - touchLocation.y;
-
- deltaX *= xDeltaFactor * screenFactor;
- deltaY *= yDeltaFactor * screenFactor;
-
- if (deltaX != 0 || deltaY != 0) {
- LiSendMouseMoveEvent(deltaX, deltaY);
- touchLocation = currentLocation;
-
- // If we've moved far enough to confirm this wasn't just human/machine error,
- // mark it as such.
- if ([self isConfirmedMove:touchLocation from:originalLocation]) {
- touchMoved = true;
- }
+#if !TARGET_OS_TV
+ if (@available(iOS 13.4, *)) {
+ for (UITouch* touch in touches) {
+ if (touch.type == UITouchTypePencil) {
+ if ([self sendStylusEvent:touch]) {
+ return;
}
}
- } else if ([[event allTouches] count] == 2) {
- CGPoint firstLocation = [[[[event allTouches] allObjects] objectAtIndex:0] locationInView:self];
- CGPoint secondLocation = [[[[event allTouches] allObjects] objectAtIndex:1] locationInView:self];
-
- CGPoint avgLocation = CGPointMake((firstLocation.x + secondLocation.x) / 2, (firstLocation.y + secondLocation.y) / 2);
- if (touchLocation.y != avgLocation.y) {
- LiSendScrollEvent(avgLocation.y - touchLocation.y);
+ }
+
+ UITouch *touch = [touches anyObject];
+ if (touch.type == UITouchTypeIndirectPointer) {
+ if (@available(iOS 14.0, *)) {
+ if ([GCMouse current] != nil) {
+ // We'll handle this with GCMouse. Do nothing here.
+ return;
+ }
}
+
+ // We must handle this event to properly support
+ // drags while the middle, X1, or X2 mouse buttons are
+ // held down. For some reason, left and right buttons
+ // don't require this, but we do it anyway for them too.
+ // Cursor movement without a button held down is handled
+ // in pointerInteraction:regionForRequest:defaultRegion.
+ [self updateCursorLocation:[touch locationInView:self] isMouse:YES];
+ return;
+ }
+ }
+#endif
+
+ hasUserInteracted = YES;
+
+ if (![onScreenControls handleTouchMovedEvent:touches]) {
+ [touchHandler touchesMoved:touches withEvent:event];
+ }
+}
- // If we've moved far enough to confirm this wasn't just human/machine error,
- // mark it as such.
- if ([self isConfirmedMove:firstLocation from:originalLocation]) {
- touchMoved = true;
+- (void)pressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)event {
+ BOOL handled = NO;
+
+ if (@available(iOS 13.4, tvOS 13.4, *)) {
+ for (UIPress* press in presses) {
+ // For now, we'll treated it as handled if we handle at least one of the
+ // UIPress events inside the set.
+ if ([KeyboardSupport sendKeyEventForPress:press down:YES]) {
+ // This will prevent the legacy UITextField from receiving the event
+ handled = YES;
}
-
- touchLocation = avgLocation;
}
}
+ if (!handled) {
+ [super pressesBegan:presses withEvent:event];
+ }
+}
+
+- (void)pressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event {
+ BOOL handled = NO;
+
+ if (@available(iOS 13.4, tvOS 13.4, *)) {
+ for (UIPress* press in presses) {
+ // For now, we'll treated it as handled if we handle at least one of the
+ // UIPress events inside the set.
+ if ([KeyboardSupport sendKeyEventForPress:press down:NO]) {
+ // This will prevent the legacy UITextField from receiving the event
+ handled = YES;
+ }
+ }
+ }
+
+ if (!handled) {
+ [super pressesEnded:presses withEvent:event];
+ }
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
+ if ([self handleMouseButtonEvent:BUTTON_ACTION_RELEASE
+ forTouches:touches
+ withEvent:event]) {
+ // If it's a mouse event, we're done
+ return;
+ }
+
Log(LOG_D, @"Touch up");
- if (![onScreenControls handleTouchUpEvent:touches]) {
- [dragTimer invalidate];
- dragTimer = nil;
- if (!touchMoved) {
- if ([[event allTouches] count] == 2) {
- Log(LOG_D, @"Sending right mouse button press");
-
- LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_RIGHT);
-
- // Wait 100 ms to simulate a real button press
- usleep(100 * 1000);
-
- LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
- } else {
- if (!isDragging){
- Log(LOG_D, @"Sending left mouse button press");
-
- LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
-
- // Wait 100 ms to simulate a real button press
- usleep(100 * 1000);
+
+ hasUserInteracted = YES;
+
+#if !TARGET_OS_TV
+ if (@available(iOS 13.4, *)) {
+ for (UITouch* touch in touches) {
+ if (touch.type == UITouchTypePencil) {
+ if ([self sendStylusEvent:touch]) {
+ return;
}
- isDragging = false;
- LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
}
}
- else if (isDragging) {
- isDragging = false;
- LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
- }
-
- // We we're moving from 2+ touches to 1. Synchronize the current position
- // of the active finger so we don't jump unexpectedly on the next touchesMoved
- // callback when finger 1 switches on us.
- if ([[event allTouches] count] - [touches count] == 1) {
- NSMutableSet *activeSet = [[NSMutableSet alloc] initWithCapacity:[[event allTouches] count]];
- [activeSet unionSet:[event allTouches]];
- [activeSet minusSet:touches];
- touchLocation = [[activeSet anyObject] locationInView:self];
-
- // Mark this touch as moved so we don't send a left mouse click if the user
- // right clicks without moving their other finger.
- touchMoved = true;
- }
+ }
+#endif
+
+ if (![onScreenControls handleTouchUpEvent:touches]) {
+ [touchHandler touchesEnded:touches withEvent:event];
}
}
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
+ [touchHandler touchesCancelled:touches withEvent:event];
+ [self handleMouseButtonEvent:BUTTON_ACTION_RELEASE
+ forTouches:touches
+ withEvent:event];
+#if !TARGET_OS_TV
+ if (@available(iOS 13.4, *)) {
+ for (UITouch* touch in touches) {
+ if (touch.type == UITouchTypePencil) {
+ [self sendStylusEvent:touch];
+ }
+ }
+ }
+#endif
}
+#if !TARGET_OS_TV
+- (void) updateCursorLocation:(CGPoint)location isMouse:(BOOL)isMouse {
+ CGPoint normalizedLocation = [self adjustCoordinatesForVideoArea:location];
+ CGSize videoSize = [self getVideoAreaSize];
+
+ // Send the mouse position relative to the video region if it has changed
+ // if we're receiving coordinates from a real mouse.
+ //
+ // NB: It is important for functionality (not just optimization) to only
+ // send it if the value has changed. We will receive one of these events
+ // any time the user presses a modifier key, which can result in errant
+ // mouse motion when using a Citrix X1 mouse.
+ if (normalizedLocation.x != lastMouseX || normalizedLocation.y != lastMouseY || !isMouse) {
+ if (lastMouseX != 0 || lastMouseY != 0 || !isMouse) {
+ LiSendMousePositionEvent(normalizedLocation.x, normalizedLocation.y, videoSize.width, videoSize.height);
+ }
+
+ if (isMouse) {
+ lastMouseX = normalizedLocation.x;
+ lastMouseY = normalizedLocation.y;
+ }
+ }
+}
+
+- (UIPointerRegion *)pointerInteraction:(UIPointerInteraction *)interaction
+ regionForRequest:(UIPointerRegionRequest *)request
+ defaultRegion:(UIPointerRegion *)defaultRegion API_AVAILABLE(ios(13.4)) {
+ if (@available(iOS 14.0, *)) {
+ if ([GCMouse current] != nil) {
+ // We'll handle this with GCMouse. Do nothing here.
+ return nil;
+ }
+ }
+
+ // This logic mimics what iOS does with AVLayerVideoGravityResizeAspect
+ CGSize videoSize;
+ CGPoint videoOrigin;
+ if (self.bounds.size.width > self.bounds.size.height * streamAspectRatio) {
+ videoSize = CGSizeMake(self.bounds.size.height * streamAspectRatio, self.bounds.size.height);
+ } else {
+ videoSize = CGSizeMake(self.bounds.size.width, self.bounds.size.width / streamAspectRatio);
+ }
+ videoOrigin = CGPointMake(self.bounds.size.width / 2 - videoSize.width / 2,
+ self.bounds.size.height / 2 - videoSize.height / 2);
+
+ // Move the cursor on the host if no buttons are pressed.
+ // Motion with buttons pressed in handled in touchesMoved:
+ if (lastMouseButtonMask == 0) {
+ [self updateCursorLocation:request.location isMouse:YES];
+ }
+
+ // The pointer interaction should cover the video region only
+ return [UIPointerRegion regionWithRect:CGRectMake(videoOrigin.x, videoOrigin.y, videoSize.width, videoSize.height) identifier:nil];
+}
+
+- (UIPointerStyle *)pointerInteraction:(UIPointerInteraction *)interaction styleForRegion:(UIPointerRegion *)region API_AVAILABLE(ios(13.4)) {
+ // Always hide the mouse cursor over our stream view
+ return [UIPointerStyle hiddenPointerStyle];
+}
+
+- (void)mouseWheelMovedContinuous:(UIPanGestureRecognizer *)gesture {
+ switch (gesture.state) {
+ case UIGestureRecognizerStateBegan:
+ case UIGestureRecognizerStateChanged:
+ break;
+
+ case UIGestureRecognizerStateEnded:
+ default:
+ // Ignore recognition failure and other states
+ lastScrollTranslation = CGPointMake(0, 0);
+ return;
+ }
+
+ CGPoint currentScrollTranslation = [gesture translationInView:self];
+ const short translationMultiplier = 120 * 20; // WHEEL_DELTA * 20
+
+ {
+ short translationDeltaY = ((currentScrollTranslation.y - lastScrollTranslation.y) / self.bounds.size.height) * translationMultiplier;
+ if (translationDeltaY != 0) {
+ LiSendHighResScrollEvent(translationDeltaY);
+ lastScrollTranslation = currentScrollTranslation;
+ }
+ }
+
+ {
+ short translationDeltaX = ((currentScrollTranslation.x - lastScrollTranslation.x) / self.bounds.size.width) * translationMultiplier;
+ if (translationDeltaX != 0) {
+ // Direction is reversed from vertical scrolling
+ LiSendHighResHScrollEvent(-translationDeltaX);
+ lastScrollTranslation = currentScrollTranslation;
+ }
+ }
+}
+
+- (void)mouseWheelMovedDiscrete:(UIPanGestureRecognizer *)gesture {
+ switch (gesture.state) {
+ case UIGestureRecognizerStateBegan:
+ case UIGestureRecognizerStateChanged:
+ break;
+
+ case UIGestureRecognizerStateEnded:
+ default:
+ // Ignore recognition failure and other states
+ lastScrollTranslation = CGPointMake(0, 0);
+ return;
+ }
+
+ // Using velocityInView is 0 for discrete scroll events
+ // when scrolling very slowly, but translationInView does work.
+ CGPoint currentScrollTranslation = [gesture translationInView:self];
+
+ {
+ short translationDeltaY = currentScrollTranslation.y - lastScrollTranslation.y;
+ if (translationDeltaY != 0) {
+ LiSendScrollEvent(translationDeltaY > 0 ? 1 : -1);
+ }
+ }
+
+ {
+ short translationDeltaX = currentScrollTranslation.x - lastScrollTranslation.x;
+ if (translationDeltaX != 0) {
+ // Direction is reversed from vertical scrolling
+ LiSendHScrollEvent(translationDeltaX < 0 ? 1 : -1);
+ }
+ }
+
+ lastScrollTranslation = currentScrollTranslation;
+}
+
+#endif
+
+- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
+ if (@available(iOS 13.0, *)) {
+ // Disable the 3 finger tap gestures that trigger the copy/paste/undo toolbar on iOS 13+
+ return gestureRecognizer.name == nil || ![gestureRecognizer.name hasPrefix:@"kbProductivity."];
+ }
+ else {
+ return YES;
+ }
+}
+
+- (BOOL)textFieldShouldReturn:(UITextField *)textField {
+ // This method is called when the "Return" key is pressed.
+ LiSendKeyboardEvent(0x0d, KEY_ACTION_DOWN, 0);
+ usleep(50 * 1000);
+ LiSendKeyboardEvent(0x0d, KEY_ACTION_UP, 0);
+ return NO;
+}
+
+- (void)textFieldDidEndEditing:(UITextField *)textField {
+ for (NSNumber* keyCode in keysDown) {
+ LiSendKeyboardEvent([keyCode shortValue], KEY_ACTION_UP, 0);
+ }
+ [keysDown removeAllObjects];
+}
+
+- (void)onKeyboardPressed:(UITextField *)textField {
+ NSString* inputText = textField.text;
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
+ // If the text became empty, we know the user pressed the backspace key.
+ if ([inputText isEqual:@""]) {
+ LiSendKeyboardEvent(0x08, KEY_ACTION_DOWN, 0);
+ usleep(50 * 1000);
+ LiSendKeyboardEvent(0x08, KEY_ACTION_UP, 0);
+ } else {
+ // Character 0 will be our known sentinel value
+
+ // Check if any characters exist which can't be represented in a basic key event
+ for (int i = 1; i < [inputText length]; i++) {
+ struct KeyEvent event = [KeyboardSupport translateKeyEvent:[inputText characterAtIndex:i] withModifierFlags:0];
+ if (event.keycode == 0) {
+ // We found an unknown key, so send the entire string as UTF-8
+ const char* utf8String = [inputText UTF8String];
+
+ // Skip the first character which is our sentinel
+ LiSendUtf8TextEvent(utf8String + 1, (int)strlen(utf8String) - 1);
+ return;
+ }
+ }
+
+ // We didn't find any unknown characters, so send them all as basic key events
+ for (int i = 1; i < [inputText length]; i++) {
+ struct KeyEvent event = [KeyboardSupport translateKeyEvent:[inputText characterAtIndex:i] withModifierFlags:0];
+ assert(event.keycode != 0);
+ [self sendLowLevelEvent:event];
+ }
+ }
+ });
+
+ // Reset text field back to known state
+ textField.text = @"0";
+
+ // Move the insertion point back to the end of the text box
+ UITextRange *textRange = [textField textRangeFromPosition:textField.endOfDocument toPosition:textField.endOfDocument];
+ [textField setSelectedTextRange:textRange];
+}
+
+- (void)specialCharPressed:(UIKeyCommand *)cmd {
+ struct KeyEvent event = [KeyboardSupport translateKeyEvent:0x20 withModifierFlags:[cmd modifierFlags]];
+ event.keycode = [[dictCodes valueForKey:[cmd input]] intValue];
+ [self sendLowLevelEvent:event];
+}
+
+- (void)keyPressed:(UIKeyCommand *)cmd {
+ struct KeyEvent event = [KeyboardSupport translateKeyEvent:[[cmd input] characterAtIndex:0] withModifierFlags:[cmd modifierFlags]];
+ [self sendLowLevelEvent:event];
+}
+
+- (void)sendLowLevelEvent:(struct KeyEvent)event {
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
+ // When we want to send a modified key (like uppercase letters) we need to send the
+ // modifier ("shift") seperately from the key itself.
+ if (event.modifier != 0) {
+ LiSendKeyboardEvent(event.modifierKeycode, KEY_ACTION_DOWN, event.modifier);
+ }
+ // Let the host know these are not (necessarily) normalized to US English scancodes
+ LiSendKeyboardEvent2(event.keycode, KEY_ACTION_DOWN, event.modifier, SS_KBE_FLAG_NON_NORMALIZED);
+ usleep(50 * 1000);
+ LiSendKeyboardEvent2(event.keycode, KEY_ACTION_UP, event.modifier, SS_KBE_FLAG_NON_NORMALIZED);
+ if (event.modifier != 0) {
+ LiSendKeyboardEvent(event.modifierKeycode, KEY_ACTION_UP, event.modifier);
+ }
+ });
+}
+
+- (BOOL)canBecomeFirstResponder {
+ return YES;
+}
+
+- (NSArray *)keyCommands
+{
+ NSString *charset = @"qwertyuiopasdfghjklzxcvbnm1234567890\t§[]\\'\"/.,`<>-´ç+`¡'º;ñ= ";
+
+ NSMutableArray * commands = [NSMutableArray array];
+ dictCodes = [[NSDictionary alloc] initWithObjectsAndKeys: [NSNumber numberWithInt: 0x0d], @"\r", [NSNumber numberWithInt: 0x08], @"\b", [NSNumber numberWithInt: 0x1b], UIKeyInputEscape, [NSNumber numberWithInt: 0x28], UIKeyInputDownArrow, [NSNumber numberWithInt: 0x26], UIKeyInputUpArrow, [NSNumber numberWithInt: 0x25], UIKeyInputLeftArrow, [NSNumber numberWithInt: 0x27], UIKeyInputRightArrow, nil];
+
+ [charset enumerateSubstringsInRange:NSMakeRange(0, charset.length)
+ options:NSStringEnumerationByComposedCharacterSequences
+ usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) {
+ [commands addObject:[UIKeyCommand keyCommandWithInput:substring modifierFlags:0 action:@selector(keyPressed:)]];
+ [commands addObject:[UIKeyCommand keyCommandWithInput:substring modifierFlags:UIKeyModifierShift action:@selector(keyPressed:)]];
+ [commands addObject:[UIKeyCommand keyCommandWithInput:substring modifierFlags:UIKeyModifierControl action:@selector(keyPressed:)]];
+ [commands addObject:[UIKeyCommand keyCommandWithInput:substring modifierFlags:UIKeyModifierAlternate action:@selector(keyPressed:)]];
+ }];
+
+ for (NSString *c in [dictCodes keyEnumerator]) {
+ [commands addObject:[UIKeyCommand keyCommandWithInput:c
+ modifierFlags:0
+ action:@selector(specialCharPressed:)]];
+ [commands addObject:[UIKeyCommand keyCommandWithInput:c
+ modifierFlags:UIKeyModifierShift
+ action:@selector(specialCharPressed:)]];
+ [commands addObject:[UIKeyCommand keyCommandWithInput:c
+ modifierFlags:UIKeyModifierShift | UIKeyModifierAlternate
+ action:@selector(specialCharPressed:)]];
+ [commands addObject:[UIKeyCommand keyCommandWithInput:c
+ modifierFlags:UIKeyModifierShift | UIKeyModifierControl
+ action:@selector(specialCharPressed:)]];
+ [commands addObject:[UIKeyCommand keyCommandWithInput:c
+ modifierFlags:UIKeyModifierControl
+ action:@selector(specialCharPressed:)]];
+ [commands addObject:[UIKeyCommand keyCommandWithInput:c
+ modifierFlags:UIKeyModifierControl | UIKeyModifierAlternate
+ action:@selector(specialCharPressed:)]];
+ [commands addObject:[UIKeyCommand keyCommandWithInput:c
+ modifierFlags:UIKeyModifierAlternate
+ action:@selector(specialCharPressed:)]];
+ }
+
+ return commands;
+}
+
+- (void)connectedStateDidChangeWithIdentifier:(NSUUID * _Nonnull)identifier isConnected:(BOOL)isConnected {
+ NSLog(@"Citrix X1 mouse state change: %@ -> %s",
+ identifier, isConnected ? "connected" : "disconnected");
+}
+
+- (void)mouseDidMoveWithIdentifier:(NSUUID * _Nonnull)identifier deltaX:(int16_t)deltaX deltaY:(int16_t)deltaY {
+ accumulatedMouseDeltaX += deltaX / X1_MOUSE_SPEED_DIVISOR;
+ accumulatedMouseDeltaY += deltaY / X1_MOUSE_SPEED_DIVISOR;
+
+ short shortX = (short)accumulatedMouseDeltaX;
+ short shortY = (short)accumulatedMouseDeltaY;
+
+ if (shortX == 0 && shortY == 0) {
+ return;
+ }
+
+ LiSendMouseMoveEvent(shortX, shortY);
+
+ accumulatedMouseDeltaX -= shortX;
+ accumulatedMouseDeltaY -= shortY;
+}
+
+- (int) buttonFromX1ButtonCode:(enum X1MouseButton)button {
+ switch (button) {
+ case X1MouseButtonLeft:
+ return BUTTON_LEFT;
+ case X1MouseButtonRight:
+ return BUTTON_RIGHT;
+ case X1MouseButtonMiddle:
+ return BUTTON_MIDDLE;
+ default:
+ return -1;
+ }
+}
+
+- (void)mouseDownWithIdentifier:(NSUUID * _Nonnull)identifier button:(enum X1MouseButton)button {
+ LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, [self buttonFromX1ButtonCode:button]);
+}
+
+- (void)mouseUpWithIdentifier:(NSUUID * _Nonnull)identifier button:(enum X1MouseButton)button {
+ LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, [self buttonFromX1ButtonCode:button]);
+}
+
+- (void)wheelDidScrollWithIdentifier:(NSUUID * _Nonnull)identifier deltaZ:(int8_t)deltaZ {
+ LiSendScrollEvent(deltaZ);
+}
+
+#if !TARGET_OS_TV
+- (BOOL)isMultipleTouchEnabled {
+ return YES;
+}
+#endif
@end
diff --git a/Limelight/Limelight-Info.plist b/Limelight/Limelight-Info.plist
index d4cd281..963c433 100644
--- a/Limelight/Limelight-Info.plist
+++ b/Limelight/Limelight-Info.plist
@@ -2,6 +2,10 @@
+ CADisableMinimumFrameDuration
+
+ CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
en
CFBundleDisplayName
@@ -17,11 +21,24 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.5.0
+ $(MARKETING_VERSION)
CFBundleSignature
????
CFBundleVersion
- 2
+ $(CURRENT_PROJECT_VERSION)
+ GCSupportedGameControllers
+
+
+ ProfileName
+ ExtendedGamepad
+
+
+ GCSupportsControllerUserInteraction
+
+ LSSupportsGameMode
+
+ ITSAppUsesNonExemptEncryption
+
LSApplicationCategoryType
public.app-category.games
LSRequiresIPhoneOS
@@ -31,15 +48,18 @@
NSAllowsArbitraryLoads
- UIAppFonts
+ NSBluetoothAlwaysUsageDescription
+ Bluetooth access allows Moonlight to connect to Citrix X1 mice.
+ NSBluetoothPeripheralUsageDescription
+ Bluetooth access allows Moonlight to connect to Citrix X1 mice.
+ NSBonjourServices
- Roboto-Black.ttf
- Roboto-Bold.ttf
- Roboto-Light.ttf
- Roboto-Medium.ttf
- Roboto-Regular.ttf
- Roboto-Thin.ttf
+ _nvstream._tcp
+ NSLocalNetworkUsageDescription
+ Moonlight uses the local network to connect to your gaming PC for streaming.
+ UIApplicationSupportsIndirectInputEvents
+
UILaunchStoryboardName
Launch Screen
UIMainStoryboardFile
diff --git a/Limelight/Limelight-Prefix.pch b/Limelight/Limelight-Prefix.pch
index b6e3f7e..4e945b8 100644
--- a/Limelight/Limelight-Prefix.pch
+++ b/Limelight/Limelight-Prefix.pch
@@ -5,14 +5,20 @@
//
#import
+#include
#ifndef __IPHONE_3_0
#warning "This project uses features only available in iOS SDK 3.0 and later."
#endif
#ifdef __OBJC__
+
+#if TARGET_OS_IPHONE
#import
- #import
- #import
- #import "Logger.h"
-#endif
\ No newline at end of file
+#elif TARGET_OS_MAC
+ #import
+#endif
+#import
+#import
+#import "Logger.h"
+#endif
diff --git a/Limelight/Limelight.xcdatamodeld/.xccurrentversion b/Limelight/Limelight.xcdatamodeld/.xccurrentversion
index 2346a99..b202eb9 100644
--- a/Limelight/Limelight.xcdatamodeld/.xccurrentversion
+++ b/Limelight/Limelight.xcdatamodeld/.xccurrentversion
@@ -3,6 +3,6 @@
_XCCurrentVersionName
- Moonlight v1.0-2.xcdatamodel
+ Moonlight v1.10.xcdatamodel
diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.0-2.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.0-2.xcdatamodel/contents
index 6844893..a9ff5b4 100644
--- a/Limelight/Limelight.xcdatamodeld/Moonlight v1.0-2.xcdatamodel/contents
+++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.0-2.xcdatamodel/contents
@@ -1,5 +1,5 @@
-
+
@@ -12,21 +12,22 @@
-
+
-
-
-
-
+
+
+
+
+
-
+
-
+
\ No newline at end of file
diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.1.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.1.xcdatamodel/contents
new file mode 100644
index 0000000..090bd63
--- /dev/null
+++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.1.xcdatamodel/contents
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.10.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.10.xcdatamodel/contents
new file mode 100644
index 0000000..a2cc61c
--- /dev/null
+++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.10.xcdatamodel/contents
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.2.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.2.xcdatamodel/contents
new file mode 100644
index 0000000..0d3feba
--- /dev/null
+++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.2.xcdatamodel/contents
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.3.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.3.xcdatamodel/contents
new file mode 100644
index 0000000..9eee4f1
--- /dev/null
+++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.3.xcdatamodel/contents
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.4.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.4.xcdatamodel/contents
new file mode 100644
index 0000000..ce5077c
--- /dev/null
+++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.4.xcdatamodel/contents
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.5.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.5.xcdatamodel/contents
new file mode 100644
index 0000000..89bac30
--- /dev/null
+++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.5.xcdatamodel/contents
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.6.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.6.xcdatamodel/contents
new file mode 100644
index 0000000..39de9e9
--- /dev/null
+++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.6.xcdatamodel/contents
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.7.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.7.xcdatamodel/contents
new file mode 100644
index 0000000..712c76a
--- /dev/null
+++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.7.xcdatamodel/contents
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.8.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.8.xcdatamodel/contents
new file mode 100644
index 0000000..19d6d78
--- /dev/null
+++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.8.xcdatamodel/contents
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.9.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.9.xcdatamodel/contents
new file mode 100644
index 0000000..30a96bc
--- /dev/null
+++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.9.xcdatamodel/contents
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Limelight/Network/AppAssetManager.h b/Limelight/Network/AppAssetManager.h
index 42f7720..d202072 100644
--- a/Limelight/Network/AppAssetManager.h
+++ b/Limelight/Network/AppAssetManager.h
@@ -6,7 +6,6 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
#import "TemporaryApp.h"
#import "HttpManager.h"
#import "TemporaryHost.h"
@@ -22,5 +21,6 @@
- (id) initWithCallback:(id)callback;
- (void) retrieveAssetsFromHost:(TemporaryHost*)host;
- (void) stopRetrieving;
++ (NSString*) boxArtPathForApp:(TemporaryApp*)app;
@end
diff --git a/Limelight/Network/AppAssetManager.m b/Limelight/Network/AppAssetManager.m
index f8a8f5d..4a6480d 100644
--- a/Limelight/Network/AppAssetManager.m
+++ b/Limelight/Network/AppAssetManager.m
@@ -19,6 +19,22 @@
static const int MAX_REQUEST_COUNT = 4;
++ (NSString*) boxArtPathForApp:(TemporaryApp*)app {
+ NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
+ NSString *filePath = [paths objectAtIndex:0];
+
+ // Keep app assets separate by host UUID
+ filePath = [filePath stringByAppendingPathComponent:app.host.uuid];
+
+ // Use the app ID as the file name
+ filePath = [filePath stringByAppendingPathComponent:app.id];
+
+ // Add a png extension
+ filePath = [filePath stringByAppendingPathExtension:@"png"];
+
+ return filePath;
+}
+
- (id) initWithCallback:(id)callback {
self = [super init];
_callback = callback;
@@ -30,7 +46,7 @@ static const int MAX_REQUEST_COUNT = 4;
- (void) retrieveAssetsFromHost:(TemporaryHost*)host {
for (TemporaryApp* app in host.appList) {
- if (app.image == nil) {
+ if (![[NSFileManager defaultManager] fileExistsAtPath:[AppAssetManager boxArtPathForApp:app]]) {
AppAssetRetriever* retriever = [[AppAssetRetriever alloc] init];
retriever.app = app;
retriever.host = host;
diff --git a/Limelight/Network/AppAssetResponse.h b/Limelight/Network/AppAssetResponse.h
index 4e9ac7b..ad225f8 100644
--- a/Limelight/Network/AppAssetResponse.h
+++ b/Limelight/Network/AppAssetResponse.h
@@ -6,7 +6,6 @@
// Copyright (c) 2015 Moonlight Stream. All rights reserved.
//
-#import
#import "HttpResponse.h"
@interface AppAssetResponse : NSObject
diff --git a/Limelight/Network/AppAssetResponse.m b/Limelight/Network/AppAssetResponse.m
index 9a41e64..0fb9ff8 100644
--- a/Limelight/Network/AppAssetResponse.m
+++ b/Limelight/Network/AppAssetResponse.m
@@ -16,10 +16,8 @@
self.statusMessage = @"App asset has no status message";
self.statusCode = -1;
}
-
- (UIImage*) getImage {
- UIImage* appImage = [[UIImage alloc] initWithData:self.data];
- return appImage;
+ return [[UIImage alloc] initWithData:self.data];
}
@end
diff --git a/Limelight/Network/AppAssetRetriever.h b/Limelight/Network/AppAssetRetriever.h
index b897a0f..07ee687 100644
--- a/Limelight/Network/AppAssetRetriever.h
+++ b/Limelight/Network/AppAssetRetriever.h
@@ -6,7 +6,6 @@
// Copyright (c) 2015 Moonlight Stream. All rights reserved.
//
-#import
#import "TemporaryHost.h"
#import "TemporaryApp.h"
#import "AppAssetManager.h"
diff --git a/Limelight/Network/AppAssetRetriever.m b/Limelight/Network/AppAssetRetriever.m
index 824b423..75580db 100644
--- a/Limelight/Network/AppAssetRetriever.m
+++ b/Limelight/Network/AppAssetRetriever.m
@@ -18,18 +18,20 @@ static const double RETRY_DELAY = 2; // seconds
static const int MAX_ATTEMPTS = 5;
- (void) main {
- UIImage* appImage = nil;
int attempts = 0;
- while (![self isCancelled] && appImage == nil && attempts++ < MAX_ATTEMPTS) {
-
- HttpManager* hMan = [[HttpManager alloc] initWithHost:_host.activeAddress uniqueId:[IdManager getUniqueId] deviceName:deviceName cert:[CryptoManager readCertFromFile]];
+ while (![self isCancelled] && attempts++ < MAX_ATTEMPTS) {
+ HttpManager* hMan = [[HttpManager alloc] initWithHost:_host];
AppAssetResponse* appAssetResp = [[AppAssetResponse alloc] init];
[hMan executeRequestSynchronously:[HttpRequest requestForResponse:appAssetResp withUrlRequest:[hMan newAppAssetRequestWithAppId:self.app.id]]];
+
+ if (appAssetResp.data != nil) {
+ NSString* boxArtPath = [AppAssetManager boxArtPathForApp:self.app];
+ [[NSFileManager defaultManager] createDirectoryAtPath:[boxArtPath stringByDeletingLastPathComponent] withIntermediateDirectories:YES attributes:nil error:nil];
+ [appAssetResp.data writeToFile:boxArtPath atomically:NO];
+ break;
+ }
- appImage = [UIImage imageWithData:appAssetResp.data];
- self.app.image = UIImagePNGRepresentation(appImage);
-
- if (![self isCancelled] && appImage == nil) {
+ if (![self isCancelled]) {
[NSThread sleepForTimeInterval:RETRY_DELAY];
}
}
diff --git a/Limelight/Network/AppListResponse.m b/Limelight/Network/AppListResponse.m
index ab6bc22..d5d3010 100644
--- a/Limelight/Network/AppListResponse.m
+++ b/Limelight/Network/AppListResponse.m
@@ -19,6 +19,8 @@
static const char* TAG_APP = "App";
static const char* TAG_APP_TITLE = "AppTitle";
static const char* TAG_APP_ID = "ID";
+static const char* TAG_HDR_SUPPORTED = "IsHdrSupported";
+static const char* TAG_APP_INSTALL_PATH = "AppInstallPath";
- (void)populateWithData:(NSData *)xml {
self.data = xml;
@@ -42,7 +44,7 @@ static const char* TAG_APP_ID = "ID";
xmlChar* statusStr = xmlGetProp(node, (const xmlChar*)[TAG_STATUS_CODE UTF8String]);
if (statusStr != NULL) {
- int status = [[NSString stringWithUTF8String:(const char*)statusStr] intValue];
+ int status = (int)[[NSString stringWithUTF8String:(const char*)statusStr] longLongValue];
xmlFree(statusStr);
self.statusCode = status;
}
@@ -66,14 +68,14 @@ static const char* TAG_APP_ID = "ID";
xmlNodePtr appInfoNode = node->xmlChildrenNode;
NSString* appName = @"";
NSString* appId = nil;
+ NSString* hdrSupported = @"0";
+ NSString* appInstallPath = nil;
while (appInfoNode != NULL) {
if (!xmlStrcmp(appInfoNode->name, (xmlChar*)TAG_APP_TITLE)) {
xmlChar* nodeVal = xmlNodeListGetString(docPtr, appInfoNode->xmlChildrenNode, 1);
if (nodeVal != NULL) {
appName = [[NSString alloc] initWithCString:(const char*)nodeVal encoding:NSUTF8StringEncoding];
xmlFree(nodeVal);
- } else {
- appName = @"";
}
} else if (!xmlStrcmp(appInfoNode->name, (xmlChar*)TAG_APP_ID)) {
xmlChar* nodeVal = xmlNodeListGetString(docPtr, appInfoNode->xmlChildrenNode, 1);
@@ -81,13 +83,28 @@ static const char* TAG_APP_ID = "ID";
appId = [[NSString alloc] initWithCString:(const char*)nodeVal encoding:NSUTF8StringEncoding];
xmlFree(nodeVal);
}
+ } else if (!xmlStrcmp(appInfoNode->name, (xmlChar*)TAG_HDR_SUPPORTED)) {
+ xmlChar* nodeVal = xmlNodeListGetString(docPtr, appInfoNode->xmlChildrenNode, 1);
+ if (nodeVal != NULL) {
+ hdrSupported = [[NSString alloc] initWithCString:(const char*)nodeVal encoding:NSUTF8StringEncoding];
+ xmlFree(nodeVal);
+ }
+ } else if (!xmlStrcmp(appInfoNode->name, (xmlChar*)TAG_APP_INSTALL_PATH)) {
+ xmlChar* nodeVal = xmlNodeListGetString(docPtr, appInfoNode->xmlChildrenNode, 1);
+ if (nodeVal != NULL) {
+ appInstallPath = [[NSString alloc] initWithCString:(const char*)nodeVal encoding:NSUTF8StringEncoding];
+ xmlFree(nodeVal);
+ }
}
+
appInfoNode = appInfoNode->next;
}
if (appId != nil) {
TemporaryApp* app = [[TemporaryApp alloc] init];
app.name = appName;
app.id = appId;
+ app.hdrSupported = [hdrSupported intValue] != 0;
+ app.installPath = appInstallPath;
[_appList addObject:app];
}
}
@@ -95,6 +112,42 @@ static const char* TAG_APP_ID = "ID";
}
xmlFreeDoc(docPtr);
+
+#ifdef ENABLE_APP_STORE_RESTRICTIONS
+ // APP STORE REVIEW COMPLIANCE
+ //
+ // Remove default Steam entry from the app list to comply with Apple App Store Guideline 4.2.7d:
+ //
+ // The UI appearing on the client does not resemble an iOS or App Store view, does not provide a store-like interface,
+ // or include the ability to browse, select, or purchase software not already owned or licensed by the user.
+ //
+ // However, if the user manually adds Steam themselves, then we will display it.
+ TemporaryApp* officialSteamApp = nil;
+ TemporaryApp* manuallyAddedSteamApp = nil;
+ for (TemporaryApp* app in _appList) {
+ if (app.installPath != nil && [[app.installPath lowercaseString] hasSuffix:@"\\steam\\"]) {
+ // The official Steam app is marked as HDR supported, while manually added ones are not.
+ if ([app.name isEqualToString:@"Steam"] && app.hdrSupported) {
+ officialSteamApp = app;
+ }
+ else {
+ manuallyAddedSteamApp = app;
+ }
+ }
+ }
+
+ // To be safe, don't do anything if we didn't find an HDR-enabled Steam app.
+ if (officialSteamApp != nil) {
+ // If we didn't find a manually added Steam app, remove the official one to
+ // comply with the App Store guidelines.
+ if (manuallyAddedSteamApp == nil) {
+ [_appList removeObject:officialSteamApp];
+ }
+ else {
+ [_appList removeObject:manuallyAddedSteamApp];
+ }
+ }
+#endif
}
- (NSSet*) getAppList {
diff --git a/Limelight/Network/ConnectionHelper.h b/Limelight/Network/ConnectionHelper.h
new file mode 100644
index 0000000..cd88599
--- /dev/null
+++ b/Limelight/Network/ConnectionHelper.h
@@ -0,0 +1,21 @@
+//
+// ConnectionHelper.h
+// Moonlight macOS
+//
+// Created by Felix Kratz on 22.03.18.
+// Copyright © 2018 Felix Kratz. All rights reserved.
+//
+
+#import "AppListResponse.h"
+#import "TemporaryHost.h"
+
+#ifndef ConnectionHelper_h
+#define ConnectionHelper_h
+
+@interface ConnectionHelper : NSObject
+
++(AppListResponse*) getAppListForHost:(TemporaryHost*)host;
+
+@end
+
+#endif /* ConnectionHelper_h */
diff --git a/Limelight/Network/ConnectionHelper.m b/Limelight/Network/ConnectionHelper.m
new file mode 100644
index 0000000..f312427
--- /dev/null
+++ b/Limelight/Network/ConnectionHelper.m
@@ -0,0 +1,41 @@
+//
+// ConnectionHelper.m
+// Moonlight macOS
+//
+// Created by Felix Kratz on 22.03.18.
+// Copyright © 2018 Felix Kratz. All rights reserved.
+//
+
+#import "ConnectionHelper.h"
+#import "ServerInfoResponse.h"
+#import "HttpManager.h"
+#import "PairManager.h"
+#import "DiscoveryManager.h"
+
+@implementation ConnectionHelper
+
++(AppListResponse*) getAppListForHost:(TemporaryHost*)host {
+ HttpManager* hMan = [[HttpManager alloc] initWithHost:host];
+
+ // Try up to 5 times to get the app list
+ AppListResponse* appListResp = nil;
+ for (int i = 0; i < 5; i++) {
+ appListResp = [[AppListResponse alloc] init];
+ [hMan executeRequestSynchronously:[HttpRequest requestForResponse:appListResp withUrlRequest:[hMan newAppListRequest]]];
+ if (![appListResp isStatusOk] || [appListResp getAppList] == nil) {
+ Log(LOG_W, @"Failed to get applist on try %d: %@", i, appListResp.statusMessage);
+
+ // Wait for one second then retry
+ [NSThread sleepForTimeInterval:1];
+ }
+ else {
+ Log(LOG_I, @"App list successfully retreived - took %d tries", i);
+ break;
+ }
+ }
+
+ return appListResp;
+}
+
+@end
+
diff --git a/Limelight/Network/DiscoveryManager.h b/Limelight/Network/DiscoveryManager.h
index 7d5dfcc..ba26549 100644
--- a/Limelight/Network/DiscoveryManager.h
+++ b/Limelight/Network/DiscoveryManager.h
@@ -6,7 +6,6 @@
// Copyright (c) 2015 Moonlight Stream. All rights reserved.
//
-#import
#import "MDNSManager.h"
#import "TemporaryHost.h"
@@ -22,8 +21,11 @@
- (void) startDiscovery;
- (void) stopDiscovery;
- (void) stopDiscoveryBlocking;
+- (void) resetDiscoveryState;
- (BOOL) addHostToDiscovery:(TemporaryHost*)host;
- (void) removeHostFromDiscovery:(TemporaryHost*)host;
+- (void) pauseDiscoveryForHost:(TemporaryHost *)host;
+- (void) resumeDiscoveryForHost:(TemporaryHost *)host;
- (void) discoverHost:(NSString*)hostAddress withCallback:(void (^)(TemporaryHost*, NSString*))callback;
@end
diff --git a/Limelight/Network/DiscoveryManager.m b/Limelight/Network/DiscoveryManager.m
index 286fa34..a72df63 100644
--- a/Limelight/Network/DiscoveryManager.m
+++ b/Limelight/Network/DiscoveryManager.m
@@ -15,8 +15,14 @@
#import "ServerInfoResponse.h"
#import "IdManager.h"
+#include
+#include
+#include
+#include
+
@implementation DiscoveryManager {
NSMutableArray* _hostQueue;
+ NSMutableSet* _pausedHosts;
id _callback;
MDNSManager* _mdnsMan;
NSOperationQueue* _opQueue;
@@ -33,6 +39,7 @@
_callback = callback;
shouldDiscover = NO;
_hostQueue = [NSMutableArray array];
+ _pausedHosts = [NSMutableSet set];
for (TemporaryHost* host in hosts)
{
[self addHostToDiscovery:host];
@@ -41,44 +48,194 @@
_opQueue = [[NSOperationQueue alloc] init];
_mdnsMan = [[MDNSManager alloc] initWithCallback:self];
- [CryptoManager generateKeyPairUsingSSl];
+ [CryptoManager generateKeyPairUsingSSL];
_uniqueId = [IdManager getUniqueId];
_cert = [CryptoManager readCertFromFile];
return self;
}
-- (void) discoverHost:(NSString *)hostAddress withCallback:(void (^)(TemporaryHost *, NSString*))callback {
- HttpManager* hMan = [[HttpManager alloc] initWithHost:hostAddress uniqueId:_uniqueId deviceName:deviceName cert:_cert];
++ (BOOL) isAddressLAN:(in_addr_t)addr {
+ addr = htonl(addr);
+
+ // 10.0.0.0/8
+ if ((addr & 0xFF000000) == 0x0A000000) {
+ return YES;
+ }
+ // 172.16.0.0/12
+ else if ((addr & 0xFFF00000) == 0xAC100000) {
+ return YES;
+ }
+ // 192.168.0.0/16
+ else if ((addr & 0xFFFF0000) == 0xC0A80000) {
+ return YES;
+ }
+ // 169.254.0.0/16
+ else if ((addr & 0xFFFF0000) == 0xA9FE0000) {
+ return YES;
+ }
+ // 100.64.0.0/10 - RFC6598 official CGN address (shouldn't see this in a LAN)
+ else if ((addr & 0xFFC00000) == 0x64400000) {
+ return YES;
+ }
+
+ return NO;
+}
+
+// This ensures that only RFC 1918 IPv4 addresses can be passed to
+// the Add PC dialog. This is required to comply with Apple App Store
+// Guideline 4.2.7a.
++ (BOOL) isProhibitedAddress:(NSString*)address {
+#ifdef ENABLE_APP_STORE_RESTRICTIONS
+ struct addrinfo hints;
+ struct addrinfo* result;
+ int err;
+
+ // We're explicitly using AF_INET here because we don't want to
+ // ever receive a synthesized IPv6 address here, even on NAT64.
+ // IPv6 addresses are not restricted here because we cannot easily
+ // tell whether they are local or not.
+ memset(&hints, 0, sizeof(hints));
+ hints.ai_family = AF_INET;
+ err = getaddrinfo([address UTF8String], NULL, &hints, &result);
+ if (err != 0 || result == NULL) {
+ Log(LOG_W, @"getaddrinfo(%@) failed: %d", address, err);
+ return NO;
+ }
+
+ if (result->ai_family != AF_INET) {
+ // This should never happen due to our hints
+ assert(result->ai_family == AF_INET);
+ Log(LOG_W, @"Unexpected address family: %d", result->ai_family);
+ freeaddrinfo(result);
+ return NO;
+ }
+
+ BOOL ret = ![DiscoveryManager isAddressLAN:((struct sockaddr_in*)result->ai_addr)->sin_addr.s_addr];
+ freeaddrinfo(result);
+
+ return ret;
+#else
+ return NO;
+#endif
+}
+
+- (ServerInfoResponse*) getServerInfoResponseForAddress:(NSString*)address {
+ HttpManager* hMan = [[HttpManager alloc] initWithAddress:address httpsPort:0 serverCert:nil];
ServerInfoResponse* serverInfoResponse = [[ServerInfoResponse alloc] init];
- [hMan executeRequestSynchronously:[HttpRequest requestForResponse:serverInfoResponse withUrlRequest:[hMan newServerInfoRequest] fallbackError:401 fallbackRequest:[hMan newHttpServerInfoRequest]]];
+ [hMan executeRequestSynchronously:[HttpRequest requestForResponse:serverInfoResponse withUrlRequest:[hMan newServerInfoRequest:false] fallbackError:401 fallbackRequest:[hMan newHttpServerInfoRequest]]];
+ return serverInfoResponse;
+}
+
+- (void) discoverHost:(NSString *)hostAddress withCallback:(void (^)(TemporaryHost *, NSString*))callback {
+ BOOL prohibitedAddress = [DiscoveryManager isProhibitedAddress:hostAddress];
+ NSString* prohibitedAddressMessage = [NSString stringWithFormat: @"Moonlight only supports adding PCs on your local network on %s.",
+ #if TARGET_OS_TV
+ "tvOS"
+ #else
+ "iOS"
+ #endif
+ ];
+ ServerInfoResponse* serverInfoResponse = [self getServerInfoResponseForAddress:hostAddress];
TemporaryHost* host = nil;
if ([serverInfoResponse isStatusOk]) {
host = [[TemporaryHost alloc] init];
host.activeAddress = host.address = hostAddress;
- host.online = YES;
+ host.state = StateOnline;
[serverInfoResponse populateHost:host];
+
+ // Check if this is a new PC
+ if (![self getHostInDiscovery:host.uuid]) {
+ // Enforce LAN restriction for App Store Guideline 4.2.7a
+ if ([DiscoveryManager isProhibitedAddress:hostAddress]) {
+ // We have a prohibited address. This might be because the user specified their WAN address
+ // instead of their LAN address. If that's the case, we'll try their LAN address and if we
+ // can reach it through that address, we'll allow it.
+ ServerInfoResponse* lanInfo = [self getServerInfoResponseForAddress:host.localAddress];
+ if ([lanInfo isStatusOk]) {
+ TemporaryHost* lanHost = [[TemporaryHost alloc] init];
+ [lanInfo populateHost:lanHost];
+
+ if (![lanHost.uuid isEqualToString:host.uuid]) {
+ // This is a different host, so it's prohibited
+ prohibitedAddress = YES;
+ }
+ else {
+ // This is the same host that is reachable on the LAN
+ prohibitedAddress = NO;
+ }
+ }
+ else {
+ // LAN request failed, so it's a prohibited address
+ prohibitedAddress = YES;
+ }
+ }
+ else {
+ // It's an RFC 1918 IPv4 address or IPv6 address which counts as LAN
+ prohibitedAddress = NO;
+ }
+
+ if (prohibitedAddress) {
+ callback(nil, prohibitedAddressMessage);
+ return;
+ }
+ else if ([DiscoveryManager isAddressLAN:inet_addr([hostAddress UTF8String])]) {
+ // Don't send a STUN request if we're connected to a VPN. We'll likely get the VPN
+ // gateway's external address rather than the external address of the LAN.
+ if (![Utils isActiveNetworkVPN]) {
+ // This host was discovered over a permissible LAN address, so we can update our
+ // external address for this host.
+ struct in_addr wanAddr;
+ int err = LiFindExternalAddressIP4("stun.moonlight-stream.org", 3478, &wanAddr.s_addr);
+ if (err == 0) {
+ char addrStr[INET_ADDRSTRLEN];
+ inet_ntop(AF_INET, &wanAddr, addrStr, sizeof(addrStr));
+ host.externalAddress = [NSString stringWithFormat: @"%s", addrStr];
+ }
+ }
+ }
+ }
+
if (![self addHostToDiscovery:host]) {
- callback(nil, @"Host already added");
+ callback(nil, @"Host information updated");
} else {
callback(host, nil);
}
+ } else if (!prohibitedAddress) {
+ callback(nil, @"Could not connect to host.\n\nIf you're hosting using GeForce Experience, make sure you've enabled the toggle on the SHIELD tab.\n\nIf you're hosting using Sunshine, ensure it is running properly. If you're using a non-default port, you will need to include that here.");
} else {
- callback(nil, @"Could not connect to host. Ensure GameStream is enabled in GeForce Experience on your PC.");
+ callback(nil, prohibitedAddressMessage);
}
-
+}
+
+- (void) resetDiscoveryState {
+ // Allow us to rediscover hosts that were already found before
+ [_mdnsMan forgetHosts];
}
- (void) startDiscovery {
+ if (shouldDiscover) {
+ return;
+ }
+
Log(LOG_I, @"Starting discovery");
shouldDiscover = YES;
[_mdnsMan searchForHosts];
- for (TemporaryHost* host in _hostQueue) {
- [_opQueue addOperation:[self createWorkerForHost:host]];
+
+ @synchronized (_hostQueue) {
+ for (TemporaryHost* host in _hostQueue) {
+ if (![_pausedHosts containsObject:host]) {
+ [_opQueue addOperation:[self createWorkerForHost:host]];
+ }
+ }
}
}
- (void) stopDiscovery {
+ if (!shouldDiscover) {
+ return;
+ }
+
Log(LOG_I, @"Stopping discovery");
shouldDiscover = NO;
[_mdnsMan stopSearching];
@@ -87,10 +244,18 @@
- (void) stopDiscoveryBlocking {
Log(LOG_I, @"Stopping discovery and waiting for workers to stop");
- shouldDiscover = NO;
- [_mdnsMan stopSearching];
- [_opQueue cancelAllOperations];
+
+ if (shouldDiscover) {
+ shouldDiscover = NO;
+ [_mdnsMan stopSearching];
+ [_opQueue cancelAllOperations];
+ }
+
+ // Ensure we always wait, just in case discovery
+ // was stopped already but in an async manner that
+ // left operations in progress.
[_opQueue waitUntilAllOperationsAreFinished];
+
Log(LOG_I, @"All discovery workers stopped");
}
@@ -101,6 +266,12 @@
TemporaryHost *existingHost = [self getHostInDiscovery:host.uuid];
if (existingHost != nil) {
+ // NB: Our logic here depends on the fact that we never propagate
+ // the entire TemporaryHost to existingHost. In particular, when mDNS
+ // discovers a PC and we poll it, we will do so over HTTP which will
+ // not have accurate pair state. The fields explicitly copied below
+ // are accurate though.
+
// Update address of existing host
if (host.address != nil) {
existingHost.address = host.address;
@@ -108,71 +279,96 @@
if (host.localAddress != nil) {
existingHost.localAddress = host.localAddress;
}
+ if (host.ipv6Address != nil) {
+ existingHost.ipv6Address = host.ipv6Address;
+ }
if (host.externalAddress != nil) {
existingHost.externalAddress = host.externalAddress;
}
existingHost.activeAddress = host.activeAddress;
+ existingHost.state = host.state;
return NO;
}
else {
- // If we were added without an explicit address,
- // populate it from our other available addresses
- if (host.address == nil) {
- if (host.externalAddress != nil) {
- host.address = host.externalAddress;
+ @synchronized (_hostQueue) {
+ [_hostQueue addObject:host];
+ if (shouldDiscover) {
+ [_opQueue addOperation:[self createWorkerForHost:host]];
}
- else {
- host.address = host.localAddress;
- }
- }
- [_hostQueue addObject:host];
- if (shouldDiscover) {
- [_opQueue addOperation:[self createWorkerForHost:host]];
}
return YES;
}
}
- (void) removeHostFromDiscovery:(TemporaryHost *)host {
- for (DiscoveryWorker* worker in [_opQueue operations]) {
- if ([worker getHost] == host) {
- [worker cancel];
- }
- }
- [_hostQueue removeObject:host];
-}
-
-// Override from MDNSCallback
-- (void)updateHosts:(NSArray *)hosts {
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
- // Discover the hosts before adding to eliminate duplicates
- for (TemporaryHost* host in hosts) {
- Log(LOG_I, @"Found host through MDNS: %@:", host.name);
- // Since this is on a background thread, we do not need to use the opQueue
- DiscoveryWorker* worker = (DiscoveryWorker*)[self createWorkerForHost:host];
- [worker discoverHost];
- if ([self addHostToDiscovery:host]) {
- Log(LOG_I, @"Adding host to discovery: %@", host.name);
- [_callback updateAllHosts:_hostQueue];
- } else {
- Log(LOG_I, @"Not adding host to discovery: %@", host.name);
+ @synchronized (_hostQueue) {
+ for (DiscoveryWorker* worker in [_opQueue operations]) {
+ if ([worker getHost] == host) {
+ [worker cancel];
}
}
- });
+
+ [_hostQueue removeObject:host];
+ [_pausedHosts removeObject:host];
+ }
+}
+
+- (void) pauseDiscoveryForHost:(TemporaryHost *)host {
+ @synchronized (_hostQueue) {
+ // Stop any worker for the host
+ for (DiscoveryWorker* worker in [_opQueue operations]) {
+ if ([worker getHost] == host) {
+ [worker cancel];
+ }
+ }
+
+ // Add it to the paused hosts list
+ [_pausedHosts addObject:host];
+ }
+}
+
+- (void) resumeDiscoveryForHost:(TemporaryHost *)host {
+ @synchronized (_hostQueue) {
+ // Remove it from the paused hosts list
+ [_pausedHosts removeObject:host];
+
+ // Start discovery again
+ if (shouldDiscover) {
+ [_opQueue addOperation:[self createWorkerForHost:host]];
+ }
+ }
+}
+
+// Override from MDNSCallback - called in a worker thread
+- (void)updateHost:(TemporaryHost*)host {
+ // Discover the hosts before adding to eliminate duplicates
+ Log(LOG_D, @"Found host through MDNS: %@:", host.name);
+ // Since this is on a background thread, we do not need to use the opQueue
+ DiscoveryWorker* worker = (DiscoveryWorker*)[self createWorkerForHost:host];
+ [worker discoverHost];
+ if ([self addHostToDiscovery:host]) {
+ Log(LOG_I, @"Found new host through MDNS: %@:", host.name);
+ @synchronized (_hostQueue) {
+ [_callback updateAllHosts:_hostQueue];
+ }
+ } else {
+ Log(LOG_D, @"Found existing host through MDNS: %@", host.name);
+ }
}
- (TemporaryHost*) getHostInDiscovery:(NSString*)uuidString {
- for (int i = 0; i < _hostQueue.count; i++) {
- TemporaryHost* discoveredHost = [_hostQueue objectAtIndex:i];
- if (discoveredHost.uuid.length > 0 && [discoveredHost.uuid isEqualToString:uuidString]) {
- return discoveredHost;
+ @synchronized (_hostQueue) {
+ for (TemporaryHost* discoveredHost in _hostQueue) {
+ if (discoveredHost.uuid.length > 0 && [discoveredHost.uuid isEqualToString:uuidString]) {
+ return discoveredHost;
+ }
}
}
return nil;
}
- (NSOperation*) createWorkerForHost:(TemporaryHost*)host {
- DiscoveryWorker* worker = [[DiscoveryWorker alloc] initWithHost:host uniqueId:_uniqueId cert:_cert];
+ DiscoveryWorker* worker = [[DiscoveryWorker alloc] initWithHost:host uniqueId:_uniqueId];
return worker;
}
diff --git a/Limelight/Network/DiscoveryWorker.h b/Limelight/Network/DiscoveryWorker.h
index c8d2a6c..da6491d 100644
--- a/Limelight/Network/DiscoveryWorker.h
+++ b/Limelight/Network/DiscoveryWorker.h
@@ -6,12 +6,11 @@
// Copyright (c) 2015 Moonlight Stream. All rights reserved.
//
-#import
#import "TemporaryHost.h"
@interface DiscoveryWorker : NSOperation
-- (id) initWithHost:(TemporaryHost*)host uniqueId:(NSString*)uniqueId cert:(NSData*)cert;
+- (id) initWithHost:(TemporaryHost*)host uniqueId:(NSString*)uniqueId;
- (void) discoverHost;
- (TemporaryHost*) getHost;
diff --git a/Limelight/Network/DiscoveryWorker.m b/Limelight/Network/DiscoveryWorker.m
index 15ae3b4..a3c80aa 100644
--- a/Limelight/Network/DiscoveryWorker.m
+++ b/Limelight/Network/DiscoveryWorker.m
@@ -16,16 +16,14 @@
@implementation DiscoveryWorker {
TemporaryHost* _host;
NSString* _uniqueId;
- NSData* _cert;
}
static const float POLL_RATE = 2.0f; // Poll every 2 seconds
-- (id) initWithHost:(TemporaryHost*)host uniqueId:(NSString*)uniqueId cert:(NSData*)cert {
+- (id) initWithHost:(TemporaryHost*)host uniqueId:(NSString*)uniqueId {
self = [super init];
_host = host;
_uniqueId = uniqueId;
- _cert = cert;
return self;
}
@@ -48,11 +46,14 @@ static const float POLL_RATE = 2.0f; // Poll every 2 seconds
if (_host.localAddress != nil) {
[array addObject:_host.localAddress];
}
+ if (_host.address != nil) {
+ [array addObject:_host.address];
+ }
if (_host.externalAddress != nil) {
[array addObject:_host.externalAddress];
}
- if (_host.address != nil) {
- [array addObject:_host.address];
+ if (_host.ipv6Address != nil) {
+ [array addObject:_host.ipv6Address];
}
// Remove duplicate addresses from the list.
@@ -88,8 +89,9 @@ static const float POLL_RATE = 2.0f; // Poll every 2 seconds
Log(LOG_D, @"%@ has %d unique addresses", _host.name, [addresses count]);
- // Give the PC 3 tries to respond before declaring it offline
- for (int i = 0; i < 3; i++) {
+ // Give the PC 2 tries to respond before declaring it offline if we've seen it before.
+ // If this is an unknown PC, update the status after 1 attempt to get the UI refreshed quickly.
+ for (int i = 0; i < (_host.state == StateUnknown ? 1 : 2); i++) {
for (NSString *address in addresses) {
if (self.cancelled) {
// Get out without updating the status because
@@ -98,11 +100,11 @@ static const float POLL_RATE = 2.0f; // Poll every 2 seconds
return;
}
- ServerInfoResponse* serverInfoResp = [self requestInfoAtAddress:address];
+ ServerInfoResponse* serverInfoResp = [self requestInfoAtAddress:address cert:_host.serverCert];
receivedResponse = [self checkResponse:serverInfoResp];
if (receivedResponse) {
- [serverInfoResp populateHost:_host];
_host.activeAddress = address;
+ [serverInfoResp populateHost:_host];
// Update the database using the response
DataManager *dataManager = [[DataManager alloc] init];
@@ -112,29 +114,22 @@ static const float POLL_RATE = 2.0f; // Poll every 2 seconds
}
if (receivedResponse) {
- Log(LOG_I, @"Received serverinfo response on try %d", i);
+ Log(LOG_D, @"Received serverinfo response on try %d", i);
break;
}
- else {
- // Wait for one second then retry
- [NSThread sleepForTimeInterval:1];
- }
}
- _host.online = receivedResponse;
+ _host.state = receivedResponse ? StateOnline : StateOffline;
if (receivedResponse) {
- Log(LOG_D, @"Received response from: %@\n{\n\t address:%@ \n\t localAddress:%@ \n\t externalAddress:%@ \n\t uuid:%@ \n\t mac:%@ \n\t pairState:%d \n\t online:%d \n\t activeAddress:%@ \n}", _host.name, _host.address, _host.localAddress, _host.externalAddress, _host.uuid, _host.mac, _host.pairState, _host.online, _host.activeAddress);
+ Log(LOG_D, @"Received response from: %@\n{\n\t address:%@ \n\t localAddress:%@ \n\t externalAddress:%@ \n\t ipv6Address:%@ \n\t uuid:%@ \n\t mac:%@ \n\t pairState:%d \n\t online:%d \n\t activeAddress:%@ \n}", _host.name, _host.address, _host.localAddress, _host.externalAddress, _host.ipv6Address, _host.uuid, _host.mac, _host.pairState, _host.state, _host.activeAddress);
}
}
-- (ServerInfoResponse*) requestInfoAtAddress:(NSString*)address {
- HttpManager* hMan = [[HttpManager alloc] initWithHost:address
- uniqueId:_uniqueId
- deviceName:deviceName
- cert:_cert];
+- (ServerInfoResponse*) requestInfoAtAddress:(NSString*)address cert:(NSData*)cert {
+ HttpManager* hMan = [[HttpManager alloc] initWithAddress:address httpsPort:0 serverCert:cert];
ServerInfoResponse* response = [[ServerInfoResponse alloc] init];
[hMan executeRequestSynchronously:[HttpRequest requestForResponse:response
- withUrlRequest:[hMan newServerInfoRequest]
+ withUrlRequest:[hMan newServerInfoRequest:true]
fallbackError:401 fallbackRequest:[hMan newHttpServerInfoRequest]]];
return response;
}
diff --git a/Limelight/Network/HttpManager.h b/Limelight/Network/HttpManager.h
index 97caaaa..4d23846 100644
--- a/Limelight/Network/HttpManager.h
+++ b/Limelight/Network/HttpManager.h
@@ -6,24 +6,27 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
#import "HttpResponse.h"
#import "HttpRequest.h"
+#import "StreamConfiguration.h"
+#import "TemporaryHost.h"
@interface HttpManager : NSObject
-- (id) initWithHost:(NSString*) host uniqueId:(NSString*) uniqueId deviceName:(NSString*) deviceName cert:(NSData*) cert;
-- (NSURLRequest*) newPairRequest:(NSData*)salt;
+- (id) initWithHost:(TemporaryHost*) host;
+- (id) initWithAddress:(NSString*) hostAddressPortString httpsPort:(unsigned short) httpsPort serverCert:(NSData*) serverCert;
+- (void) setServerCert:(NSData*) serverCert;
+- (NSURLRequest*) newPairRequest:(NSData*)salt clientCert:(NSData*)clientCert;
- (NSURLRequest*) newUnpairRequest;
- (NSURLRequest*) newChallengeRequest:(NSData*)challenge;
- (NSURLRequest*) newChallengeRespRequest:(NSData*)challengeResp;
- (NSURLRequest*) newClientSecretRespRequest:(NSString*)clientPairSecret;
- (NSURLRequest*) newPairChallenge;
- (NSURLRequest*) newAppListRequest;
-- (NSURLRequest*) newServerInfoRequest;
+- (NSURLRequest*) newServerInfoRequest:(bool)fastFail;
+- (NSURLRequest*) newHttpServerInfoRequest:(bool)fastFail;
- (NSURLRequest*) newHttpServerInfoRequest;
-- (NSURLRequest*) newLaunchRequest:(NSString*)appId width:(int)width height:(int)height refreshRate:(int)refreshRate rikey:(NSString*)rikey rikeyid:(int)rikeyid;
-- (NSURLRequest*) newResumeRequestWithRiKey:(NSString*)riKey riKeyId:(int)riKeyId;
+- (NSURLRequest*) newLaunchOrResumeRequest:(NSString*)verb config:(StreamConfiguration*)config;
- (NSURLRequest*) newQuitAppRequest;
- (NSURLRequest*) newAppAssetRequestWithAppId:(NSString*)appId;
- (void) executeRequestSynchronously:(HttpRequest*)request;
diff --git a/Limelight/Network/HttpManager.m b/Limelight/Network/HttpManager.m
index 6bef638..39bd354 100644
--- a/Limelight/Network/HttpManager.m
+++ b/Limelight/Network/HttpManager.m
@@ -10,28 +10,29 @@
#import "HttpRequest.h"
#import "CryptoManager.h"
#import "TemporaryApp.h"
+#import "ServerInfoResponse.h"
#include
#include
+#include
+
+#define SHORT_TIMEOUT_SEC 2
+#define NORMAL_TIMEOUT_SEC 5
+#define LONG_TIMEOUT_SEC 60
+#define EXTRA_LONG_TIMEOUT_SEC 180
+
@implementation HttpManager {
- NSURLSession* _urlSession;
+ NSString* _urlSafeHostName;
NSString* _baseHTTPURL;
- NSString* _baseHTTPSURL;
- NSString* _host;
NSString* _uniqueId;
NSString* _deviceName;
- NSData* _cert;
- NSMutableData* _respData;
- NSData* _requestResp;
- dispatch_semaphore_t _requestLock;
+ NSData* _serverCert;
- BOOL _errorOccurred;
+ TemporaryHost *_host; // May be nil
+ NSString* _baseHTTPSURL;
}
-static const NSString* HTTP_PORT = @"47989";
-static const NSString* HTTPS_PORT = @"47984";
-
+ (NSData*) fixXmlVersion:(NSData*) xmlData {
NSString* dataString = [[NSString alloc] initWithData:xmlData encoding:NSUTF8StringEncoding];
NSString* xmlString = [dataString stringByReplacingOccurrencesOfString:@"UTF-16" withString:@"UTF-8" options:NSCaseInsensitiveSearch range:NSMakeRange(0, [dataString length])];
@@ -39,50 +40,115 @@ static const NSString* HTTPS_PORT = @"47984";
return [xmlString dataUsingEncoding:NSUTF8StringEncoding];
}
-- (id) initWithHost:(NSString*) host uniqueId:(NSString*) uniqueId deviceName:(NSString*) deviceName cert:(NSData*) cert {
- self = [super init];
+- (void) setServerCert:(NSData*) serverCert {
+ _serverCert = serverCert;
+}
+
+- (id) initWithHost:(TemporaryHost*) host {
+ self = [self initWithAddress:host.activeAddress httpsPort:host.httpsPort serverCert:host.serverCert];
_host = host;
- _uniqueId = uniqueId;
- _deviceName = deviceName;
- _cert = cert;
- _baseHTTPURL = [NSString stringWithFormat:@"http://%@:%@", host, HTTP_PORT];
- _baseHTTPSURL = [NSString stringWithFormat:@"https://%@:%@", host, HTTPS_PORT];
- _requestLock = dispatch_semaphore_create(0);
- _respData = [[NSMutableData alloc] init];
- NSURLSessionConfiguration* config = [NSURLSessionConfiguration ephemeralSessionConfiguration];
- _urlSession = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];
return self;
}
+- (id) initWithAddress:(NSString*) hostAddressPortString httpsPort:(unsigned short)httpsPort serverCert:(NSData*) serverCert {
+ self = [super init];
+ // Use the same UID for all Moonlight clients to allow them
+ // quit games started on another Moonlight client.
+ _uniqueId = @"0123456789ABCDEF";
+ _deviceName = deviceName;
+ _serverCert = serverCert;
+
+ NSString* address = [Utils addressPortStringToAddress:hostAddressPortString];
+ unsigned short port = [Utils addressPortStringToPort:hostAddressPortString];
+
+ // If this is an IPv6 literal, we must properly enclose it in brackets
+ if ([address containsString:@":"]) {
+ _urlSafeHostName = [NSString stringWithFormat:@"[%@]", address];
+ } else {
+ _urlSafeHostName = address;
+ }
+
+ _baseHTTPURL = [NSString stringWithFormat:@"http://%@:%u", _urlSafeHostName, port];
+
+ if (httpsPort) {
+ _baseHTTPSURL = [NSString stringWithFormat:@"https://%@:%u", _urlSafeHostName, httpsPort];
+ }
+
+ return self;
+}
+
+- (BOOL) ensureHttpsUrlPopulated:(bool)fastFail {
+ if (!_baseHTTPSURL) {
+ // Use the caller's provided port if one was specified
+ if (_host && _host.httpsPort != 0) {
+ _baseHTTPSURL = [NSString stringWithFormat:@"https://%@:%u", _urlSafeHostName, _host.httpsPort];
+ }
+ else {
+ // Query the host to retrieve the HTTPS port
+ ServerInfoResponse* serverInfoResponse = [[ServerInfoResponse alloc] init];
+ [self executeRequestSynchronously:[HttpRequest requestForResponse:serverInfoResponse withUrlRequest:[self newHttpServerInfoRequest:false]]];
+ TemporaryHost* dummyHost = [[TemporaryHost alloc] init];
+ if (![serverInfoResponse isStatusOk]) {
+ return NO;
+ }
+ [serverInfoResponse populateHost:dummyHost];
+
+ // Pass the port back if the caller provided storage for it
+ if (_host) {
+ _host.httpsPort = dummyHost.httpsPort;
+ }
+
+ _baseHTTPSURL = [NSString stringWithFormat:@"https://%@:%u", _urlSafeHostName, dummyHost.httpsPort];
+ }
+ }
+
+ return YES;
+}
+
- (void) executeRequestSynchronously:(HttpRequest*)request {
+ // This is a special case to handle failure of HTTPS port fetching
+ if (!request.request) {
+ if (request.response) {
+ request.response.statusCode = EHOSTDOWN;
+ request.response.statusMessage = @"Host is unreachable";
+ }
+
+ return;
+ }
+
+ __block NSData* requestResp = nil;
+ __block NSError* respError = nil;
+ __block dispatch_semaphore_t requestLock = dispatch_semaphore_create(0);
+
Log(LOG_D, @"Making Request: %@", request);
- [_respData setLength:0];
- [[_urlSession dataTaskWithRequest:request.request completionHandler:^(NSData * __nullable data, NSURLResponse * __nullable response, NSError * __nullable error) {
+ NSURLSession* urlSession = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration ephemeralSessionConfiguration] delegate:self delegateQueue:nil];
+ [[urlSession dataTaskWithRequest:request.request completionHandler:^(NSData * __nullable data, NSURLResponse * __nullable response, NSError * __nullable error) {
if (error != NULL) {
Log(LOG_D, @"Connection error: %@", error);
- _errorOccurred = true;
+ respError = error;
}
else {
Log(LOG_D, @"Received response: %@", response);
if (data != NULL) {
Log(LOG_D, @"\n\nReceived data: %@\n\n", [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]);
- [_respData appendData:data];
- if ([[NSString alloc] initWithData:_respData encoding:NSUTF8StringEncoding] != nil) {
- _requestResp = [HttpManager fixXmlVersion:_respData];
+ if ([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] != nil) {
+ requestResp = [HttpManager fixXmlVersion:data];
} else {
- _requestResp = _respData;
+ requestResp = data;
}
}
}
- dispatch_semaphore_signal(_requestLock);
+ dispatch_semaphore_signal(requestLock);
}] resume];
- dispatch_semaphore_wait(_requestLock, DISPATCH_TIME_FOREVER);
- if (!_errorOccurred && request.response) {
- [request.response populateWithData:_requestResp];
+ dispatch_semaphore_wait(requestLock, DISPATCH_TIME_FOREVER);
+ [urlSession invalidateAndCancel];
+
+ if (!respError && request.response) {
+ [request.response populateWithData:requestResp];
// If the fallback error code was detected, issue the fallback request
if (request.response.statusCode == request.fallbackError && request.fallbackRequest != NULL) {
@@ -93,92 +159,149 @@ static const NSString* HTTPS_PORT = @"47984";
[self executeRequestSynchronously:request];
}
}
- _errorOccurred = false;
+ else if (respError && [respError code] == NSURLErrorServerCertificateUntrusted) {
+ // We must have a pinned cert for HTTPS. If we fail, it must be due to
+ // a non-matching cert, not because we had no cert at all.
+ assert(_serverCert != nil);
+
+ if (request.fallbackRequest) {
+ // This will fall back to HTTP on serverinfo queries to allow us to pair again
+ // and get the server cert updated.
+ Log(LOG_D, @"Attempting fallback request after certificate trust failure");
+ request.request = request.fallbackRequest;
+ request.fallbackError = 0;
+ request.fallbackRequest = NULL;
+ [self executeRequestSynchronously:request];
+ }
+ }
+ else if (respError && request.response) {
+ request.response.statusCode = [respError code];
+ request.response.statusMessage = [respError localizedDescription];
+ }
}
-- (NSURLRequest*) createRequestFromString:(NSString*) urlString enableTimeout:(BOOL)normalTimeout {
+- (NSURLRequest*) createRequestFromString:(NSString*) urlString timeout:(int)timeout {
NSURL* url = [[NSURL alloc] initWithString:urlString];
NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url];
- if (normalTimeout) {
- // Timeout the request after 7 seconds
- [request setTimeoutInterval:7];
- }
- else {
- // Timeout the request after 60 seconds
- [request setTimeoutInterval:60];
- }
+ [request setTimeoutInterval:timeout];
return request;
}
-- (NSURLRequest*) newPairRequest:(NSData*)salt {
+- (NSURLRequest*) newPairRequest:(NSData*)salt clientCert:(NSData*)clientCert {
NSString* urlString = [NSString stringWithFormat:@"%@/pair?uniqueid=%@&devicename=%@&updateState=1&phrase=getservercert&salt=%@&clientcert=%@",
- _baseHTTPSURL, _uniqueId, _deviceName, [self bytesToHex:salt], [self bytesToHex:_cert]];
+ _baseHTTPURL, _uniqueId, _deviceName, [self bytesToHex:salt], [self bytesToHex:clientCert]];
// This call blocks while waiting for the user to input the PIN on the PC
- return [self createRequestFromString:urlString enableTimeout:FALSE];
+ return [self createRequestFromString:urlString timeout:EXTRA_LONG_TIMEOUT_SEC];
}
- (NSURLRequest*) newUnpairRequest {
- NSString* urlString = [NSString stringWithFormat:@"%@/unpair?uniqueid=%@", _baseHTTPSURL, _uniqueId];
- return [self createRequestFromString:urlString enableTimeout:TRUE];
+ NSString* urlString = [NSString stringWithFormat:@"%@/unpair?uniqueid=%@", _baseHTTPURL, _uniqueId];
+ return [self createRequestFromString:urlString timeout:NORMAL_TIMEOUT_SEC];
}
- (NSURLRequest*) newChallengeRequest:(NSData*)challenge {
NSString* urlString = [NSString stringWithFormat:@"%@/pair?uniqueid=%@&devicename=%@&updateState=1&clientchallenge=%@",
- _baseHTTPSURL, _uniqueId, _deviceName, [self bytesToHex:challenge]];
- return [self createRequestFromString:urlString enableTimeout:TRUE];
+ _baseHTTPURL, _uniqueId, _deviceName, [self bytesToHex:challenge]];
+ return [self createRequestFromString:urlString timeout:NORMAL_TIMEOUT_SEC];
}
- (NSURLRequest*) newChallengeRespRequest:(NSData*)challengeResp {
NSString* urlString = [NSString stringWithFormat:@"%@/pair?uniqueid=%@&devicename=%@&updateState=1&serverchallengeresp=%@",
- _baseHTTPSURL, _uniqueId, _deviceName, [self bytesToHex:challengeResp]];
- return [self createRequestFromString:urlString enableTimeout:TRUE];
+ _baseHTTPURL, _uniqueId, _deviceName, [self bytesToHex:challengeResp]];
+ return [self createRequestFromString:urlString timeout:NORMAL_TIMEOUT_SEC];
}
- (NSURLRequest*) newClientSecretRespRequest:(NSString*)clientPairSecret {
- NSString* urlString = [NSString stringWithFormat:@"%@/pair?uniqueid=%@&devicename=%@&updateState=1&clientpairingsecret=%@", _baseHTTPSURL, _uniqueId, _deviceName, clientPairSecret];
- return [self createRequestFromString:urlString enableTimeout:TRUE];
+ NSString* urlString = [NSString stringWithFormat:@"%@/pair?uniqueid=%@&devicename=%@&updateState=1&clientpairingsecret=%@", _baseHTTPURL, _uniqueId, _deviceName, clientPairSecret];
+ return [self createRequestFromString:urlString timeout:NORMAL_TIMEOUT_SEC];
}
- (NSURLRequest*) newPairChallenge {
+ if (![self ensureHttpsUrlPopulated:NO]) {
+ return nil;
+ }
+
NSString* urlString = [NSString stringWithFormat:@"%@/pair?uniqueid=%@&devicename=%@&updateState=1&phrase=pairchallenge", _baseHTTPSURL, _uniqueId, _deviceName];
- return [self createRequestFromString:urlString enableTimeout:TRUE];
+ return [self createRequestFromString:urlString timeout:NORMAL_TIMEOUT_SEC];
}
- (NSURLRequest *)newAppListRequest {
+ if (![self ensureHttpsUrlPopulated:NO]) {
+ return nil;
+ }
+
NSString* urlString = [NSString stringWithFormat:@"%@/applist?uniqueid=%@", _baseHTTPSURL, _uniqueId];
- return [self createRequestFromString:urlString enableTimeout:TRUE];
+ return [self createRequestFromString:urlString timeout:NORMAL_TIMEOUT_SEC];
}
-- (NSURLRequest *)newServerInfoRequest {
+- (NSURLRequest *)newServerInfoRequest:(bool)fastFail {
+ if (_serverCert == nil) {
+ // Use HTTP if the cert is not pinned yet
+ return [self newHttpServerInfoRequest:fastFail];
+ }
+
+ if (![self ensureHttpsUrlPopulated:fastFail]) {
+ return nil;
+ }
+
NSString* urlString = [NSString stringWithFormat:@"%@/serverinfo?uniqueid=%@", _baseHTTPSURL, _uniqueId];
- return [self createRequestFromString:urlString enableTimeout:TRUE];
+ return [self createRequestFromString:urlString timeout:(fastFail ? SHORT_TIMEOUT_SEC : NORMAL_TIMEOUT_SEC)];
+}
+
+- (NSURLRequest *)newHttpServerInfoRequest:(bool)fastFail {
+ NSString* urlString = [NSString stringWithFormat:@"%@/serverinfo", _baseHTTPURL];
+ return [self createRequestFromString:urlString timeout:(fastFail ? SHORT_TIMEOUT_SEC : NORMAL_TIMEOUT_SEC)];
}
- (NSURLRequest *)newHttpServerInfoRequest {
- NSString* urlString = [NSString stringWithFormat:@"%@/serverinfo", _baseHTTPURL];
- return [self createRequestFromString:urlString enableTimeout:TRUE];
+ return [self newHttpServerInfoRequest:false];
}
-- (NSURLRequest*) newLaunchRequest:(NSString*)appId width:(int)width height:(int)height refreshRate:(int)refreshRate rikey:(NSString*)rikey rikeyid:(int)rikeyid {
- NSString* urlString = [NSString stringWithFormat:@"%@/launch?uniqueid=%@&appid=%@&mode=%dx%dx%d&additionalStates=1&sops=1&rikey=%@&rikeyid=%d", _baseHTTPSURL, _uniqueId, appId, width, height, refreshRate, rikey, rikeyid];
+- (NSURLRequest*) newLaunchOrResumeRequest:(NSString*)verb config:(StreamConfiguration*)config {
+ if (![self ensureHttpsUrlPopulated:NO]) {
+ return nil;
+ }
+
+ // Using an FPS value over 60 causes SOPS to default to 720p60,
+ // so force it to 0 to ensure the correct resolution is set. We
+ // used to use 60 here but that locked the frame rate to 60 FPS
+ // on GFE 3.20.3. We do not do this hack for Sunshine (which is
+ // indicated by a negative version in the last field.
+ int fps = (config.frameRate > 60 && ![config.appVersion containsString:@".-"]) ? 0 : config.frameRate;
+
+ NSString* urlString = [NSString stringWithFormat:@"%@/%@?uniqueid=%@&appid=%@&mode=%dx%dx%d&additionalStates=1&sops=%d&rikey=%@&rikeyid=%d%@&localAudioPlayMode=%d&surroundAudioInfo=%d&remoteControllersBitmap=%d&gcmap=%d&gcpersist=%d%s",
+ _baseHTTPSURL, verb, _uniqueId,
+ config.appID,
+ config.width, config.height, fps,
+ config.optimizeGameSettings ? 1 : 0,
+ [Utils bytesToHex:config.riKey], config.riKeyId,
+ (config.supportedVideoFormats & VIDEO_FORMAT_MASK_10BIT) ? @"&hdrMode=1&clientHdrCapVersion=0&clientHdrCapSupportedFlagsInUint32=0&clientHdrCapMetaDataId=NV_STATIC_METADATA_TYPE_1&clientHdrCapDisplayData=0x0x0x0x0x0x0x0x0x0x0": @"",
+ config.playAudioOnPC ? 1 : 0,
+ SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION(config.audioConfiguration),
+ config.gamepadMask, config.gamepadMask,
+ !config.multiController ? 1 : 0,
+ LiGetLaunchUrlQueryParameters()];
+ Log(LOG_I, @"Requesting: %@", urlString);
// This blocks while the app is launching
- return [self createRequestFromString:urlString enableTimeout:FALSE];
-}
-
-- (NSURLRequest*) newResumeRequestWithRiKey:(NSString*)riKey riKeyId:(int)riKeyId {
- NSString* urlString = [NSString stringWithFormat:@"%@/resume?uniqueid=%@&rikey=%@&rikeyid=%d", _baseHTTPSURL, _uniqueId, riKey, riKeyId];
- // This blocks while the app is resuming
- return [self createRequestFromString:urlString enableTimeout:FALSE];
+ return [self createRequestFromString:urlString timeout:LONG_TIMEOUT_SEC];
}
- (NSURLRequest*) newQuitAppRequest {
+ if (![self ensureHttpsUrlPopulated:NO]) {
+ return nil;
+ }
+
NSString* urlString = [NSString stringWithFormat:@"%@/cancel?uniqueid=%@", _baseHTTPSURL, _uniqueId];
- return [self createRequestFromString:urlString enableTimeout:FALSE];
+ return [self createRequestFromString:urlString timeout:LONG_TIMEOUT_SEC];
}
- (NSURLRequest*) newAppAssetRequestWithAppId:(NSString *)appId {
+ if (![self ensureHttpsUrlPopulated:NO]) {
+ return nil;
+ }
+
NSString* urlString = [NSString stringWithFormat:@"%@/appasset?uniqueid=%@&appid=%@&AssetType=2&AssetIdx=0", _baseHTTPSURL, _uniqueId, appId];
- return [self createRequestFromString:urlString enableTimeout:FALSE];
+ return [self createRequestFromString:urlString timeout:NORMAL_TIMEOUT_SEC];
}
- (NSString*) bytesToHex:(NSData*)data {
@@ -196,7 +319,7 @@ static const NSString* HTTPS_PORT = @"47984";
SecIdentityCopyCertificate(identity, &certificate);
- return [[NSArray alloc] initWithObjects:(__bridge id)certificate, nil];
+ return [[NSArray alloc] initWithObjects:(__bridge_transfer id)certificate, nil];
}
// Returns the identity
@@ -208,13 +331,14 @@ static const NSString* HTTPS_PORT = @"47984";
const void *keys[] = { kSecImportExportPassphrase };
const void *values[] = { password };
CFDictionaryRef options = CFDictionaryCreate(NULL, keys, values, 1, NULL, NULL);
- CFArrayRef items = CFArrayCreate(NULL, 0, 0, NULL);
+ CFArrayRef items = nil;
OSStatus securityError = SecPKCS12Import(p12Data, options, &items);
if (securityError == errSecSuccess) {
//Log(LOG_D, @"Success opening p12 certificate. Items: %ld", CFArrayGetCount(items));
CFDictionaryRef identityDict = CFArrayGetValueAtIndex(items, 0);
- identityApp = (SecIdentityRef)CFDictionaryGetValue(identityDict, kSecImportItemIdentity);
+ identityApp = (SecIdentityRef)CFRetain(CFDictionaryGetValue(identityDict, kSecImportItemIdentity));
+ CFRelease(items);
} else {
Log(LOG_E, @"Error opening Certificate.");
}
@@ -229,6 +353,36 @@ static const NSString* HTTPS_PORT = @"47984";
// Allow untrusted server certificates
if([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust])
{
+ if (SecTrustGetCertificateCount(challenge.protectionSpace.serverTrust) != 1) {
+ Log(LOG_E, @"Server certificate count mismatch");
+ completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, NULL);
+ return;
+ }
+
+ SecCertificateRef actualCert = SecTrustGetCertificateAtIndex(challenge.protectionSpace.serverTrust, 0);
+ if (actualCert == nil) {
+ Log(LOG_E, @"Server certificate parsing error");
+ completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, NULL);
+ return;
+ }
+
+ CFDataRef actualCertData = SecCertificateCopyData(actualCert);
+ if (actualCertData == nil) {
+ Log(LOG_E, @"Server certificate data parsing error");
+ completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, NULL);
+ return;
+ }
+
+ if (!CFEqual(actualCertData, (__bridge CFDataRef)_serverCert)) {
+ Log(LOG_E, @"Server certificate mismatch");
+ CFRelease(actualCertData);
+ completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, NULL);
+ return;
+ }
+
+ CFRelease(actualCertData);
+
+ // Allow TLS handshake to proceed
completionHandler(NSURLSessionAuthChallengeUseCredential,
[NSURLCredential credentialForTrust: challenge.protectionSpace.serverTrust]);
}
@@ -238,6 +392,7 @@ static const NSString* HTTPS_PORT = @"47984";
SecIdentityRef identity = [self getClientCertificate];
NSArray* certArray = [self getCertificate:identity];
NSURLCredential* newCredential = [NSURLCredential credentialWithIdentity:identity certificates:certArray persistence:NSURLCredentialPersistencePermanent];
+ CFRelease(identity);
completionHandler(NSURLSessionAuthChallengeUseCredential, newCredential);
}
else
diff --git a/Limelight/Network/HttpRequest.h b/Limelight/Network/HttpRequest.h
index 743b58b..ace3da2 100644
--- a/Limelight/Network/HttpRequest.h
+++ b/Limelight/Network/HttpRequest.h
@@ -6,7 +6,6 @@
// Copyright (c) 2015 Moonlight Stream. All rights reserved.
//
-#import
#import "HttpResponse.h"
@interface HttpRequest : NSObject
diff --git a/Limelight/Network/HttpResponse.h b/Limelight/Network/HttpResponse.h
index 28d2636..70478a5 100644
--- a/Limelight/Network/HttpResponse.h
+++ b/Limelight/Network/HttpResponse.h
@@ -6,8 +6,6 @@
// Copyright (c) 2015 Moonlight Stream. All rights reserved.
//
-#import
-
static NSString* TAG_STATUS_CODE = @"status_code";
static NSString* TAG_STATUS_MESSAGE = @"status_message";
diff --git a/Limelight/Network/HttpResponse.m b/Limelight/Network/HttpResponse.m
index 312825b..5cb398d 100644
--- a/Limelight/Network/HttpResponse.m
+++ b/Limelight/Network/HttpResponse.m
@@ -55,21 +55,26 @@
xmlChar* statusStr = xmlGetProp(node, (const xmlChar*)[TAG_STATUS_CODE UTF8String]);
if (statusStr != NULL) {
- int status = [[NSString stringWithUTF8String:(const char*)statusStr] intValue];
+ int status = (int)[[NSString stringWithUTF8String:(const char*)statusStr] longLongValue];
xmlFree(statusStr);
self.statusCode = status;
}
xmlChar* statusMsgXml = xmlGetProp(node, (const xmlChar*)[TAG_STATUS_MESSAGE UTF8String]);
- NSString* statusMsg;
if (statusMsgXml != NULL) {
- statusMsg = [NSString stringWithUTF8String:(const char*)statusMsgXml];
+ self.statusMessage = [NSString stringWithUTF8String:(const char*)statusMsgXml];
xmlFree(statusMsgXml);
}
else {
- statusMsg = @"Server Error";
+ self.statusMessage = @"Server Error";
+ }
+
+ if (self.statusCode == -1 && [self.statusMessage isEqualToString:@"Invalid"]) {
+ // Special case handling an audio capture error which GFE doesn't
+ // provide any useful status message for.
+ self.statusCode = 418;
+ self.statusMessage = @"Missing audio capture device. Reinstalling GeForce Experience should resolve this error.";
}
- self.statusMessage = statusMsg;
node = node->children;
@@ -89,6 +94,8 @@
}
xmlFreeDoc(docPtr);
+
+ Log(LOG_D, @"Parsed XML data: %@", _elements);
}
@end
diff --git a/Limelight/Network/MDNSManager.h b/Limelight/Network/MDNSManager.h
index c0932c4..1c50994 100644
--- a/Limelight/Network/MDNSManager.h
+++ b/Limelight/Network/MDNSManager.h
@@ -6,11 +6,11 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
+#import "TemporaryHost.h"
@protocol MDNSCallback
-- (void) updateHosts:(NSArray*)hosts;
+- (void) updateHost:(TemporaryHost*)host;
@end
@@ -21,6 +21,7 @@
- (id) initWithCallback:(id) callback;
- (void) searchForHosts;
- (void) stopSearching;
+- (void) forgetHosts;
@end
diff --git a/Limelight/Network/MDNSManager.m b/Limelight/Network/MDNSManager.m
index 099c108..c0e0265 100644
--- a/Limelight/Network/MDNSManager.m
+++ b/Limelight/Network/MDNSManager.m
@@ -9,11 +9,15 @@
#import "MDNSManager.h"
#import "TemporaryHost.h"
+#include
+
+#include
+
@implementation MDNSManager {
NSNetServiceBrowser* mDNSBrowser;
- NSMutableArray* domains;
NSMutableArray* services;
BOOL scanActive;
+ BOOL timerPending;
}
static NSString* NV_SERVICE_TYPE = @"_nvstream._tcp";
@@ -23,43 +27,210 @@ static NSString* NV_SERVICE_TYPE = @"_nvstream._tcp";
self.callback = callback;
+ scanActive = FALSE;
+
mDNSBrowser = [[NSNetServiceBrowser alloc] init];
[mDNSBrowser setDelegate:self];
- domains = [[NSMutableArray alloc] init];
services = [[NSMutableArray alloc] init];
return self;
}
- (void) searchForHosts {
+ if (scanActive) {
+ return;
+ }
+
Log(LOG_I, @"Starting mDNS discovery");
scanActive = TRUE;
- [mDNSBrowser searchForServicesOfType:NV_SERVICE_TYPE inDomain:@""];
+
+ if (!timerPending) {
+ timerPending = TRUE;
+
+ // Just invoke the timer callback to save a little code
+ [self startSearchTimerCallback:nil];
+ }
}
- (void) stopSearching {
+ if (!scanActive) {
+ return;
+ }
+
Log(LOG_I, @"Stopping mDNS discovery");
scanActive = FALSE;
[mDNSBrowser stop];
}
-- (NSArray*) getFoundHosts {
- NSMutableArray* hosts = [[NSMutableArray alloc] init];
- for (NSNetService* service in services) {
- if (service.hostName != nil) {
- TemporaryHost* host = [[TemporaryHost alloc] init];
- host.activeAddress = host.localAddress = service.hostName;
- host.name = service.hostName;
- [hosts addObject:host];
+- (void) forgetHosts {
+ [services removeAllObjects];
+}
+
++ (NSString*)sockAddrToString:(NSData*)addrData {
+ char addrStr[INET6_ADDRSTRLEN];
+ struct sockaddr* addr = (struct sockaddr*)[addrData bytes];
+ if (addr->sa_family == AF_INET) {
+ inet_ntop(addr->sa_family, &((struct sockaddr_in*)addr)->sin_addr, addrStr, sizeof(addrStr));
+ unsigned short port = ntohs(((struct sockaddr_in*)addr)->sin_port);
+ return [NSString stringWithFormat: @"%s:%u", addrStr, port];
+ }
+ else {
+ struct sockaddr_in6* sin6 = (struct sockaddr_in6*)addr;
+ inet_ntop(addr->sa_family, &sin6->sin6_addr, addrStr, sizeof(addrStr));
+ unsigned short port = ntohs(((struct sockaddr_in6*)addr)->sin6_port);
+ if (sin6->sin6_scope_id != 0) {
+ // Link-local addresses with scope IDs are special
+ return [NSString stringWithFormat: @"[%s%%%u]:%u", addrStr, sin6->sin6_scope_id, port];
+ }
+ else {
+ return [NSString stringWithFormat: @"[%s]:%u", addrStr, port];
}
}
- return hosts;
+}
+
++ (BOOL)isAddress:(uint8_t*)address inSubnet:(uint8_t*)subnet netmask:(int)bits {
+ for (int i = 0; i < bits; i++) {
+ uint8_t mask = 1 << (i % 8);
+ if ((address[i / 8] & mask) != (subnet[i / 8] & mask)) {
+ return NO;
+ }
+ }
+ return YES;
+}
+
++ (BOOL)isLocalIpv6Address:(NSData*)addrData {
+ struct sockaddr_in6* sin6 = (struct sockaddr_in6*)[addrData bytes];
+ if (sin6->sin6_family != AF_INET6) {
+ return NO;
+ }
+
+ uint8_t* addrBytes = sin6->sin6_addr.s6_addr;
+ uint8_t prefix[2];
+
+ // fe80::/10
+ prefix[0] = 0xfe;
+ prefix[1] = 0x80;
+ if ([MDNSManager isAddress:addrBytes inSubnet:prefix netmask:10]) {
+ // Link-local
+ return YES;
+ }
+
+ // fec0::/10
+ prefix[0] = 0xfe;
+ prefix[1] = 0xc0;
+ if ([MDNSManager isAddress:addrBytes inSubnet:prefix netmask:10]) {
+ // Site local
+ return YES;
+ }
+
+ // fc00::/7
+ prefix[0] = 0xfc;
+ prefix[1] = 0x00;
+ if ([MDNSManager isAddress:addrBytes inSubnet:prefix netmask:7]) {
+ // ULA
+ return YES;
+ }
+
+ return NO;
+}
+
++ (NSString*)getBestIpv6Address:(NSArray*)addresses {
+ for (NSData* addrData in addresses) {
+ struct sockaddr_in6* sin6 = (struct sockaddr_in6*)[addrData bytes];
+ if (sin6->sin6_family != AF_INET6) {
+ continue;
+ }
+
+ if ([MDNSManager isLocalIpv6Address:addrData]) {
+ // Skip non-global addresses
+ continue;
+ }
+
+ uint8_t* addrBytes = sin6->sin6_addr.s6_addr;
+ uint8_t prefix[2];
+
+ // 2002::/16
+ prefix[0] = 0x20;
+ prefix[1] = 0x02;
+ if ([MDNSManager isAddress:addrBytes inSubnet:prefix netmask:16]) {
+ Log(LOG_I, @"Ignoring 6to4 address: %@", [MDNSManager sockAddrToString:addrData]);
+ continue;
+ }
+
+ // 2001::/32
+ prefix[0] = 0x20;
+ prefix[1] = 0x01;
+ if ([MDNSManager isAddress:addrBytes inSubnet:prefix netmask:32]) {
+ Log(LOG_I, @"Ignoring Teredo address: %@", [MDNSManager sockAddrToString:addrData]);
+ continue;
+ }
+
+ return [MDNSManager sockAddrToString:addrData];
+ }
+
+ return nil;
}
- (void)netServiceDidResolveAddress:(NSNetService *)service {
- Log(LOG_I, @"Resolved address: %@ -> %@", service, service.hostName);
- [self.callback updateHosts:[self getFoundHosts]];
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
+ NSArray* addresses = [service addresses];
+
+ for (NSData* addrData in addresses) {
+ Log(LOG_I, @"Resolved address: %@ -> %@", [service hostName], [MDNSManager sockAddrToString: addrData]);
+ }
+
+ TemporaryHost* host = [[TemporaryHost alloc] init];
+
+ // First, look for an IPv4 record for the local address
+ for (NSData* addrData in addresses) {
+ struct sockaddr_in* sin = (struct sockaddr_in*)[addrData bytes];
+ if (sin->sin_family != AF_INET) {
+ continue;
+ }
+
+ // Don't send a STUN request if we're connected to a VPN. We'll likely get the VPN
+ // gateway's external address rather than the external address of the LAN.
+ if (![Utils isActiveNetworkVPN]) {
+ // Since we discovered this host over IPv4 mDNS, we know we're on the same network
+ // as the PC and we can use our current WAN address as a likely candidate
+ // for our PC's external address.
+ struct in_addr wanAddr;
+ int err = LiFindExternalAddressIP4("stun.moonlight-stream.org", 3478, &wanAddr.s_addr);
+ if (err == 0) {
+ char addrStr[INET_ADDRSTRLEN];
+ inet_ntop(AF_INET, &wanAddr, addrStr, sizeof(addrStr));
+ host.externalAddress = [NSString stringWithFormat: @"%s", addrStr];
+ Log(LOG_I, @"External IPv4 address (STUN): %@ -> %@", [service hostName], host.externalAddress);
+ }
+ else {
+ Log(LOG_E, @"STUN failed to get WAN address: %d", err);
+ }
+ }
+
+ host.localAddress = [MDNSManager sockAddrToString:addrData];
+ Log(LOG_I, @"Local address chosen: %@ -> %@", [service hostName], host.localAddress);
+ break;
+ }
+
+ if (host.localAddress == nil) {
+ // If we didn't find an IPv4 record, look for a local IPv6 record
+ for (NSData* addrData in addresses) {
+ if ([MDNSManager isLocalIpv6Address:addrData]) {
+ host.localAddress = [MDNSManager sockAddrToString:addrData];
+ Log(LOG_I, @"Local address chosen: %@ -> %@", [service hostName], host.localAddress);
+ break;
+ }
+ }
+ }
+
+ host.ipv6Address = [MDNSManager getBestIpv6Address:addresses];
+ Log(LOG_I, @"IPv6 address chosen: %@ -> %@", [service hostName], host.ipv6Address);
+
+ host.activeAddress = host.localAddress;
+ host.name = service.hostName;
+ [self.callback updateHost:host];
+ });
}
- (void)netService:(NSNetService *)sender didNotResolve:(NSDictionary *)errorDict {
@@ -69,17 +240,19 @@ static NSString* NV_SERVICE_TYPE = @"_nvstream._tcp";
[NSTimer scheduledTimerWithTimeInterval:2.0
target:self
selector:@selector(retryResolveTimerCallback:)
- userInfo:nil
+ userInfo:sender
repeats:NO];
}
- (void)netServiceBrowser:(NSNetServiceBrowser *)aNetServiceBrowser didFindService:(NSNetService *)aNetService moreComing:(BOOL)moreComing {
- Log(LOG_I, @"Found service: %@", aNetService);
- [aNetService setDelegate:self];
- [aNetService resolveWithTimeout:5];
+ Log(LOG_D, @"Found service: %@", aNetService);
- [services removeObject:aNetService];
- [services addObject:aNetService];
+ if (![services containsObject:aNetService]) {
+ Log(LOG_I, @"Found new host: %@", aNetService.name);
+ [aNetService setDelegate:self];
+ [aNetService resolveWithTimeout:5];
+ [services addObject:aNetService];
+ }
}
- (void)netServiceBrowser:(NSNetServiceBrowser *)aNetServiceBrowser didRemoveService:(NSNetService *)aNetService moreComing:(BOOL)moreComing {
@@ -90,23 +263,29 @@ static NSString* NV_SERVICE_TYPE = @"_nvstream._tcp";
- (void)netServiceBrowser:(NSNetServiceBrowser *)aNetServiceBrowser didNotSearch:(NSDictionary *)errorDict {
Log(LOG_W, @"Did not perform search: \n%@", [errorDict description]);
- // Schedule a retry in 2 seconds
- [NSTimer scheduledTimerWithTimeInterval:2.0
- target:self
- selector:@selector(retrySearchTimerCallback:)
- userInfo:nil
- repeats:NO];
+ // We'll schedule a retry in startSearchTimerCallback
}
-- (void)retrySearchTimerCallback:(NSTimer *)timer {
+- (void)startSearchTimerCallback:(NSTimer *)timer {
// Check if we've been stopped since this was queued
if (!scanActive) {
+ timerPending = FALSE;
return;
}
- Log(LOG_I, @"Retrying mDNS search");
+ Log(LOG_D, @"Restarting mDNS search");
[mDNSBrowser stop];
[mDNSBrowser searchForServicesOfType:NV_SERVICE_TYPE inDomain:@""];
+
+ // Search again in 5 seconds. We need to do this because
+ // we want more aggressive querying than Bonjour will normally
+ // do for when we're at the hosts screen. This also covers scenarios
+ // where discovery didn't work, like if WiFi was disabled.
+ [NSTimer scheduledTimerWithTimeInterval:5.0
+ target:self
+ selector:@selector(startSearchTimerCallback:)
+ userInfo:nil
+ repeats:NO];
}
- (void)retryResolveTimerCallback:(NSTimer *)timer {
@@ -115,13 +294,13 @@ static NSString* NV_SERVICE_TYPE = @"_nvstream._tcp";
return;
}
- Log(LOG_I, @"Retrying mDNS resolution");
- for (NSNetService* service in services) {
- if (service.hostName == nil) {
- [service setDelegate:self];
- [service resolveWithTimeout:5];
- }
+ NSNetService* service = timer.userInfo;
+ Log(LOG_I, @"Retrying mDNS resolution for %@", service);
+
+ if (service.hostName == nil) {
+ [service setDelegate:self];
+ [service resolveWithTimeout:5];
}
}
-@end
\ No newline at end of file
+@end
diff --git a/Limelight/Network/PairManager.h b/Limelight/Network/PairManager.h
index 144eb7d..2cc29d2 100644
--- a/Limelight/Network/PairManager.h
+++ b/Limelight/Network/PairManager.h
@@ -6,22 +6,17 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
#import "HttpManager.h"
@protocol PairCallback
-- (void) showPIN:(NSString*)PIN;
-- (void) pairSuccessful;
+- (void) startPairing:(NSString*)PIN;
+- (void) pairSuccessful:(NSData*)serverCert;
- (void) pairFailed:(NSString*)message;
- (void) alreadyPaired;
@end
@interface PairManager : NSOperation
-- (id) initWithManager:(HttpManager*)httpManager andCert:(NSData*)cert callback:(id)callback;
-- (NSString*) generatePIN;
-- (NSData*) saltPIN:(NSString*)PIN;
-- (void) initiatePair:(int)serverMajorVersion;
-
+- (id) initWithManager:(HttpManager*)httpManager clientCert:(NSData*)clientCert callback:(id)callback;
@end
diff --git a/Limelight/Network/PairManager.m b/Limelight/Network/PairManager.m
index 13193a6..bac91b8 100644
--- a/Limelight/Network/PairManager.m
+++ b/Limelight/Network/PairManager.m
@@ -17,78 +17,117 @@
@implementation PairManager {
HttpManager* _httpManager;
- NSData* _cert;
+ NSData* _clientCert;
id _callback;
}
-- (id) initWithManager:(HttpManager*)httpManager andCert:(NSData*)cert callback:(id)callback {
+- (id) initWithManager:(HttpManager*)httpManager clientCert:(NSData*)clientCert callback:(id)callback {
self = [super init];
_httpManager = httpManager;
- _cert = cert;
+ _clientCert = clientCert;
_callback = callback;
return self;
}
- (void) main {
+ // We have to call startPairing before calling any other _callback functions
+ NSString* PIN = [self generatePIN];
+ [_callback startPairing:PIN];
+
ServerInfoResponse* serverInfoResp = [[ServerInfoResponse alloc] init];
- [_httpManager executeRequestSynchronously:[HttpRequest requestForResponse:serverInfoResp withUrlRequest:[_httpManager newServerInfoRequest]
+ [_httpManager executeRequestSynchronously:[HttpRequest requestForResponse:serverInfoResp withUrlRequest:[_httpManager newServerInfoRequest:false]
fallbackError:401 fallbackRequest:[_httpManager newHttpServerInfoRequest]]];
- if (serverInfoResp == nil) {
- [_callback pairFailed:@"Unable to connect to PC"];
- return;
- }
if ([serverInfoResp isStatusOk]) {
- if ([[serverInfoResp getStringTag:@"state"] hasSuffix:@"_SERVER_BUSY"]) {
- [_callback pairFailed:@"You must stop streaming before attempting to pair."];
- } else if (![[serverInfoResp getStringTag:@"PairStatus"] isEqual:@"1"]) {
+ if (![[serverInfoResp getStringTag:@"PairStatus"] isEqual:@"1"]) {
NSString* appversion = [serverInfoResp getStringTag:@"appversion"];
if (appversion == nil) {
[_callback pairFailed:@"Missing XML element"];
return;
}
- [self initiatePair: [[appversion substringToIndex:1] intValue]];
+ [self initiatePairWithPin:PIN forServerMajorVersion:[[appversion substringToIndex:1] intValue] withState:[serverInfoResp getStringTag:@"state"]];
} else {
[_callback alreadyPaired];
}
}
+ else {
+ [_callback pairFailed:serverInfoResp.statusMessage];
+ }
}
-- (void) initiatePair:(int)serverMajorVersion {
- Log(LOG_I, @"Pairing with generation %d server", serverMajorVersion);
+- (void) finishPairing:(UIBackgroundTaskIdentifier)bgId
+ forResponse:(HttpResponse*)resp
+ withFallbackError:(NSString*)errorMsg {
+ [_httpManager executeRequestSynchronously:[HttpRequest requestWithUrlRequest:[_httpManager newUnpairRequest]]];
- NSString* PIN = [self generatePIN];
- NSData* salt = [self saltPIN:PIN];
- Log(LOG_I, @"PIN: %@, saltedPIN: %@", PIN, salt);
- [_callback showPIN:PIN];
+ if (bgId != UIBackgroundTaskInvalid) {
+ [[UIApplication sharedApplication] endBackgroundTask:bgId];
+ }
+
+ if (![resp isStatusOk]) {
+ // Use the response error if the request failed
+ errorMsg = resp.statusMessage;
+ }
+
+ [_callback pairFailed:errorMsg];
+}
+
+- (void) finishPairing:(UIBackgroundTaskIdentifier)bgId withSuccess:(NSData*)derCertBytes {
+ if (bgId != UIBackgroundTaskInvalid) {
+ [[UIApplication sharedApplication] endBackgroundTask:bgId];
+ }
+
+ [_callback pairSuccessful:derCertBytes];
+}
+
+// All codepaths must call finishPairing exactly once before returning!
+- (void) initiatePairWithPin:(NSString*)PIN forServerMajorVersion:(int)serverMajorVersion withState:(NSString*)state {
+ Log(LOG_I, @"Pairing with generation %d server in state %@", serverMajorVersion, state);
+
+ // Start a background task to help prevent the app from being killed
+ // while pairing is in progress.
+ UIBackgroundTaskIdentifier bgId = [[UIApplication sharedApplication] beginBackgroundTaskWithName:@"Pairing PC" expirationHandler:^{
+ Log(LOG_W, @"Background pairing time has expired!");
+ }];
+
+ NSData* salt = [Utils randomBytes:16];
+ NSData* saltedPIN = [self concatData:salt with:[PIN dataUsingEncoding:NSUTF8StringEncoding]];
+
+ Log(LOG_I, @"PIN: %@, salt %@", PIN, salt);
HttpResponse* pairResp = [[HttpResponse alloc] init];
- [_httpManager executeRequestSynchronously:[HttpRequest requestForResponse:pairResp withUrlRequest:[_httpManager newPairRequest:salt]]];
+ [_httpManager executeRequestSynchronously:[HttpRequest requestForResponse:pairResp withUrlRequest:[_httpManager newPairRequest:salt clientCert:_clientCert]]];
if (![self verifyResponseStatus:pairResp]) {
- return;
- }
- NSInteger pairedStatus;
- if (![pairResp getIntTag:@"paired" value:&pairedStatus] || !pairedStatus) {
- [_callback pairFailed:@"Pairing was declined by the target."];
+ // GFE does not allow pairing while a server is busy, but Sunshine does. We give it a try and display the busy error if it fails.
+ if ([state hasSuffix:@"_SERVER_BUSY"]) {
+ [self finishPairing:bgId forResponse:pairResp withFallbackError:@"You cannot pair while a previous session is still running on the host PC. Quit any running games or reboot the host PC, then try pairing again."];
+ }
+ else {
+ [self finishPairing:bgId forResponse:pairResp withFallbackError:@"Pairing was declined by the target."];
+ }
return;
}
NSString* plainCert = [pairResp getStringTag:@"plaincert"];
if ([plainCert length] == 0) {
- [_callback pairFailed:@"Another pairing attempt is already in progress."];
+ [self finishPairing:bgId forResponse:pairResp withFallbackError:@"Another pairing attempt is already in progress."];
return;
}
+ // Pin the cert for TLS usage on this host
+ NSData* derCertBytes = [CryptoManager pemToDer:[Utils hexToBytes:plainCert]];
+ [_httpManager setServerCert:derCertBytes];
+
CryptoManager* cryptoMan = [[CryptoManager alloc] init];
NSData* aesKey;
// Gen 7 servers use SHA256 to get the key
int hashLength;
if (serverMajorVersion >= 7) {
- aesKey = [cryptoMan createAESKeyFromSaltSHA256:salt];
+ aesKey = [cryptoMan createAESKeyFromSaltSHA256:saltedPIN];
hashLength = 32;
}
else {
- aesKey = [cryptoMan createAESKeyFromSaltSHA1:salt];
+ aesKey = [cryptoMan createAESKeyFromSaltSHA1:saltedPIN];
hashLength = 20;
}
@@ -98,11 +137,7 @@
HttpResponse* challengeResp = [[HttpResponse alloc] init];
[_httpManager executeRequestSynchronously:[HttpRequest requestForResponse:challengeResp withUrlRequest:[_httpManager newChallengeRequest:encryptedChallenge]]];
if (![self verifyResponseStatus:challengeResp]) {
- return;
- }
- if (![challengeResp getIntTag:@"paired" value:&pairedStatus] || pairedStatus != 1) {
- [_httpManager executeRequestSynchronously:[HttpRequest requestWithUrlRequest:[_httpManager newUnpairRequest]]];
- [_callback pairFailed:@"Pairing stage #2 failed"];
+ [self finishPairing:bgId forResponse:challengeResp withFallbackError:@"Pairing stage #2 failed"];
return;
}
@@ -113,7 +148,7 @@
NSData* serverChallenge = [decServerChallengeResp subdataWithRange:NSMakeRange(hashLength, 16)];
NSData* clientSecret = [Utils randomBytes:16];
- NSData* challengeRespHashInput = [self concatData:[self concatData:serverChallenge with:[CryptoManager getSignatureFromCert:_cert]] with:clientSecret];
+ NSData* challengeRespHashInput = [self concatData:[self concatData:serverChallenge with:[CryptoManager getSignatureFromCert:_clientCert]] with:clientSecret];
NSData* challengeRespHash;
if (serverMajorVersion >= 7) {
challengeRespHash = [cryptoMan SHA256HashData: challengeRespHashInput];
@@ -121,26 +156,26 @@
else {
challengeRespHash = [cryptoMan SHA1HashData: challengeRespHashInput];
}
- NSData* challengeRespEncrypted = [cryptoMan aesEncrypt:challengeRespHash withKey:aesKey];
+
+ assert([challengeRespHash length] <= 32);
+ NSMutableData* paddedHash = [NSMutableData dataWithData:challengeRespHash];
+ [paddedHash setLength:32];
+
+ NSData* challengeRespEncrypted = [cryptoMan aesEncrypt:paddedHash withKey:aesKey];
HttpResponse* secretResp = [[HttpResponse alloc] init];
[_httpManager executeRequestSynchronously:[HttpRequest requestForResponse:secretResp withUrlRequest:[_httpManager newChallengeRespRequest:challengeRespEncrypted]]];
if (![self verifyResponseStatus:secretResp]) {
- return;
- }
- if (![secretResp getIntTag:@"paired" value:&pairedStatus] || pairedStatus != 1) {
- [_httpManager executeRequestSynchronously:[HttpRequest requestWithUrlRequest:[_httpManager newUnpairRequest]]];
- [_callback pairFailed:@"Pairing stage #3 failed"];
+ [self finishPairing:bgId forResponse:secretResp withFallbackError:@"Pairing stage #3 failed"];
return;
}
NSData* serverSecretResp = [Utils hexToBytes:[secretResp getStringTag:@"pairingsecret"]];
NSData* serverSecret = [serverSecretResp subdataWithRange:NSMakeRange(0, 16)];
- NSData* serverSignature = [serverSecretResp subdataWithRange:NSMakeRange(16, 256)];
+ NSData* serverSignature = [serverSecretResp subdataWithRange:NSMakeRange(16, serverSecretResp.length - 16)];
if (![cryptoMan verifySignature:serverSecret withSignature:serverSignature andCert:[Utils hexToBytes:plainCert]]) {
- [_httpManager executeRequestSynchronously:[HttpRequest requestWithUrlRequest:[_httpManager newUnpairRequest]]];
- [_callback pairFailed:@"Server certificate invalid"];
+ [self finishPairing:bgId forResponse:secretResp withFallbackError:@"Server certificate invalid"];
return;
}
@@ -153,8 +188,7 @@
serverChallengeRespHash = [cryptoMan SHA1HashData: serverChallengeRespHashInput];
}
if (![serverChallengeRespHash isEqual:serverResponse]) {
- [_httpManager executeRequestSynchronously:[HttpRequest requestWithUrlRequest:[_httpManager newUnpairRequest]]];
- [_callback pairFailed:@"Incorrect PIN"];
+ [self finishPairing:bgId forResponse:secretResp withFallbackError:@"Incorrect PIN"];
return;
}
@@ -162,38 +196,32 @@
HttpResponse* clientSecretResp = [[HttpResponse alloc] init];
[_httpManager executeRequestSynchronously:[HttpRequest requestForResponse:clientSecretResp withUrlRequest:[_httpManager newClientSecretRespRequest:[Utils bytesToHex:clientPairingSecret]]]];
if (![self verifyResponseStatus:clientSecretResp]) {
- return;
- }
- if (![clientSecretResp getIntTag:@"paired" value:&pairedStatus] || pairedStatus != 1) {
- [_httpManager executeRequestSynchronously:[HttpRequest requestWithUrlRequest:[_httpManager newUnpairRequest]]];
- [_callback pairFailed:@"Pairing stage #4 failed"];
+ [self finishPairing:bgId forResponse:clientSecretResp withFallbackError:@"Pairing stage #4 failed"];
return;
}
HttpResponse* clientPairChallengeResp = [[HttpResponse alloc] init];
[_httpManager executeRequestSynchronously:[HttpRequest requestForResponse:clientPairChallengeResp withUrlRequest:[_httpManager newPairChallenge]]];
if (![self verifyResponseStatus:clientPairChallengeResp]) {
+ [self finishPairing:bgId forResponse:clientPairChallengeResp withFallbackError:@"Pairing stage #5 failed"];
return;
}
- if (![clientPairChallengeResp getIntTag:@"paired" value:&pairedStatus] || pairedStatus != 1) {
- [_httpManager executeRequestSynchronously:[HttpRequest requestWithUrlRequest:[_httpManager newUnpairRequest]]];
- [_callback pairFailed:@"Pairing stage #5 failed"];
- return;
- }
- [_callback pairSuccessful];
+
+ [self finishPairing:bgId withSuccess:derCertBytes];
}
+// Caller calls finishPairing for us on failure
- (BOOL) verifyResponseStatus:(HttpResponse*)resp {
- if (resp == nil) {
- [_httpManager executeRequestSynchronously:[HttpRequest requestWithUrlRequest:[_httpManager newUnpairRequest]]];
- [_callback pairFailed:@"Network error occured."];
- return false;
- } else if (![resp isStatusOk]) {
- [_httpManager executeRequestSynchronously:[HttpRequest requestWithUrlRequest:[_httpManager newUnpairRequest]]];
- [_callback pairFailed:resp.statusMessage];
+ if (![resp isStatusOk]) {
return false;
} else {
- return true;
+ NSInteger pairedStatus;
+
+ if (![resp getIntTag:@"paired" value:&pairedStatus]) {
+ return false;
+ }
+
+ return pairedStatus == 1;
}
}
@@ -205,16 +233,9 @@
- (NSString*) generatePIN {
NSString* PIN = [NSString stringWithFormat:@"%d%d%d%d",
- arc4random() % 10, arc4random() % 10,
- arc4random() % 10, arc4random() % 10];
+ arc4random_uniform(10), arc4random_uniform(10),
+ arc4random_uniform(10), arc4random_uniform(10)];
return PIN;
}
-- (NSData*) saltPIN:(NSString*)PIN {
- NSMutableData* saltedPIN = [[NSMutableData alloc] initWithCapacity:20];
- [saltedPIN appendData:[Utils randomBytes:16]];
- [saltedPIN appendBytes:[PIN UTF8String] length:4];
- return saltedPIN;
-}
-
@end
diff --git a/Limelight/Network/ServerInfoResponse.h b/Limelight/Network/ServerInfoResponse.h
index d9c0fc2..dab491c 100644
--- a/Limelight/Network/ServerInfoResponse.h
+++ b/Limelight/Network/ServerInfoResponse.h
@@ -11,6 +11,7 @@
#define TAG_HOSTNAME @"hostname"
#define TAG_EXTERNAL_IP @"ExternalIP"
+#define TAG_HTTPS_PORT @"HttpsPort"
#define TAG_LOCAL_IP @"LocalIP"
#define TAG_UNIQUE_ID @"uniqueid"
#define TAG_MAC_ADDRESS @"mac"
@@ -18,6 +19,9 @@
#define TAG_STATE @"state"
#define TAG_CURRENT_GAME @"currentgame"
+// Sunshine extension
+#define TAG_EXTERNAL_PORT @"ExternalPort"
+
@interface ServerInfoResponse : HttpResponse
- (void) populateWithData:(NSData *)data;
diff --git a/Limelight/Network/ServerInfoResponse.m b/Limelight/Network/ServerInfoResponse.m
index 0eb479c..e210369 100644
--- a/Limelight/Network/ServerInfoResponse.m
+++ b/Limelight/Network/ServerInfoResponse.m
@@ -19,12 +19,64 @@
- (void) populateHost:(TemporaryHost*)host {
host.name = [[self getStringTag:TAG_HOSTNAME] trim];
- host.externalAddress = [[self getStringTag:TAG_EXTERNAL_IP] trim];
- host.localAddress = [[self getStringTag:TAG_LOCAL_IP] trim];
host.uuid = [[self getStringTag:TAG_UNIQUE_ID] trim];
host.mac = [[self getStringTag:TAG_MAC_ADDRESS] trim];
host.currentGame = [[self getStringTag:TAG_CURRENT_GAME] trim];
+ NSInteger httpsPort;
+ if ([self getIntTag:TAG_HTTPS_PORT value:&httpsPort]) {
+ host.httpsPort = (unsigned short)httpsPort;
+ }
+ else {
+ // Use the default if it's not specified
+ host.httpsPort = 47984;
+ }
+
+ // We might get an IPv4 loopback address if we're using GS IPv6 Forwarder
+ NSString *lanAddr = [[self getStringTag:TAG_LOCAL_IP] trim];
+ if (![lanAddr hasPrefix:@"127."]) {
+ unsigned short localPort;
+
+ // If we reached this host through this port, store our port there
+ if (host.activeAddress && [lanAddr isEqualToString:[Utils addressPortStringToAddress:host.activeAddress]]) {
+ localPort = [Utils addressPortStringToPort:host.activeAddress];
+ }
+ else if (host.localAddress) {
+ // If there's an existing local address, use the port from that
+ localPort = [Utils addressPortStringToPort:host.localAddress];
+ }
+ else {
+ // If all else fails, use 47989
+ localPort = 47989;
+ }
+
+ host.localAddress = [Utils addressAndPortToAddressPortString:lanAddr port:localPort];
+ }
+
+ // This is a Sunshine extension for WAN port remapping
+ NSInteger externalHttpPort;
+ if (![self getIntTag:TAG_EXTERNAL_PORT value:&externalHttpPort]) {
+ // Use our active port if it's not specified
+ if (host.activeAddress) {
+ externalHttpPort = [Utils addressPortStringToPort:host.activeAddress];
+ }
+ else {
+ // Otherwise use the default
+ externalHttpPort = 47989;
+ }
+ }
+
+ // Modern GFE versions don't actually give us a WAN address anymore
+ // so we leave the one that we populated from mDNS discovery via STUN.
+ NSString *wanAddr = [[self getStringTag:TAG_EXTERNAL_IP] trim];
+ if (wanAddr) {
+ host.externalAddress = [Utils addressAndPortToAddressPortString:wanAddr port:externalHttpPort];
+ }
+ else if (host.externalAddress) {
+ // If we have an external address (via STUN) already, we still need to populate the port
+ host.externalAddress = [Utils addressAndPortToAddressPortString:[Utils addressPortStringToAddress:host.externalAddress] port:externalHttpPort];
+ }
+
NSString *state = [[self getStringTag:TAG_STATE] trim];
if (![state hasSuffix:@"_SERVER_BUSY"]) {
// GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer
@@ -33,12 +85,20 @@
host.currentGame = @"0";
}
+ // GFE uses the Mjolnir codename in their state enum values
+ host.isNvidiaServerSoftware = [state containsString:@"MJOLNIR"];
+
NSInteger pairStatus;
if ([self getIntTag:TAG_PAIR_STATUS value:&pairStatus]) {
host.pairState = pairStatus ? PairStatePaired : PairStateUnpaired;
} else {
host.pairState = PairStateUnknown;
}
+
+ NSString *serverCodecModeString = [self getStringTag:@"ServerCodecModeSupport"];
+ if (serverCodecModeString != nil) {
+ host.serverCodecModeSupport = [[serverCodecModeString trim] intValue];
+ }
}
@end
diff --git a/Limelight/Network/WakeOnLanManager.h b/Limelight/Network/WakeOnLanManager.h
index a3da81e..f1d0828 100644
--- a/Limelight/Network/WakeOnLanManager.h
+++ b/Limelight/Network/WakeOnLanManager.h
@@ -6,7 +6,6 @@
// Copyright (c) 2015 Moonlight Stream. All rights reserved.
//
-#import
#import "TemporaryHost.h"
@interface WakeOnLanManager : NSObject
diff --git a/Limelight/Network/WakeOnLanManager.m b/Limelight/Network/WakeOnLanManager.m
index 35fd3fb..cd679e0 100644
--- a/Limelight/Network/WakeOnLanManager.m
+++ b/Limelight/Network/WakeOnLanManager.m
@@ -16,8 +16,15 @@
@implementation WakeOnLanManager
-static const int numPorts = 5;
-static const int ports[numPorts] = {7, 9, 47998, 47999, 48000};
+static const int numStaticPorts = 2;
+static const int staticPorts[numStaticPorts] = {
+ 9, // Standard WOL port (privileged port)
+ 47009, // Port opened by Moonlight Internet Hosting Tool for WoL (non-privileged port)
+};
+static const int numDynamicPorts = 5;
+static const int dynamicPorts[numDynamicPorts] = {
+ 47998, 47999, 48000, 48002, 48010, // Ports opened by GFE/Sunshine
+};
+ (void) populateAddress:(struct sockaddr_storage*)addr withPort:(unsigned short)port {
if (addr->ss_family == AF_INET) {
@@ -33,26 +40,34 @@ static const int ports[numPorts] = {7, 9, 47998, 47999, 48000};
+ (void) wakeHost:(TemporaryHost*)host {
NSData* wolPayload = [WakeOnLanManager createPayload:host];
- for (int i = 0; i < 3; i++) {
- const char* address;
+ for (int i = 0; i < 5; i++) {
+ NSString* address;
struct addrinfo hints, *res, *curr;
// try all ip addresses
if (i == 0 && host.localAddress != nil) {
- address = [host.localAddress UTF8String];
+ address = host.localAddress;
} else if (i == 1 && host.externalAddress != nil) {
- address = [host.externalAddress UTF8String];
+ address = host.externalAddress;
} else if (i == 2 && host.address != nil) {
- address = [host.address UTF8String];
+ address = host.address;
+ } else if (i == 3 && host.ipv6Address != nil) {
+ address = host.ipv6Address;
+ } else if (i == 4) {
+ address = @"255.255.255.255";
} else {
// Requested address wasn't present
continue;
}
+ // Get the raw address and base port from the address+port string
+ NSString* rawAddress = [Utils addressPortStringToAddress:address];
+ unsigned short basePort = [Utils addressPortStringToPort:address];
+
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_flags = AI_ADDRCONFIG;
- if (getaddrinfo(address, NULL, &hints, &res) != 0 || res == NULL) {
+ if (getaddrinfo([rawAddress UTF8String], NULL, &hints, &res) != 0 || res == NULL) {
// Failed to resolve address
Log(LOG_E, @"Failed to resolve WOL address");
continue;
@@ -63,6 +78,7 @@ static const int ports[numPorts] = {7, 9, 47998, 47999, 48000};
// may be different address families.
for (curr = res; curr != NULL; curr = curr->ai_next) {
int wolSocket;
+ int val;
wolSocket = socket(curr->ai_family, SOCK_DGRAM, IPPROTO_UDP);
if (wolSocket < 0) {
@@ -70,19 +86,36 @@ static const int ports[numPorts] = {7, 9, 47998, 47999, 48000};
continue;
}
+ val = 1;
+ setsockopt(wolSocket, SOL_SOCKET, SO_BROADCAST, &val, sizeof(val));
+
struct sockaddr_storage addr;
memset(&addr, 0, sizeof(addr));
memcpy(&addr, curr->ai_addr, curr->ai_addrlen);
- for (int j = 0; j < numPorts; j++) {
- [WakeOnLanManager populateAddress:&addr withPort:ports[j]];
+ for (int j = 0; j < numStaticPorts; j++) {
+ [WakeOnLanManager populateAddress:&addr withPort:staticPorts[j]];
long err = sendto(wolSocket,
[wolPayload bytes],
[wolPayload length],
0,
(struct sockaddr*)&addr,
curr->ai_addrlen);
- Log(LOG_I, @"Sending WOL packet returned: %ld", err);
+ Log(LOG_I, @"Sending WOL packet to port %u returned: %ld", staticPorts[j], err);
+ }
+
+ for (int j = 0; j < numDynamicPorts; j++) {
+ // Offset the WoL dynamic ports by the base port
+ unsigned short port = ((int)dynamicPorts[j] - 47989) + basePort;
+
+ [WakeOnLanManager populateAddress:&addr withPort:port];
+ long err = sendto(wolSocket,
+ [wolPayload bytes],
+ [wolPayload length],
+ 0,
+ (struct sockaddr*)&addr,
+ curr->ai_addrlen);
+ Log(LOG_I, @"Sending WOL packet to port %u returned: %ld", port, err);
}
close(wolSocket);
diff --git a/Limelight/Stream/Connection.h b/Limelight/Stream/Connection.h
index 62b1774..dc15541 100644
--- a/Limelight/Stream/Connection.h
+++ b/Limelight/Stream/Connection.h
@@ -6,27 +6,29 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
#import "VideoDecoderRenderer.h"
#import "StreamConfiguration.h"
-@protocol ConnectionCallbacks
+#define CONN_TEST_SERVER "ios.conntest.moonlight-stream.org"
-- (void) connectionStarted;
-- (void) connectionTerminated:(long)errorCode;
-- (void) stageStarting:(const char*)stageName;
-- (void) stageComplete:(const char*)stageName;
-- (void) stageFailed:(const char*)stageName withError:(long)errorCode;
-- (void) launchFailed:(NSString*)message;
-- (void) displayMessage:(const char*)message;
-- (void) displayTransientMessage:(const char*)message;
-
-@end
+typedef struct {
+ CFTimeInterval startTime;
+ CFTimeInterval endTime;
+ int totalFrames;
+ int receivedFrames;
+ int networkDroppedFrames;
+ int totalHostProcessingLatency;
+ int framesWithHostProcessingLatency;
+ int maxHostProcessingLatency;
+ int minHostProcessingLatency;
+} video_stats_t;
@interface Connection : NSOperation
-(id) initWithConfig:(StreamConfiguration*)config renderer:(VideoDecoderRenderer*)myRenderer connectionCallbacks:(id)callbacks;
-(void) terminate;
-(void) main;
+-(BOOL) getVideoStats:(video_stats_t*)stats;
+-(NSString*) getActiveCodecName;
@end
diff --git a/Limelight/Stream/Connection.m b/Limelight/Stream/Connection.m
index 17a4991..33361fc 100644
--- a/Limelight/Stream/Connection.m
+++ b/Limelight/Stream/Connection.m
@@ -7,13 +7,15 @@
//
#import "Connection.h"
+#import "Utils.h"
-#import
-#import
#import
+#define SDL_MAIN_HANDLED
+#import
+
#include "Limelight.h"
-#include "opus.h"
+#include "opus_multistream.h"
@implementation Connection {
SERVER_INFORMATION _serverInfo;
@@ -21,221 +23,286 @@
CONNECTION_LISTENER_CALLBACKS _clCallbacks;
DECODER_RENDERER_CALLBACKS _drCallbacks;
AUDIO_RENDERER_CALLBACKS _arCallbacks;
+ char _hostString[256];
+ char _appVersionString[32];
+ char _gfeVersionString[32];
+ char _rtspSessionUrl[128];
}
static NSLock* initLock;
-static OpusDecoder *opusDecoder;
+static OpusMSDecoder* opusDecoder;
static id _callbacks;
+static int lastFrameNumber;
+static int activeVideoFormat;
+static video_stats_t currentVideoStats;
+static video_stats_t lastVideoStats;
+static NSLock* videoStatsLock;
-#define PCM_BUFFER_SIZE 1024
-#define OUTPUT_BUS 0
+static SDL_AudioDeviceID audioDevice;
+static OPUS_MULTISTREAM_CONFIGURATION audioConfig;
+static void* audioBuffer;
+static int audioFrameSize;
-struct AUDIO_BUFFER_QUEUE_ENTRY {
- struct AUDIO_BUFFER_QUEUE_ENTRY *next;
- int length;
- int offset;
- char data[0];
-};
-
-#define MAX_QUEUE_ENTRIES 10
-
-static short decodedPcmBuffer[512];
-static NSLock *audioLock;
-static struct AUDIO_BUFFER_QUEUE_ENTRY *audioBufferQueue;
-static int audioBufferQueueLength;
-static AudioComponentInstance audioUnit;
static VideoDecoderRenderer* renderer;
int DrDecoderSetup(int videoFormat, int width, int height, int redrawRate, void* context, int drFlags)
{
- [renderer setupWithVideoFormat:videoFormat];
+ [renderer setupWithVideoFormat:videoFormat width:width height:height frameRate:redrawRate];
+ lastFrameNumber = 0;
+ activeVideoFormat = videoFormat;
+ memset(¤tVideoStats, 0, sizeof(currentVideoStats));
+ memset(&lastVideoStats, 0, sizeof(lastVideoStats));
return 0;
}
+void DrStart(void)
+{
+ [renderer start];
+}
+
+void DrStop(void)
+{
+ [renderer stop];
+}
+
+-(BOOL) getVideoStats:(video_stats_t*)stats
+{
+ // We return lastVideoStats because it is a complete 1 second window
+ [videoStatsLock lock];
+ if (lastVideoStats.endTime != 0) {
+ memcpy(stats, &lastVideoStats, sizeof(*stats));
+ [videoStatsLock unlock];
+ return YES;
+ }
+
+ // No stats yet
+ [videoStatsLock unlock];
+ return NO;
+}
+
+-(NSString*) getActiveCodecName
+{
+ switch (activeVideoFormat)
+ {
+ case VIDEO_FORMAT_H264:
+ return @"H.264";
+ case VIDEO_FORMAT_H264_HIGH8_444:
+ return @"H.264 (4:4:4)";
+ case VIDEO_FORMAT_H265:
+ return @"HEVC";
+ case VIDEO_FORMAT_H265_REXT8_444:
+ return @"HEVC (4:4:4)";
+ case VIDEO_FORMAT_H265_MAIN10:
+ if (LiGetCurrentHostDisplayHdrMode()) {
+ return @"HEVC Main 10 HDR";
+ }
+ else {
+ return @"HEVC Main 10 SDR";
+ }
+ case VIDEO_FORMAT_H265_REXT10_444:
+ if (LiGetCurrentHostDisplayHdrMode()) {
+ return @"HEVC Main 10 HDR (4:4:4)";
+ }
+ else {
+ return @"HEVC Main 10 SDR (4:4:4)";
+ }
+ case VIDEO_FORMAT_AV1_MAIN8:
+ return @"AV1";
+ case VIDEO_FORMAT_AV1_HIGH8_444:
+ return @"AV1 (4:4:4)";
+ case VIDEO_FORMAT_AV1_MAIN10:
+ if (LiGetCurrentHostDisplayHdrMode()) {
+ return @"AV1 10-bit HDR";
+ }
+ else {
+ return @"AV1 10-bit SDR";
+ }
+ case VIDEO_FORMAT_AV1_HIGH10_444:
+ if (LiGetCurrentHostDisplayHdrMode()) {
+ return @"AV1 10-bit HDR (4:4:4)";
+ }
+ else {
+ return @"AV1 10-bit SDR (4:4:4)";
+ }
+ default:
+ return @"UNKNOWN";
+ }
+}
+
int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit)
{
int offset = 0;
+ int ret;
unsigned char* data = (unsigned char*) malloc(decodeUnit->fullLength);
if (data == NULL) {
// A frame was lost due to OOM condition
return DR_NEED_IDR;
}
- PLENTRY entry = decodeUnit->bufferList;
- while (entry != NULL) {
- memcpy(&data[offset], entry->data, entry->length);
- offset += entry->length;
- entry = entry->next;
+ CFTimeInterval now = CACurrentMediaTime();
+ if (!lastFrameNumber) {
+ currentVideoStats.startTime = now;
+ lastFrameNumber = decodeUnit->frameNumber;
+ }
+ else {
+ // Flip stats roughly every second
+ if (now - currentVideoStats.startTime >= 1.0f) {
+ currentVideoStats.endTime = now;
+
+ [videoStatsLock lock];
+ lastVideoStats = currentVideoStats;
+ [videoStatsLock unlock];
+
+ memset(¤tVideoStats, 0, sizeof(currentVideoStats));
+ currentVideoStats.startTime = now;
+ }
+
+ // Any frame number greater than m_LastFrameNumber + 1 represents a dropped frame
+ currentVideoStats.networkDroppedFrames += decodeUnit->frameNumber - (lastFrameNumber + 1);
+ currentVideoStats.totalFrames += decodeUnit->frameNumber - (lastFrameNumber + 1);
+ lastFrameNumber = decodeUnit->frameNumber;
}
- // This function will take our buffer
- return [renderer submitDecodeBuffer:data length:decodeUnit->fullLength];
+ if (decodeUnit->frameHostProcessingLatency != 0) {
+ if (currentVideoStats.minHostProcessingLatency == 0 || decodeUnit->frameHostProcessingLatency < currentVideoStats.minHostProcessingLatency) {
+ currentVideoStats.minHostProcessingLatency = decodeUnit->frameHostProcessingLatency;
+ }
+
+ if (decodeUnit->frameHostProcessingLatency > currentVideoStats.maxHostProcessingLatency) {
+ currentVideoStats.maxHostProcessingLatency = decodeUnit->frameHostProcessingLatency;
+ }
+
+ currentVideoStats.framesWithHostProcessingLatency++;
+ currentVideoStats.totalHostProcessingLatency += decodeUnit->frameHostProcessingLatency;
+ }
+
+ currentVideoStats.receivedFrames++;
+ currentVideoStats.totalFrames++;
+
+ PLENTRY entry = decodeUnit->bufferList;
+ while (entry != NULL) {
+ // Submit parameter set NALUs directly since no copy is required by the decoder
+ if (entry->bufferType != BUFFER_TYPE_PICDATA) {
+ ret = [renderer submitDecodeBuffer:(unsigned char*)entry->data
+ length:entry->length
+ bufferType:entry->bufferType
+ decodeUnit:decodeUnit];
+ if (ret != DR_OK) {
+ free(data);
+ return ret;
+ }
+ }
+ else {
+ memcpy(&data[offset], entry->data, entry->length);
+ offset += entry->length;
+ }
+
+ entry = entry->next;
+ }
+
+ // This function will take our picture data buffer
+ return [renderer submitDecodeBuffer:data
+ length:offset
+ bufferType:BUFFER_TYPE_PICDATA
+ decodeUnit:decodeUnit];
}
int ArInit(int audioConfiguration, POPUS_MULTISTREAM_CONFIGURATION opusConfig, void* context, int flags)
{
int err;
+ SDL_AudioSpec want, have;
- // We only support stereo for now
- assert(audioConfiguration == AUDIO_CONFIGURATION_STEREO);
-
- opusDecoder = opus_decoder_create(opusConfig->sampleRate,
- opusConfig->channelCount,
- &err);
-
- audioLock = [[NSLock alloc] init];
-
- // Configure the audio session for our app
- NSError *audioSessionError = nil;
- AVAudioSession* audioSession = [AVAudioSession sharedInstance];
-
- [audioSession setPreferredSampleRate:opusConfig->sampleRate error:&audioSessionError];
- [audioSession setCategory: AVAudioSessionCategoryPlayback error: &audioSessionError];
- [audioSession setPreferredOutputNumberOfChannels:opusConfig->channelCount error:&audioSessionError];
- [audioSession setPreferredIOBufferDuration:0.005 error:&audioSessionError];
- [audioSession setActive: YES error: &audioSessionError];
-
- OSStatus status;
-
- AudioComponentDescription audioDesc;
- audioDesc.componentType = kAudioUnitType_Output;
- audioDesc.componentSubType = kAudioUnitSubType_RemoteIO;
- audioDesc.componentFlags = 0;
- audioDesc.componentFlagsMask = 0;
- audioDesc.componentManufacturer = kAudioUnitManufacturer_Apple;
-
- status = AudioComponentInstanceNew(AudioComponentFindNext(NULL, &audioDesc), &audioUnit);
-
- if (status) {
- Log(LOG_E, @"Unable to instantiate new AudioComponent: %d", (int32_t)status);
- return status;
+ if (SDL_InitSubSystem(SDL_INIT_AUDIO) < 0) {
+ Log(LOG_E, @"Failed to initialize audio subsystem: %s\n", SDL_GetError());
+ return -1;
+ }
+
+ SDL_zero(want);
+ want.freq = opusConfig->sampleRate;
+ want.format = AUDIO_S16;
+ want.channels = opusConfig->channelCount;
+ want.samples = opusConfig->samplesPerFrame;
+
+ audioDevice = SDL_OpenAudioDevice(NULL, 0, &want, &have, 0);
+ if (audioDevice == 0) {
+ Log(LOG_E, @"Failed to open audio device: %s\n", SDL_GetError());
+ ArCleanup();
+ return -1;
}
- AudioStreamBasicDescription audioFormat = {0};
- audioFormat.mSampleRate = opusConfig->sampleRate;
- audioFormat.mBitsPerChannel = 16;
- audioFormat.mFormatID = kAudioFormatLinearPCM;
- audioFormat.mFormatFlags = kAudioFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked;
- audioFormat.mChannelsPerFrame = opusConfig->channelCount;
- audioFormat.mBytesPerFrame = audioFormat.mChannelsPerFrame * (audioFormat.mBitsPerChannel / 8);
- audioFormat.mBytesPerPacket = audioFormat.mBytesPerFrame;
- audioFormat.mFramesPerPacket = audioFormat.mBytesPerPacket / audioFormat.mBytesPerFrame;
- audioFormat.mReserved = 0;
-
- status = AudioUnitSetProperty(audioUnit,
- kAudioUnitProperty_StreamFormat,
- kAudioUnitScope_Input,
- OUTPUT_BUS,
- &audioFormat,
- sizeof(audioFormat));
- if (status) {
- Log(LOG_E, @"Unable to set audio unit to input: %d", (int32_t)status);
- return status;
+ audioConfig = *opusConfig;
+ audioFrameSize = opusConfig->samplesPerFrame * sizeof(short) * opusConfig->channelCount;
+ audioBuffer = SDL_malloc(audioFrameSize);
+ if (audioBuffer == NULL) {
+ Log(LOG_E, @"Failed to allocate audio frame buffer");
+ ArCleanup();
+ return -1;
}
- AURenderCallbackStruct callbackStruct = {0};
- callbackStruct.inputProc = playbackCallback;
- callbackStruct.inputProcRefCon = NULL;
-
- status = AudioUnitSetProperty(audioUnit,
- kAudioUnitProperty_SetRenderCallback,
- kAudioUnitScope_Input,
- OUTPUT_BUS,
- &callbackStruct,
- sizeof(callbackStruct));
- if (status) {
- Log(LOG_E, @"Unable to set audio unit callback: %d", (int32_t)status);
- return status;
+ opusDecoder = opus_multistream_decoder_create(opusConfig->sampleRate,
+ opusConfig->channelCount,
+ opusConfig->streams,
+ opusConfig->coupledStreams,
+ opusConfig->mapping,
+ &err);
+ if (opusDecoder == NULL) {
+ Log(LOG_E, @"Failed to create Opus decoder");
+ ArCleanup();
+ return -1;
}
- status = AudioUnitInitialize(audioUnit);
- if (status) {
- Log(LOG_E, @"Unable to initialize audioUnit: %d", (int32_t)status);
- return status;
- }
+ // Start playback
+ SDL_PauseAudioDevice(audioDevice, 0);
- status = AudioOutputUnitStart(audioUnit);
- if (status) {
- Log(LOG_E, @"Unable to start audioUnit: %d", (int32_t)status);
- return status;
- }
+ // Disable lowering volume of other audio streams (SDL sets AVAudioSessionCategoryOptionDuckOthers by default)
+ [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback withOptions:AVAudioSessionCategoryOptionMixWithOthers error:nil];
- return status;
+ return 0;
}
void ArCleanup(void)
{
if (opusDecoder != NULL) {
- opus_decoder_destroy(opusDecoder);
+ opus_multistream_decoder_destroy(opusDecoder);
opusDecoder = NULL;
}
- OSStatus status = AudioOutputUnitStop(audioUnit);
- if (status) {
- Log(LOG_E, @"Unable to stop audioUnit: %d", (int32_t)status);
+ if (audioDevice != 0) {
+ SDL_CloseAudioDevice(audioDevice);
+ audioDevice = 0;
}
- status = AudioUnitUninitialize(audioUnit);
- if (status) {
- Log(LOG_E, @"Unable to uninitialize audioUnit: %d", (int32_t)status);
+ if (audioBuffer != NULL) {
+ SDL_free(audioBuffer);
+ audioBuffer = NULL;
}
- // Audio session is now inactive
- AVAudioSession* audioSession = [AVAudioSession sharedInstance];
- [audioSession setActive: YES error: nil];
-
- // This is safe because we're guaranteed that nobody
- // is touching this list now
- struct AUDIO_BUFFER_QUEUE_ENTRY *entry;
- while (audioBufferQueue != NULL) {
- entry = audioBufferQueue;
- audioBufferQueue = entry->next;
- audioBufferQueueLength--;
- free(entry);
- }
+ SDL_QuitSubSystem(SDL_INIT_AUDIO);
}
void ArDecodeAndPlaySample(char* sampleData, int sampleLength)
{
- int decodedLength = opus_decode(opusDecoder, (unsigned char*)sampleData, sampleLength, decodedPcmBuffer, PCM_BUFFER_SIZE / 2, 0);
- if (decodedLength > 0) {
- // Return of opus_decode is samples per channel
- decodedLength *= 4;
+ int decodeLen;
+
+ // Don't queue if there's already more than 30 ms of audio data waiting
+ // in Moonlight's audio queue.
+ if (LiGetPendingAudioDuration() > 30) {
+ return;
+ }
+
+ decodeLen = opus_multistream_decode(opusDecoder, (unsigned char *)sampleData, sampleLength,
+ (short*)audioBuffer, audioConfig.samplesPerFrame, 0);
+ if (decodeLen > 0) {
+ // Provide backpressure on the queue to ensure too many frames don't build up
+ // in SDL's audio queue.
+ while (SDL_GetQueuedAudioSize(audioDevice) / audioFrameSize > 10) {
+ SDL_Delay(1);
+ }
- struct AUDIO_BUFFER_QUEUE_ENTRY *newEntry = malloc(sizeof(*newEntry) + decodedLength);
- if (newEntry != NULL) {
- newEntry->next = NULL;
- newEntry->length = decodedLength;
- newEntry->offset = 0;
- memcpy(newEntry->data, decodedPcmBuffer, decodedLength);
-
- [audioLock lock];
- if (audioBufferQueueLength > MAX_QUEUE_ENTRIES) {
- Log(LOG_W, @"Audio player too slow. Dropping all decoded samples!");
-
- // Clear all values from the buffer queue
- struct AUDIO_BUFFER_QUEUE_ENTRY *entry;
- while (audioBufferQueue != NULL) {
- entry = audioBufferQueue;
- audioBufferQueue = entry->next;
- audioBufferQueueLength--;
- free(entry);
- }
- }
-
- if (audioBufferQueue == NULL) {
- audioBufferQueue = newEntry;
- }
- else {
- struct AUDIO_BUFFER_QUEUE_ENTRY *lastEntry = audioBufferQueue;
- while (lastEntry->next != NULL) {
- lastEntry = lastEntry->next;
- }
- lastEntry->next = newEntry;
- }
- audioBufferQueueLength++;
-
- [audioLock unlock];
+ if (SDL_QueueAudio(audioDevice,
+ audioBuffer,
+ sizeof(short) * decodeLen * audioConfig.channelCount) < 0) {
+ Log(LOG_E, @"Failed to queue audio sample: %s\n", SDL_GetError());
}
}
}
@@ -250,9 +317,9 @@ void ClStageComplete(int stage)
[_callbacks stageComplete:LiGetStageName(stage)];
}
-void ClStageFailed(int stage, long errorCode)
+void ClStageFailed(int stage, int errorCode)
{
- [_callbacks stageFailed:LiGetStageName(stage) withError:errorCode];
+ [_callbacks stageFailed:LiGetStageName(stage) withError:errorCode portTestFlags:LiGetPortFlagsFromStage(stage)];
}
void ClConnectionStarted(void)
@@ -260,21 +327,11 @@ void ClConnectionStarted(void)
[_callbacks connectionStarted];
}
-void ClConnectionTerminated(long errorCode)
+void ClConnectionTerminated(int errorCode)
{
[_callbacks connectionTerminated: errorCode];
}
-void ClDisplayMessage(const char* message)
-{
- [_callbacks displayMessage: message];
-}
-
-void ClDisplayTransientMessage(const char* message)
-{
- [_callbacks displayTransientMessage: message];
-}
-
void ClLogMessage(const char* format, ...)
{
va_list va;
@@ -283,8 +340,45 @@ void ClLogMessage(const char* format, ...)
va_end(va);
}
+void ClRumble(unsigned short controllerNumber, unsigned short lowFreqMotor, unsigned short highFreqMotor)
+{
+ [_callbacks rumble:controllerNumber lowFreqMotor:lowFreqMotor highFreqMotor:highFreqMotor];
+}
+
+void ClConnectionStatusUpdate(int status)
+{
+ [_callbacks connectionStatusUpdate:status];
+}
+
+void ClSetHdrMode(bool enabled)
+{
+ [renderer setHdrMode:enabled];
+ [_callbacks setHdrMode:enabled];
+}
+
+void ClRumbleTriggers(uint16_t controllerNumber, uint16_t leftTriggerMotor, uint16_t rightTriggerMotor)
+{
+ [_callbacks rumbleTriggers:controllerNumber leftTrigger:leftTriggerMotor rightTrigger:rightTriggerMotor];
+}
+
+void ClSetMotionEventState(uint16_t controllerNumber, uint8_t motionType, uint16_t reportRateHz)
+{
+ [_callbacks setMotionEventState:controllerNumber motionType:motionType reportRateHz:reportRateHz];
+}
+
+void ClSetControllerLED(uint16_t controllerNumber, uint8_t r, uint8_t g, uint8_t b)
+{
+ [_callbacks setControllerLed:controllerNumber r:r g:g b:b];
+}
+
-(void) terminate
{
+ // Interrupt any action blocking LiStartConnection(). This is
+ // thread-safe and done outside initLock on purpose, since we
+ // won't be able to acquire it if LiStartConnection is in
+ // progress.
+ LiInterruptConnection();
+
// We dispatch this async to get out because this can be invoked
// on a thread inside common and we don't want to deadlock. It also avoids
// blocking on the caller's thread waiting to acquire initLock.
@@ -298,154 +392,109 @@ void ClLogMessage(const char* format, ...)
-(id) initWithConfig:(StreamConfiguration*)config renderer:(VideoDecoderRenderer*)myRenderer connectionCallbacks:(id)callbacks
{
self = [super init];
-
+
// Use a lock to ensure that only one thread is initializing
// or deinitializing a connection at a time.
if (initLock == nil) {
initLock = [[NSLock alloc] init];
}
- LiInitializeServerInformation(&_serverInfo);
- _serverInfo.address = [config.host cStringUsingEncoding:NSUTF8StringEncoding];
- _serverInfo.serverInfoAppVersion = [config.appVersion cStringUsingEncoding:NSUTF8StringEncoding];
- if (config.gfeVersion != nil) {
- _serverInfo.serverInfoGfeVersion = [config.gfeVersion cStringUsingEncoding:NSUTF8StringEncoding];
+ if (videoStatsLock == nil) {
+ videoStatsLock = [[NSLock alloc] init];
}
+ NSString *rawAddress = [Utils addressPortStringToAddress:config.host];
+ strncpy(_hostString,
+ [rawAddress cStringUsingEncoding:NSUTF8StringEncoding],
+ sizeof(_hostString) - 1);
+ strncpy(_appVersionString,
+ [config.appVersion cStringUsingEncoding:NSUTF8StringEncoding],
+ sizeof(_appVersionString) - 1);
+ if (config.gfeVersion != nil) {
+ strncpy(_gfeVersionString,
+ [config.gfeVersion cStringUsingEncoding:NSUTF8StringEncoding],
+ sizeof(_gfeVersionString) - 1);
+ }
+ if (config.rtspSessionUrl != nil) {
+ strncpy(_rtspSessionUrl,
+ [config.rtspSessionUrl cStringUsingEncoding:NSUTF8StringEncoding],
+ sizeof(_rtspSessionUrl) - 1);
+ }
+
+ LiInitializeServerInformation(&_serverInfo);
+ _serverInfo.address = _hostString;
+ _serverInfo.serverInfoAppVersion = _appVersionString;
+ if (config.gfeVersion != nil) {
+ _serverInfo.serverInfoGfeVersion = _gfeVersionString;
+ }
+ if (config.rtspSessionUrl != nil) {
+ _serverInfo.rtspSessionUrl = _rtspSessionUrl;
+ }
+ _serverInfo.serverCodecModeSupport = config.serverCodecModeSupport;
+
renderer = myRenderer;
_callbacks = callbacks;
-
+
LiInitializeStreamConfiguration(&_streamConfig);
_streamConfig.width = config.width;
_streamConfig.height = config.height;
_streamConfig.fps = config.frameRate;
_streamConfig.bitrate = config.bitRate;
+ _streamConfig.supportedVideoFormats = config.supportedVideoFormats;
+ _streamConfig.audioConfiguration = config.audioConfiguration;
- // On iOS 11, we can use HEVC if the server supports encoding it
- // and this device has hardware decode for it (A9 and later)
- if (@available(iOS 11.0, *)) {
- _streamConfig.supportsHevc = VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC);
+ // Since we require iOS 12 or above, we're guaranteed to be running
+ // on a 64-bit device with ARMv8 crypto instructions, so we don't
+ // need to check for that here.
+ _streamConfig.encryptionFlags = ENCFLG_ALL;
+
+ if ([Utils isActiveNetworkVPN]) {
+ // Force remote streaming mode when a VPN is connected
+ _streamConfig.streamingRemotely = STREAM_CFG_REMOTE;
+ _streamConfig.packetSize = 1024;
}
-
- // Use some of the HEVC encoding efficiency improvements to
- // reduce bandwidth usage while still gaining some image
- // quality improvement.
- _streamConfig.hevcBitratePercentageMultiplier = 75;
-
- // FIXME: We should use 1024 when streaming remotely
- _streamConfig.packetSize = 1292;
-
+ else {
+ // Detect remote streaming automatically based on the IP address of the target
+ _streamConfig.streamingRemotely = STREAM_CFG_AUTO;
+ _streamConfig.packetSize = 1392;
+ }
+
memcpy(_streamConfig.remoteInputAesKey, [config.riKey bytes], [config.riKey length]);
memset(_streamConfig.remoteInputAesIv, 0, 16);
int riKeyId = htonl(config.riKeyId);
memcpy(_streamConfig.remoteInputAesIv, &riKeyId, sizeof(riKeyId));
-
+
LiInitializeVideoCallbacks(&_drCallbacks);
_drCallbacks.setup = DrDecoderSetup;
- _drCallbacks.submitDecodeUnit = DrSubmitDecodeUnit;
-
- // RFI doesn't work properly with HEVC on iOS 11 with an iPhone SE (at least)
- _drCallbacks.capabilities = CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC;
-
+ _drCallbacks.start = DrStart;
+ _drCallbacks.stop = DrStop;
+ _drCallbacks.capabilities = CAPABILITY_PULL_RENDERER |
+ CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC |
+ CAPABILITY_REFERENCE_FRAME_INVALIDATION_AV1;
+
LiInitializeAudioCallbacks(&_arCallbacks);
_arCallbacks.init = ArInit;
_arCallbacks.cleanup = ArCleanup;
_arCallbacks.decodeAndPlaySample = ArDecodeAndPlaySample;
-
+ _arCallbacks.capabilities = CAPABILITY_SUPPORTS_ARBITRARY_AUDIO_DURATION;
+
LiInitializeConnectionCallbacks(&_clCallbacks);
_clCallbacks.stageStarting = ClStageStarting;
_clCallbacks.stageComplete = ClStageComplete;
_clCallbacks.stageFailed = ClStageFailed;
_clCallbacks.connectionStarted = ClConnectionStarted;
_clCallbacks.connectionTerminated = ClConnectionTerminated;
- _clCallbacks.displayMessage = ClDisplayMessage;
- _clCallbacks.displayTransientMessage = ClDisplayTransientMessage;
_clCallbacks.logMessage = ClLogMessage;
-
+ _clCallbacks.rumble = ClRumble;
+ _clCallbacks.connectionStatusUpdate = ClConnectionStatusUpdate;
+ _clCallbacks.setHdrMode = ClSetHdrMode;
+ _clCallbacks.rumbleTriggers = ClRumbleTriggers;
+ _clCallbacks.setMotionEventState = ClSetMotionEventState;
+ _clCallbacks.setControllerLED = ClSetControllerLED;
+
return self;
}
-static OSStatus playbackCallback(void *inRefCon,
- AudioUnitRenderActionFlags *ioActionFlags,
- const AudioTimeStamp *inTimeStamp,
- UInt32 inBusNumber,
- UInt32 inNumberFrames,
- AudioBufferList *ioData) {
- // Notes: ioData contains buffers (may be more than one!)
- // Fill them up as much as you can. Remember to set the size value in each buffer to match how
- // much data is in the buffer.
-
- bool ranOutOfData = false;
- for (int i = 0; i < ioData->mNumberBuffers; i++) {
- ioData->mBuffers[i].mNumberChannels = 2;
-
- if (ranOutOfData) {
- ioData->mBuffers[i].mDataByteSize = 0;
- continue;
- }
-
- if (ioData->mBuffers[i].mDataByteSize != 0) {
- int thisBufferOffset = 0;
-
- FillBufferAgain:
- // Make sure there's data to write
- if (ioData->mBuffers[i].mDataByteSize - thisBufferOffset == 0) {
- continue;
- }
-
- struct AUDIO_BUFFER_QUEUE_ENTRY *audioEntry = NULL;
-
- [audioLock lock];
- if (audioBufferQueue != NULL) {
- // Dequeue this entry temporarily
- audioEntry = audioBufferQueue;
- audioBufferQueue = audioBufferQueue->next;
- audioBufferQueueLength--;
- }
- [audioLock unlock];
-
- if (audioEntry == NULL) {
- // No data left
- ranOutOfData = true;
- ioData->mBuffers[i].mDataByteSize = thisBufferOffset;
- continue;
- }
-
- // Figure out how much data we can write
- int min = MIN(ioData->mBuffers[i].mDataByteSize - thisBufferOffset, audioEntry->length);
-
- // Copy data to the audio buffer
- memcpy(&ioData->mBuffers[i].mData[thisBufferOffset], &audioEntry->data[audioEntry->offset], min);
- thisBufferOffset += min;
-
- if (min < audioEntry->length) {
- // This entry still has unused data
- audioEntry->length -= min;
- audioEntry->offset += min;
-
- // Requeue the entry
- [audioLock lock];
- audioEntry->next = audioBufferQueue;
- audioBufferQueue = audioEntry;
- audioBufferQueueLength++;
- [audioLock unlock];
- }
- else {
- // This entry is fully depleted so free it
- free(audioEntry);
-
- // Try to grab another sample to fill this buffer with
- goto FillBufferAgain;
- }
-
- ioData->mBuffers[i].mDataByteSize = thisBufferOffset;
- }
- }
-
- return noErr;
-}
-
-
-(void) main
{
[initLock lock];
diff --git a/Limelight/Stream/ConnectionCallbacks.h b/Limelight/Stream/ConnectionCallbacks.h
new file mode 100644
index 0000000..f9c13b4
--- /dev/null
+++ b/Limelight/Stream/ConnectionCallbacks.h
@@ -0,0 +1,25 @@
+//
+// ConnectionCallbacks.h
+// Moonlight
+//
+// Created by Cameron Gutman on 11/1/20.
+// Copyright © 2020 Moonlight Game Streaming Project. All rights reserved.
+//
+
+@protocol ConnectionCallbacks
+
+- (void) connectionStarted;
+- (void) connectionTerminated:(int)errorCode;
+- (void) stageStarting:(const char*)stageName;
+- (void) stageComplete:(const char*)stageName;
+- (void) stageFailed:(const char*)stageName withError:(int)errorCode portTestFlags:(int)portTestFlags;
+- (void) launchFailed:(NSString*)message;
+- (void) rumble:(unsigned short)controllerNumber lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor;
+- (void) connectionStatusUpdate:(int)status;
+- (void) setHdrMode:(bool)enabled;
+- (void) rumbleTriggers:(uint16_t)controllerNumber leftTrigger:(uint16_t)leftTrigger rightTrigger:(uint16_t)rightTrigger;
+- (void) setMotionEventState:(uint16_t)controllerNumber motionType:(uint8_t)motionType reportRateHz:(uint16_t)reportRateHz;
+- (void) setControllerLed:(uint16_t)controllerNumber r:(uint8_t)r g:(uint8_t)g b:(uint8_t)b;
+- (void) videoContentShown;
+
+@end
diff --git a/Limelight/Stream/StreamConfiguration.h b/Limelight/Stream/StreamConfiguration.h
index 7ce1ba4..e912b57 100644
--- a/Limelight/Stream/StreamConfiguration.h
+++ b/Limelight/Stream/StreamConfiguration.h
@@ -6,19 +6,30 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
-
@interface StreamConfiguration : NSObject
@property NSString* host;
+@property unsigned short httpsPort;
@property NSString* appVersion;
@property NSString* gfeVersion;
@property NSString* appID;
+@property NSString* appName;
+@property NSString* rtspSessionUrl;
+@property int serverCodecModeSupport;
@property int width;
@property int height;
@property int frameRate;
@property int bitRate;
@property int riKeyId;
@property NSData* riKey;
+@property int gamepadMask;
+@property BOOL optimizeGameSettings;
+@property BOOL playAudioOnPC;
+@property BOOL swapABXYButtons;
+@property int audioConfiguration;
+@property int supportedVideoFormats;
+@property BOOL multiController;
+@property BOOL useFramePacing;
+@property NSData* serverCert;
@end
diff --git a/Limelight/Stream/StreamConfiguration.m b/Limelight/Stream/StreamConfiguration.m
index e463739..4d308cd 100644
--- a/Limelight/Stream/StreamConfiguration.m
+++ b/Limelight/Stream/StreamConfiguration.m
@@ -9,5 +9,5 @@
#import "StreamConfiguration.h"
@implementation StreamConfiguration
-@synthesize host, appID, width, height, frameRate, bitRate, riKeyId, riKey;
+@synthesize host, httpsPort, appID, width, height, frameRate, bitRate, riKeyId, riKey, gamepadMask, appName, optimizeGameSettings, playAudioOnPC, swapABXYButtons, audioConfiguration, supportedVideoFormats, multiController, serverCert, rtspSessionUrl, serverCodecModeSupport;
@end
diff --git a/Limelight/Stream/StreamManager.h b/Limelight/Stream/StreamManager.h
index b2285e1..277d6fe 100644
--- a/Limelight/Stream/StreamManager.h
+++ b/Limelight/Stream/StreamManager.h
@@ -6,13 +6,15 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
#import "StreamConfiguration.h"
#import "Connection.h"
@interface StreamManager : NSOperation
- (id) initWithConfig:(StreamConfiguration*)config renderView:(UIView*)view connectionCallbacks:(id)callback;
+
- (void) stopStream;
+- (NSString*) getStatsOverlayText;
+
@end
diff --git a/Limelight/Stream/StreamManager.m b/Limelight/Stream/StreamManager.m
index f3d7d15..e776fee 100644
--- a/Limelight/Stream/StreamManager.m
+++ b/Limelight/Stream/StreamManager.m
@@ -10,15 +10,18 @@
#import "CryptoManager.h"
#import "HttpManager.h"
#import "Utils.h"
-#import "OnScreenControls.h"
+
#import "StreamView.h"
#import "ServerInfoResponse.h"
#import "HttpResponse.h"
#import "HttpRequest.h"
#import "IdManager.h"
+#include
+
@implementation StreamManager {
StreamConfiguration* _config;
+
UIView* _renderView;
id _callbacks;
Connection* _connection;
@@ -34,25 +37,24 @@
return self;
}
-
- (void)main {
- [CryptoManager generateKeyPairUsingSSl];
- NSString* uniqueId = [IdManager getUniqueId];
- NSData* cert = [CryptoManager readCertFromFile];
+ [CryptoManager generateKeyPairUsingSSL];
- HttpManager* hMan = [[HttpManager alloc] initWithHost:_config.host
- uniqueId:uniqueId
- deviceName:@"roth"
- cert:cert];
+ HttpManager* hMan = [[HttpManager alloc] initWithAddress:_config.host httpsPort:_config.httpsPort
+ serverCert:_config.serverCert];
ServerInfoResponse* serverInfoResp = [[ServerInfoResponse alloc] init];
- [hMan executeRequestSynchronously:[HttpRequest requestForResponse:serverInfoResp withUrlRequest:[hMan newServerInfoRequest]
+ [hMan executeRequestSynchronously:[HttpRequest requestForResponse:serverInfoResp withUrlRequest:[hMan newServerInfoRequest:false]
fallbackError:401 fallbackRequest:[hMan newHttpServerInfoRequest]]];
NSString* pairStatus = [serverInfoResp getStringTag:@"PairStatus"];
NSString* appversion = [serverInfoResp getStringTag:@"appversion"];
NSString* gfeVersion = [serverInfoResp getStringTag:@"GfeVersion"];
NSString* serverState = [serverInfoResp getStringTag:@"state"];
- if (![serverInfoResp isStatusOk] || pairStatus == NULL || appversion == NULL || serverState == NULL) {
+ if (![serverInfoResp isStatusOk]) {
+ [_callbacks launchFailed:serverInfoResp.statusMessage];
+ return;
+ }
+ else if (pairStatus == NULL || appversion == NULL || serverState == NULL) {
[_callbacks launchFailed:@"Failed to connect to PC"];
return;
}
@@ -63,34 +65,45 @@
return;
}
- // resumeApp and launchApp handle calling launchFailed
- if ([serverState hasSuffix:@"_SERVER_BUSY"]) {
- // App already running, resume it
- if (![self resumeApp:hMan]) {
- return;
- }
- } else {
- // Start app
- if (![self launchApp:hMan]) {
+ // Only perform this check on GFE (as indicated by MJOLNIR in state value)
+ if ((_config.width > 4096 || _config.height > 4096) && [serverState containsString:@"MJOLNIR"]) {
+ // Pascal added support for 8K HEVC encoding support. Maxwell 2 could encode HEVC but only up to 4K.
+ // We can't directly identify Pascal, but we can look for HEVC Main10 which was added in the same generation.
+ NSString* codecSupport = [serverInfoResp getStringTag:@"ServerCodecModeSupport"];
+ if (codecSupport == nil || !([codecSupport intValue] & 0x200)) {
+ [_callbacks launchFailed:@"Your host PC's GPU doesn't support streaming video resolutions over 4K."];
return;
}
}
- // Set mouse delta factors from the screen resolution and stream size
- CGFloat screenScale = [[UIScreen mainScreen] scale];
- CGRect screenBounds = [[UIScreen mainScreen] bounds];
- CGSize screenSize = CGSizeMake(screenBounds.size.width * screenScale, screenBounds.size.height * screenScale);
- [((StreamView*)_renderView) setMouseDeltaFactors:_config.width / screenSize.width
- y:_config.height / screenSize.height];
-
// Populate the config's version fields from serverinfo
_config.appVersion = appversion;
_config.gfeVersion = gfeVersion;
- VideoDecoderRenderer* renderer = [[VideoDecoderRenderer alloc]initWithView:_renderView];
- _connection = [[Connection alloc] initWithConfig:_config renderer:renderer connectionCallbacks:_callbacks];
- NSOperationQueue* opQueue = [[NSOperationQueue alloc] init];
- [opQueue addOperation:_connection];
+ // resumeApp and launchApp handle calling launchFailed
+ NSString* sessionUrl;
+ if ([serverState hasSuffix:@"_SERVER_BUSY"]) {
+ // App already running, resume it
+ if (![self resumeApp:hMan receiveSessionUrl:&sessionUrl]) {
+ return;
+ }
+ } else {
+ // Start app
+ if (![self launchApp:hMan receiveSessionUrl:&sessionUrl]) {
+ return;
+ }
+ }
+
+ // Populate RTSP session URL from launch/resume response
+ _config.rtspSessionUrl = sessionUrl;
+
+ // Initializing the renderer must be done on the main thread
+ dispatch_async(dispatch_get_main_queue(), ^{
+ VideoDecoderRenderer* renderer = [[VideoDecoderRenderer alloc] initWithView:self->_renderView callbacks:self->_callbacks streamAspectRatio:(float)self->_config.width / (float)self->_config.height useFramePacing:self->_config.useFramePacing];
+ self->_connection = [[Connection alloc] initWithConfig:self->_config renderer:renderer connectionCallbacks:self->_callbacks];
+ NSOperationQueue* opQueue = [[NSOperationQueue alloc] init];
+ [opQueue addOperation:self->_connection];
+ });
}
- (void) stopStream
@@ -98,46 +111,82 @@
[_connection terminate];
}
-- (BOOL) launchApp:(HttpManager*)hMan {
+- (BOOL) launchApp:(HttpManager*)hMan receiveSessionUrl:(NSString**)sessionUrl {
HttpResponse* launchResp = [[HttpResponse alloc] init];
- [hMan executeRequestSynchronously:[HttpRequest requestForResponse:launchResp withUrlRequest:
- [hMan newLaunchRequest:_config.appID
- width:_config.width
- height:_config.height
- refreshRate:_config.frameRate
- rikey:[Utils bytesToHex:_config.riKey]
- rikeyid:_config.riKeyId]]];
+ [hMan executeRequestSynchronously:[HttpRequest requestForResponse:launchResp withUrlRequest:[hMan newLaunchOrResumeRequest:@"launch" config:_config]]];
NSString *gameSession = [launchResp getStringTag:@"gamesession"];
- if (launchResp == NULL || ![launchResp isStatusOk]) {
- [_callbacks launchFailed:@"Failed to launch app"];
+ if (![launchResp isStatusOk]) {
+ [_callbacks launchFailed:launchResp.statusMessage];
Log(LOG_E, @"Failed Launch Response: %@", launchResp.statusMessage);
return FALSE;
} else if (gameSession == NULL || [gameSession isEqualToString:@"0"]) {
- [_callbacks launchFailed:launchResp.statusMessage];
- Log(LOG_E, @"Failed to parse game session. Code: %ld Response: %@", (long)launchResp.statusCode, launchResp.statusMessage);
+ [_callbacks launchFailed:@"Failed to launch app"];
+ Log(LOG_E, @"Failed to parse game session");
return FALSE;
}
+ *sessionUrl = [launchResp getStringTag:@"sessionUrl0"];
return TRUE;
}
-- (BOOL) resumeApp:(HttpManager*)hMan {
+- (BOOL) resumeApp:(HttpManager*)hMan receiveSessionUrl:(NSString**)sessionUrl {
HttpResponse* resumeResp = [[HttpResponse alloc] init];
- [hMan executeRequestSynchronously:[HttpRequest requestForResponse:resumeResp withUrlRequest:
- [hMan newResumeRequestWithRiKey:[Utils bytesToHex:_config.riKey]
- riKeyId:_config.riKeyId]]];
+ [hMan executeRequestSynchronously:[HttpRequest requestForResponse:resumeResp withUrlRequest:[hMan newLaunchOrResumeRequest:@"resume" config:_config]]];
NSString* resume = [resumeResp getStringTag:@"resume"];
- if (resumeResp == NULL || ![resumeResp isStatusOk]) {
- [_callbacks launchFailed:@"Failed to resume app"];
+ if (![resumeResp isStatusOk]) {
+ [_callbacks launchFailed:resumeResp.statusMessage];
Log(LOG_E, @"Failed Resume Response: %@", resumeResp.statusMessage);
return FALSE;
} else if (resume == NULL || [resume isEqualToString:@"0"]) {
- [_callbacks launchFailed:resumeResp.statusMessage];
- Log(LOG_E, @"Failed to parse resume response. Code: %ld Response: %@", (long)resumeResp.statusCode, resumeResp.statusMessage);
+ [_callbacks launchFailed:@"Failed to resume app"];
+ Log(LOG_E, @"Failed to parse resume response");
return FALSE;
}
+ *sessionUrl = [resumeResp getStringTag:@"sessionUrl0"];
return TRUE;
}
+- (NSString*) getStatsOverlayText {
+ video_stats_t stats;
+
+ if (!_connection) {
+ return nil;
+ }
+
+ if (![_connection getVideoStats:&stats]) {
+ return nil;
+ }
+
+ uint32_t rtt, variance;
+ NSString* latencyString;
+ if (LiGetEstimatedRttInfo(&rtt, &variance)) {
+ latencyString = [NSString stringWithFormat:@"%u ms (variance: %u ms)", rtt, variance];
+ }
+ else {
+ latencyString = @"N/A";
+ }
+
+ NSString* hostProcessingString;
+ if (stats.framesWithHostProcessingLatency != 0) {
+ hostProcessingString = [NSString stringWithFormat:@"\nHost processing latency min/max/avg: %.1f/%.1f/%.1f ms",
+ stats.minHostProcessingLatency / 10.f,
+ stats.maxHostProcessingLatency / 10.f,
+ (float)stats.totalHostProcessingLatency / stats.framesWithHostProcessingLatency / 10.f];
+ }
+ else {
+ hostProcessingString = @"";
+ }
+
+ float interval = stats.endTime - stats.startTime;
+ return [NSString stringWithFormat:@"Video stream: %dx%d %.2f FPS (Codec: %@)\nFrames dropped by your network connection: %.2f%%\nAverage network latency: %@%@",
+ _config.width,
+ _config.height,
+ stats.totalFrames / interval,
+ [_connection getActiveCodecName],
+ stats.networkDroppedFrames / interval,
+ latencyString,
+ hostProcessingString];
+}
+
@end
diff --git a/Limelight/Stream/VideoDecoderRenderer.h b/Limelight/Stream/VideoDecoderRenderer.h
index 6c34ba3..0513374 100644
--- a/Limelight/Stream/VideoDecoderRenderer.h
+++ b/Limelight/Stream/VideoDecoderRenderer.h
@@ -6,18 +6,21 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
-
@import AVFoundation;
+#import "ConnectionCallbacks.h"
+
+#include "Limelight.h"
+
@interface VideoDecoderRenderer : NSObject
-- (id)initWithView:(UIView*)view;
+- (id)initWithView:(UIView*)view callbacks:(id)callbacks streamAspectRatio:(float)aspectRatio useFramePacing:(BOOL)useFramePacing;
-- (void)setupWithVideoFormat:(int)videoFormat;
+- (void)setupWithVideoFormat:(int)videoFormat width:(int)videoWidth height:(int)videoHeight frameRate:(int)frameRate;
+- (void)start;
+- (void)stop;
+- (void)setHdrMode:(BOOL)enabled;
-- (void)updateBufferForRange:(CMBlockBufferRef)existingBuffer data:(unsigned char *)data offset:(int)offset length:(int)nalLength;
-
-- (int)submitDecodeBuffer:(unsigned char *)data length:(int)length;
+- (int)submitDecodeBuffer:(unsigned char *)data length:(int)length bufferType:(int)bufferType decodeUnit:(PDECODE_UNIT)du;
@end
diff --git a/Limelight/Stream/VideoDecoderRenderer.m b/Limelight/Stream/VideoDecoderRenderer.m
index 7545318..5c89e5a 100644
--- a/Limelight/Stream/VideoDecoderRenderer.m
+++ b/Limelight/Stream/VideoDecoderRenderer.m
@@ -7,18 +7,34 @@
//
#import "VideoDecoderRenderer.h"
+#import "StreamView.h"
-#include "Limelight.h"
+#include
+#include
+#include
+#include
+#include
+
+// Private libavformat API for writing the AV1 Codec Configuration Box
+extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
+ int write_seq_header);
@implementation VideoDecoderRenderer {
- UIView *_view;
+ StreamView* _view;
+ id _callbacks;
+ float _streamAspectRatio;
AVSampleBufferDisplayLayer* displayLayer;
- Boolean waitingForSps, waitingForPps, waitingForVps;
int videoFormat;
+ int frameRate;
- NSData *spsData, *ppsData, *vpsData;
+ NSMutableArray *parameterSetBuffers;
+ NSData *masteringDisplayColorVolume;
+ NSData *contentLightLevelInfo;
CMVideoFormatDescriptionRef formatDesc;
+
+ CADisplayLink* _displayLink;
+ BOOL framePacing;
}
- (void)reinitializeDisplayLayer
@@ -26,10 +42,26 @@
CALayer *oldLayer = displayLayer;
displayLayer = [[AVSampleBufferDisplayLayer alloc] init];
- displayLayer.bounds = _view.bounds;
displayLayer.backgroundColor = [UIColor blackColor].CGColor;
+
+ // Ensure the AVSampleBufferDisplayLayer is sized to preserve the aspect ratio
+ // of the video stream. We used to use AVLayerVideoGravityResizeAspect, but that
+ // respects the PAR encoded in the SPS which causes our computed video-relative
+ // touch location to be wrong in StreamView if the aspect ratio of the host
+ // desktop doesn't match the aspect ratio of the stream.
+ CGSize videoSize;
+ if (_view.bounds.size.width > _view.bounds.size.height * _streamAspectRatio) {
+ videoSize = CGSizeMake(_view.bounds.size.height * _streamAspectRatio, _view.bounds.size.height);
+ } else {
+ videoSize = CGSizeMake(_view.bounds.size.width, _view.bounds.size.width / _streamAspectRatio);
+ }
displayLayer.position = CGPointMake(CGRectGetMidX(_view.bounds), CGRectGetMidY(_view.bounds));
- displayLayer.videoGravity = AVLayerVideoGravityResizeAspect;
+ displayLayer.bounds = CGRectMake(0, 0, videoSize.width, videoSize.height);
+ displayLayer.videoGravity = AVLayerVideoGravityResize;
+
+ // Hide the layer until we get an IDR frame. This ensures we
+ // can see the loading progress label as the stream is starting.
+ displayLayer.hidden = YES;
if (oldLayer != nil) {
// Switch out the old display layer with the new one
@@ -39,321 +71,614 @@
[_view.layer addSublayer:displayLayer];
}
- // We need some parameter sets before we can properly start decoding frames
- waitingForSps = true;
- spsData = nil;
- waitingForPps = true;
- ppsData = nil;
- waitingForVps = true;
- vpsData = nil;
-
if (formatDesc != nil) {
CFRelease(formatDesc);
formatDesc = nil;
}
}
-- (id)initWithView:(UIView*)view
+- (id)initWithView:(StreamView*)view callbacks:(id)callbacks streamAspectRatio:(float)aspectRatio useFramePacing:(BOOL)useFramePacing
{
self = [super init];
_view = view;
+ _callbacks = callbacks;
+ _streamAspectRatio = aspectRatio;
+ framePacing = useFramePacing;
+
+ parameterSetBuffers = [[NSMutableArray alloc] init];
[self reinitializeDisplayLayer];
return self;
}
-- (void)setupWithVideoFormat:(int)videoFormat
+- (void)setupWithVideoFormat:(int)videoFormat width:(int)videoWidth height:(int)videoHeight frameRate:(int)frameRate
{
self->videoFormat = videoFormat;
+ self->frameRate = frameRate;
+}
+
+- (void)start
+{
+ _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkCallback:)];
+ if (@available(iOS 15.0, tvOS 15.0, *)) {
+ _displayLink.preferredFrameRateRange = CAFrameRateRangeMake(self->frameRate, self->frameRate, self->frameRate);
+ }
+ else {
+ _displayLink.preferredFramesPerSecond = self->frameRate;
+ }
+ [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
+}
+
+// TODO: Refactor this
+int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit);
+
+- (void)displayLinkCallback:(CADisplayLink *)sender
+{
+ VIDEO_FRAME_HANDLE handle;
+ PDECODE_UNIT du;
+
+ while (LiPollNextVideoFrame(&handle, &du)) {
+ LiCompleteVideoFrame(handle, DrSubmitDecodeUnit(du));
+
+ if (framePacing) {
+ // Calculate the actual display refresh rate
+ double displayRefreshRate = 1 / (_displayLink.targetTimestamp - _displayLink.timestamp);
+
+ // Only pace frames if the display refresh rate is >= 90% of our stream frame rate.
+ // Battery saver, accessibility settings, or device thermals can cause the actual
+ // refresh rate of the display to drop below the physical maximum.
+ if (displayRefreshRate >= frameRate * 0.9f) {
+ // Keep one pending frame to smooth out gaps due to
+ // network jitter at the cost of 1 frame of latency
+ if (LiGetPendingVideoFrames() == 1) {
+ break;
+ }
+ }
+ }
+ }
+}
+
+- (void)stop
+{
+ [_displayLink invalidate];
}
-#define FRAME_START_PREFIX_SIZE 4
#define NALU_START_PREFIX_SIZE 3
#define NAL_LENGTH_PREFIX_SIZE 4
-#define AVC_NAL_TYPE_SPS 0x67
-#define AVC_NAL_TYPE_PPS 0x68
-#define HEVC_NAL_TYPE_VPS 0x40
-#define HEVC_NAL_TYPE_SPS 0x42
-#define HEVC_NAL_TYPE_PPS 0x44
-#define IS_NAL_SPS(x) ((videoFormat == VIDEO_FORMAT_H264) ? ((x) == AVC_NAL_TYPE_SPS) : ((x) == HEVC_NAL_TYPE_SPS))
-#define IS_NAL_PPS(x) ((videoFormat == VIDEO_FORMAT_H264) ? ((x) == AVC_NAL_TYPE_PPS) : ((x) == HEVC_NAL_TYPE_PPS))
-#define IS_NAL_VPS(x) ((videoFormat == VIDEO_FORMAT_H265) && ((x) == HEVC_NAL_TYPE_VPS))
-
-- (Boolean)readyForPictureData
-{
- if (videoFormat == VIDEO_FORMAT_H264) {
- return !waitingForSps && !waitingForPps;
- }
- else {
- // H.265 requires VPS in addition to SPS and PPS
- return !waitingForVps && !waitingForSps && !waitingForPps;
- }
-}
-
-- (Boolean)isNalReferencePicture:(unsigned char)nalType
-{
- if (videoFormat == VIDEO_FORMAT_H264) {
- return nalType == 0x65;
- }
- else {
- // HEVC has several types of reference NALU types
- switch (nalType) {
- case 0x20:
- case 0x22:
- case 0x24:
- case 0x26:
- case 0x28:
- case 0x2A:
- return true;
- default:
- return false;
- }
- }
-}
-
-- (Boolean)isNalPictureData:(unsigned char)nalType
-{
- if (videoFormat == VIDEO_FORMAT_H264) {
- return !(nalType == AVC_NAL_TYPE_SPS || nalType == AVC_NAL_TYPE_PPS);
- }
- else {
- return !(nalType == HEVC_NAL_TYPE_VPS || nalType == HEVC_NAL_TYPE_SPS || nalType == HEVC_NAL_TYPE_PPS);
- }
-}
-
-- (void)updateBufferForRange:(CMBlockBufferRef)existingBuffer data:(unsigned char *)data offset:(int)offset length:(int)nalLength
+- (void)updateAnnexBBufferForRange:(CMBlockBufferRef)frameBuffer dataBlock:(CMBlockBufferRef)dataBuffer offset:(int)offset length:(int)nalLength
{
OSStatus status;
- size_t oldOffset = CMBlockBufferGetDataLength(existingBuffer);
+ size_t oldOffset = CMBlockBufferGetDataLength(frameBuffer);
- // If we're at index 1 (first NALU in frame), enqueue this buffer to the memory block
- // so it can handle freeing it when the block buffer is destroyed
- if (offset == 1) {
- int dataLength = nalLength - NALU_START_PREFIX_SIZE;
-
- // Pass the real buffer pointer directly (no offset)
- // This will give it to the block buffer to free when it's released.
- // All further calls to CMBlockBufferAppendMemoryBlock will do so
- // at an offset and will not be asking the buffer to be freed.
- status = CMBlockBufferAppendMemoryBlock(existingBuffer, data,
- nalLength + 1, // Add 1 for the offset we decremented
- kCFAllocatorDefault,
- NULL, 0, nalLength + 1, 0);
- if (status != noErr) {
- Log(LOG_E, @"CMBlockBufferReplaceDataBytes failed: %d", (int)status);
- return;
- }
-
- // Write the length prefix to existing buffer
- const uint8_t lengthBytes[] = {(uint8_t)(dataLength >> 24), (uint8_t)(dataLength >> 16),
- (uint8_t)(dataLength >> 8), (uint8_t)dataLength};
- status = CMBlockBufferReplaceDataBytes(lengthBytes, existingBuffer,
- oldOffset, NAL_LENGTH_PREFIX_SIZE);
- if (status != noErr) {
- Log(LOG_E, @"CMBlockBufferReplaceDataBytes failed: %d", (int)status);
- return;
- }
- } else {
- // Append a 4 byte buffer to this block for the length prefix
- status = CMBlockBufferAppendMemoryBlock(existingBuffer, NULL,
- NAL_LENGTH_PREFIX_SIZE,
- kCFAllocatorDefault, NULL, 0,
- NAL_LENGTH_PREFIX_SIZE, 0);
- if (status != noErr) {
- Log(LOG_E, @"CMBlockBufferAppendMemoryBlock failed: %d", (int)status);
- return;
- }
-
- // Write the length prefix to the new buffer
- int dataLength = nalLength - NALU_START_PREFIX_SIZE;
- const uint8_t lengthBytes[] = {(uint8_t)(dataLength >> 24), (uint8_t)(dataLength >> 16),
- (uint8_t)(dataLength >> 8), (uint8_t)dataLength};
- status = CMBlockBufferReplaceDataBytes(lengthBytes, existingBuffer,
- oldOffset, NAL_LENGTH_PREFIX_SIZE);
- if (status != noErr) {
- Log(LOG_E, @"CMBlockBufferReplaceDataBytes failed: %d", (int)status);
- return;
- }
-
- // Attach the buffer by reference to the block buffer
- status = CMBlockBufferAppendMemoryBlock(existingBuffer, &data[offset+NALU_START_PREFIX_SIZE],
- dataLength,
- kCFAllocatorNull, // Don't deallocate data on free
- NULL, 0, dataLength, 0);
- if (status != noErr) {
- Log(LOG_E, @"CMBlockBufferReplaceDataBytes failed: %d", (int)status);
- return;
- }
+ // Append a 4 byte buffer to the frame block for the length prefix
+ status = CMBlockBufferAppendMemoryBlock(frameBuffer, NULL,
+ NAL_LENGTH_PREFIX_SIZE,
+ kCFAllocatorDefault, NULL, 0,
+ NAL_LENGTH_PREFIX_SIZE, 0);
+ if (status != noErr) {
+ Log(LOG_E, @"CMBlockBufferAppendMemoryBlock failed: %d", (int)status);
+ return;
+ }
+
+ // Write the length prefix to the new buffer
+ const int dataLength = nalLength - NALU_START_PREFIX_SIZE;
+ const uint8_t lengthBytes[] = {(uint8_t)(dataLength >> 24), (uint8_t)(dataLength >> 16),
+ (uint8_t)(dataLength >> 8), (uint8_t)dataLength};
+ status = CMBlockBufferReplaceDataBytes(lengthBytes, frameBuffer,
+ oldOffset, NAL_LENGTH_PREFIX_SIZE);
+ if (status != noErr) {
+ Log(LOG_E, @"CMBlockBufferReplaceDataBytes failed: %d", (int)status);
+ return;
+ }
+
+ // Attach the data buffer to the frame buffer by reference
+ status = CMBlockBufferAppendBufferReference(frameBuffer, dataBuffer, offset + NALU_START_PREFIX_SIZE, dataLength, 0);
+ if (status != noErr) {
+ Log(LOG_E, @"CMBlockBufferAppendBufferReference failed: %d", (int)status);
+ return;
}
}
-// This function must free data
-- (int)submitDecodeBuffer:(unsigned char *)data length:(int)length
-{
- unsigned char nalType = data[FRAME_START_PREFIX_SIZE];
- OSStatus status;
+- (NSData*)getAv1CodecConfigurationBox:(NSData*)frameData {
+ AVIOContext* ioctx = NULL;
+ int err;
- // Check for previous decoder errors before doing anything
- if (displayLayer.status == AVQueuedSampleBufferRenderingStatusFailed) {
- Log(LOG_E, @"Display layer rendering failed: %@", displayLayer.error);
-
- // Recreate the display layer
- [self reinitializeDisplayLayer];
-
- // Request an IDR frame to initialize the new decoder
- return DR_NEED_IDR;
+ err = avio_open_dyn_buf(&ioctx);
+ if (err < 0) {
+ Log(LOG_E, @"avio_open_dyn_buf() failed: %d", err);
+ return nil;
+ }
+
+ // Submit the IDR frame to write the av1C blob
+ err = ff_isom_write_av1c(ioctx, (uint8_t*)frameData.bytes, (int)frameData.length, 1);
+ if (err < 0) {
+ Log(LOG_E, @"ff_isom_write_av1c() failed: %d", err);
+ // Fall-through to close and free buffer
}
- if (![self isNalPictureData:nalType]) {
- if (IS_NAL_VPS(nalType)) {
- Log(LOG_I, @"Got VPS");
- vpsData = [NSData dataWithBytes:&data[FRAME_START_PREFIX_SIZE] length:length - FRAME_START_PREFIX_SIZE];
- waitingForVps = false;
+ // Close the dynbuf and get the underlying buffer back (which we must free)
+ uint8_t* av1cBuf = NULL;
+ int av1cBufLen = avio_close_dyn_buf(ioctx, &av1cBuf);
+
+ Log(LOG_I, @"av1C block is %d bytes", av1cBufLen);
+
+ // Only return data if ff_isom_write_av1c() was successful
+ NSData* data = nil;
+ if (err >= 0 && av1cBufLen > 0) {
+ data = [NSData dataWithBytes:av1cBuf length:av1cBufLen];
+ }
+ else {
+ data = nil;
+ }
+
+ av_free(av1cBuf);
+ return data;
+}
+
+// Much of this logic comes from Chrome
+- (CMVideoFormatDescriptionRef)createAV1FormatDescriptionForIDRFrame:(NSData*)frameData {
+ NSMutableDictionary* extensions = [[NSMutableDictionary alloc] init];
+
+ CodedBitstreamContext* cbsCtx = NULL;
+ int err = ff_cbs_init(&cbsCtx, AV_CODEC_ID_AV1, NULL);
+ if (err < 0) {
+ Log(LOG_E, @"ff_cbs_init() failed: %d", err);
+ return nil;
+ }
+
+ AVPacket avPacket = {};
+ avPacket.data = (uint8_t*)frameData.bytes;
+ avPacket.size = (int)frameData.length;
+
+ // Read the sequence header OBU
+ CodedBitstreamFragment cbsFrag = {};
+ err = ff_cbs_read_packet(cbsCtx, &cbsFrag, &avPacket);
+ if (err < 0) {
+ Log(LOG_E, @"ff_cbs_read_packet() failed: %d", err);
+ ff_cbs_close(&cbsCtx);
+ return nil;
+ }
+
+#define SET_CFSTR_EXTENSION(key, value) extensions[(__bridge NSString*)key] = (__bridge NSString*)(value)
+#define SET_EXTENSION(key, value) extensions[(__bridge NSString*)key] = (value)
+
+ SET_EXTENSION(kCMFormatDescriptionExtension_FormatName, @"av01");
+
+ // We use the value for YUV without alpha, same as Chrome
+ // https://developer.apple.com/library/archive/qa/qa1183/_index.html
+ SET_EXTENSION(kCMFormatDescriptionExtension_Depth, @24);
+
+ CodedBitstreamAV1Context* bitstreamCtx = (CodedBitstreamAV1Context*)cbsCtx->priv_data;
+ AV1RawSequenceHeader* seqHeader = bitstreamCtx->sequence_header;
+ if (seqHeader == NULL) {
+ Log(LOG_E, @"AV1 sequence header not found in IDR frame!");
+ ff_cbs_fragment_free(&cbsFrag);
+ ff_cbs_close(&cbsCtx);
+ return nil;
+ }
+
+ switch (seqHeader->color_config.color_primaries) {
+ case 1: // CP_BT_709
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_ColorPrimaries,
+ kCMFormatDescriptionColorPrimaries_ITU_R_709_2);
+ break;
- // We got a new VPS so wait for a new SPS to match it
- waitingForSps = true;
- }
- else if (IS_NAL_SPS(nalType)) {
- Log(LOG_I, @"Got SPS");
- spsData = [NSData dataWithBytes:&data[FRAME_START_PREFIX_SIZE] length:length - FRAME_START_PREFIX_SIZE];
- waitingForSps = false;
+ case 6: // CP_BT_601
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_ColorPrimaries,
+ kCMFormatDescriptionColorPrimaries_SMPTE_C);
+ break;
- // We got a new SPS so wait for a new PPS to match it
- waitingForPps = true;
- } else if (IS_NAL_PPS(nalType)) {
- Log(LOG_I, @"Got PPS");
- ppsData = [NSData dataWithBytes:&data[FRAME_START_PREFIX_SIZE] length:length - FRAME_START_PREFIX_SIZE];
- waitingForPps = false;
+ case 9: // CP_BT_2020
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_ColorPrimaries,
+ kCMFormatDescriptionColorPrimaries_ITU_R_2020);
+ break;
+
+ default:
+ Log(LOG_W, @"Unsupported color_primaries value: %d", seqHeader->color_config.color_primaries);
+ break;
+ }
+
+ switch (seqHeader->color_config.transfer_characteristics) {
+ case 1: // TC_BT_709
+ case 6: // TC_BT_601
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_TransferFunction,
+ kCMFormatDescriptionTransferFunction_ITU_R_709_2);
+ break;
+
+ case 7: // TC_SMPTE_240
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_TransferFunction,
+ kCMFormatDescriptionTransferFunction_SMPTE_240M_1995);
+ break;
+
+ case 8: // TC_LINEAR
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_TransferFunction,
+ kCMFormatDescriptionTransferFunction_Linear);
+ break;
+
+ case 14: // TC_BT_2020_10_BIT
+ case 15: // TC_BT_2020_12_BIT
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_TransferFunction,
+ kCMFormatDescriptionTransferFunction_ITU_R_2020);
+ break;
+
+ case 16: // TC_SMPTE_2084
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_TransferFunction,
+ kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ);
+ break;
+
+ case 17: // TC_HLG
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_TransferFunction,
+ kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG);
+ break;
+
+ default:
+ Log(LOG_W, @"Unsupported transfer_characteristics value: %d", seqHeader->color_config.transfer_characteristics);
+ break;
+ }
+
+ switch (seqHeader->color_config.matrix_coefficients) {
+ case 1: // MC_BT_709
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_YCbCrMatrix,
+ kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2);
+ break;
+
+ case 6: // MC_BT_601
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_YCbCrMatrix,
+ kCMFormatDescriptionYCbCrMatrix_ITU_R_601_4);
+ break;
+
+ case 7: // MC_SMPTE_240
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_YCbCrMatrix,
+ kCMFormatDescriptionYCbCrMatrix_SMPTE_240M_1995);
+ break;
+
+ case 9: // MC_BT_2020_NCL
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_YCbCrMatrix,
+ kCMFormatDescriptionYCbCrMatrix_ITU_R_2020);
+ break;
+
+ default:
+ Log(LOG_W, @"Unsupported matrix_coefficients value: %d", seqHeader->color_config.matrix_coefficients);
+ break;
+ }
+
+ SET_EXTENSION(kCMFormatDescriptionExtension_FullRangeVideo, @(seqHeader->color_config.color_range == 1));
+
+ // Progressive content
+ SET_EXTENSION(kCMFormatDescriptionExtension_FieldCount, @(1));
+
+ switch (seqHeader->color_config.chroma_sample_position) {
+ case 1: // CSP_VERTICAL
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_ChromaLocationTopField,
+ kCMFormatDescriptionChromaLocation_Left);
+ break;
+
+ case 2: // CSP_COLOCATED
+ SET_CFSTR_EXTENSION(kCMFormatDescriptionExtension_ChromaLocationTopField,
+ kCMFormatDescriptionChromaLocation_TopLeft);
+ break;
+
+ default:
+ Log(LOG_W, @"Unsupported chroma_sample_position value: %d", seqHeader->color_config.chroma_sample_position);
+ break;
+ }
+
+ if (contentLightLevelInfo) {
+ SET_EXTENSION(kCMFormatDescriptionExtension_ContentLightLevelInfo, contentLightLevelInfo);
+ }
+
+ if (masteringDisplayColorVolume) {
+ SET_EXTENSION(kCMFormatDescriptionExtension_MasteringDisplayColorVolume, masteringDisplayColorVolume);
+ }
+
+ // Referenced the VP9 code in Chrome that performs a similar function
+ // https://source.chromium.org/chromium/chromium/src/+/main:media/gpu/mac/vt_config_util.mm;drc=977dc02c431b4979e34c7792bc3d646f649dacb4;l=155
+ extensions[(__bridge NSString*)kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms] =
+ @{
+ @"av1C" : [self getAv1CodecConfigurationBox:frameData],
+ };
+ extensions[@"BitsPerComponent"] = @(bitstreamCtx->bit_depth);
+
+#undef SET_EXTENSION
+#undef SET_CFSTR_EXTENSION
+
+ // AV1 doesn't have a special format description function like H.264 and HEVC have, so we just use the generic one
+ CMVideoFormatDescriptionRef formatDesc = NULL;
+ OSStatus status = CMVideoFormatDescriptionCreate(kCFAllocatorDefault, kCMVideoCodecType_AV1,
+ bitstreamCtx->frame_width, bitstreamCtx->frame_height,
+ (__bridge CFDictionaryRef)extensions,
+ &formatDesc);
+ if (status != noErr) {
+ Log(LOG_E, @"Failed to create AV1 format description: %d", (int)status);
+ formatDesc = NULL;
+ }
+
+ ff_cbs_fragment_free(&cbsFrag);
+ ff_cbs_close(&cbsCtx);
+ return formatDesc;
+}
+
+// This function must free data for bufferType == BUFFER_TYPE_PICDATA
+- (int)submitDecodeBuffer:(unsigned char *)data length:(int)length bufferType:(int)bufferType decodeUnit:(PDECODE_UNIT)du
+{
+ OSStatus status;
+
+ // Construct a new format description object each time we receive an IDR frame
+ if (du->frameType == FRAME_TYPE_IDR) {
+ if (bufferType != BUFFER_TYPE_PICDATA) {
+ if (bufferType == BUFFER_TYPE_VPS || bufferType == BUFFER_TYPE_SPS || bufferType == BUFFER_TYPE_PPS) {
+ // Add new parameter set into the parameter set array
+ int startLen = data[2] == 0x01 ? 3 : 4;
+ [parameterSetBuffers addObject:[NSData dataWithBytes:&data[startLen] length:length - startLen]];
+ }
+
+ // Data is NOT to be freed here. It's a direct usage of the caller's buffer.
+
+ // No frame data to submit for these NALUs
+ return DR_OK;
}
- // See if we've got all the parameter sets we need for our video format
- if ([self readyForPictureData]) {
- if (videoFormat == VIDEO_FORMAT_H264) {
- const uint8_t* const parameterSetPointers[] = { [spsData bytes], [ppsData bytes] };
- const size_t parameterSetSizes[] = { [spsData length], [ppsData length] };
-
- Log(LOG_I, @"Constructing new H264 format description");
- status = CMVideoFormatDescriptionCreateFromH264ParameterSets(kCFAllocatorDefault,
- 2, /* count of parameter sets */
- parameterSetPointers,
- parameterSetSizes,
- NAL_LENGTH_PREFIX_SIZE,
- &formatDesc);
- if (status != noErr) {
- Log(LOG_E, @"Failed to create H264 format description: %d", (int)status);
- formatDesc = NULL;
- }
- }
- else {
- const uint8_t* const parameterSetPointers[] = { [vpsData bytes], [spsData bytes], [ppsData bytes] };
- const size_t parameterSetSizes[] = { [vpsData length], [spsData length], [ppsData length] };
-
- Log(LOG_I, @"Constructing new HEVC format description");
- if (@available(iOS 11.0, *)) {
- status = CMVideoFormatDescriptionCreateFromHEVCParameterSets(kCFAllocatorDefault,
- 3, /* count of parameter sets */
- parameterSetPointers,
- parameterSetSizes,
- NAL_LENGTH_PREFIX_SIZE,
- nil,
- &formatDesc);
- } else {
- // This means Moonlight-common-c decided to give us an HEVC stream
- // even though we said we couldn't support it. All we can do is abort().
- abort();
- }
-
- if (status != noErr) {
- Log(LOG_E, @"Failed to create HEVC format description: %d", (int)status);
- formatDesc = NULL;
- }
- }
+ // Create the new format description when we get the first picture data buffer of an IDR frame.
+ // This is the only way we know that there is no more CSD for this frame.
+ //
+ // NB: This logic depends on the fact that we submit all picture data in one buffer!
+
+ // Free the old format description
+ if (formatDesc != NULL) {
+ CFRelease(formatDesc);
+ formatDesc = NULL;
}
- // Free the data buffer
- free(data);
-
- // No frame data to submit for these NALUs
- return DR_OK;
+ if (videoFormat & VIDEO_FORMAT_MASK_H264) {
+ // Construct parameter set arrays for the format description
+ size_t parameterSetCount = [parameterSetBuffers count];
+ const uint8_t* parameterSetPointers[parameterSetCount];
+ size_t parameterSetSizes[parameterSetCount];
+ for (int i = 0; i < parameterSetCount; i++) {
+ NSData* parameterSet = parameterSetBuffers[i];
+ parameterSetPointers[i] = parameterSet.bytes;
+ parameterSetSizes[i] = parameterSet.length;
+ }
+
+ Log(LOG_I, @"Constructing new H264 format description");
+ status = CMVideoFormatDescriptionCreateFromH264ParameterSets(kCFAllocatorDefault,
+ parameterSetCount,
+ parameterSetPointers,
+ parameterSetSizes,
+ NAL_LENGTH_PREFIX_SIZE,
+ &formatDesc);
+ if (status != noErr) {
+ Log(LOG_E, @"Failed to create H264 format description: %d", (int)status);
+ formatDesc = NULL;
+ }
+
+ // Free parameter set buffers after submission
+ [parameterSetBuffers removeAllObjects];
+ }
+ else if (videoFormat & VIDEO_FORMAT_MASK_H265) {
+ // Construct parameter set arrays for the format description
+ size_t parameterSetCount = [parameterSetBuffers count];
+ const uint8_t* parameterSetPointers[parameterSetCount];
+ size_t parameterSetSizes[parameterSetCount];
+ for (int i = 0; i < parameterSetCount; i++) {
+ NSData* parameterSet = parameterSetBuffers[i];
+ parameterSetPointers[i] = parameterSet.bytes;
+ parameterSetSizes[i] = parameterSet.length;
+ }
+
+ Log(LOG_I, @"Constructing new HEVC format description");
+
+ NSMutableDictionary* videoFormatParams = [[NSMutableDictionary alloc] init];
+
+ if (contentLightLevelInfo) {
+ [videoFormatParams setObject:contentLightLevelInfo forKey:(__bridge NSString*)kCMFormatDescriptionExtension_ContentLightLevelInfo];
+ }
+
+ if (masteringDisplayColorVolume) {
+ [videoFormatParams setObject:masteringDisplayColorVolume forKey:(__bridge NSString*)kCMFormatDescriptionExtension_MasteringDisplayColorVolume];
+ }
+
+ status = CMVideoFormatDescriptionCreateFromHEVCParameterSets(kCFAllocatorDefault,
+ parameterSetCount,
+ parameterSetPointers,
+ parameterSetSizes,
+ NAL_LENGTH_PREFIX_SIZE,
+ (__bridge CFDictionaryRef)videoFormatParams,
+ &formatDesc);
+
+ if (status != noErr) {
+ Log(LOG_E, @"Failed to create HEVC format description: %d", (int)status);
+ formatDesc = NULL;
+ }
+
+ // Free parameter set buffers after submission
+ [parameterSetBuffers removeAllObjects];
+ }
+ else if (videoFormat & VIDEO_FORMAT_MASK_AV1) {
+ NSData* fullFrameData = [NSData dataWithBytesNoCopy:data length:length freeWhenDone:NO];
+
+ Log(LOG_I, @"Constructing new AV1 format description");
+ formatDesc = [self createAV1FormatDescriptionForIDRFrame:fullFrameData];
+ }
+ else {
+ // Unsupported codec!
+ abort();
+ }
}
if (formatDesc == NULL) {
// Can't decode if we haven't gotten our parameter sets yet
free(data);
- return DR_OK;
+ return DR_NEED_IDR;
}
- // Now we're decoding actual frame data here
- CMBlockBufferRef blockBuffer;
-
- status = CMBlockBufferCreateEmpty(NULL, 0, 0, &blockBuffer);
- if (status != noErr) {
- Log(LOG_E, @"CMBlockBufferCreateEmpty failed: %d", (int)status);
+ // Check for previous decoder errors before doing anything
+ if (displayLayer.status == AVQueuedSampleBufferRenderingStatusFailed) {
+ Log(LOG_E, @"Display layer rendering failed: %@", displayLayer.error);
+
+ // Recreate the display layer. We are already on the main thread,
+ // so this is safe to do right here.
+ [self reinitializeDisplayLayer];
+
+ // Request an IDR frame to initialize the new decoder
free(data);
return DR_NEED_IDR;
}
- int lastOffset = -1;
- for (int i = 0; i < length - FRAME_START_PREFIX_SIZE; i++) {
- // Search for a NALU
- if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 1) {
- // It's the start of a new NALU
- if (lastOffset != -1) {
- // We've seen a start before this so enqueue that NALU
- [self updateBufferForRange:blockBuffer data:data offset:lastOffset length:i - lastOffset];
- }
-
- lastOffset = i;
- }
- }
+ // Now we're decoding actual frame data here
+ CMBlockBufferRef frameBlockBuffer;
+ CMBlockBufferRef dataBlockBuffer;
- if (lastOffset != -1) {
- // Enqueue the remaining data
- [self updateBufferForRange:blockBuffer data:data offset:lastOffset length:length - lastOffset];
+ status = CMBlockBufferCreateWithMemoryBlock(NULL, data, length, kCFAllocatorDefault, NULL, 0, length, 0, &dataBlockBuffer);
+ if (status != noErr) {
+ Log(LOG_E, @"CMBlockBufferCreateWithMemoryBlock failed: %d", (int)status);
+ free(data);
+ return DR_NEED_IDR;
}
// From now on, CMBlockBuffer owns the data pointer and will free it when it's dereferenced
- CMSampleBufferRef sampleBuffer;
-
- status = CMSampleBufferCreate(kCFAllocatorDefault,
- blockBuffer,
- true, NULL,
- NULL, formatDesc, 1, 0,
- NULL, 0, NULL,
- &sampleBuffer);
+ status = CMBlockBufferCreateEmpty(NULL, 0, 0, &frameBlockBuffer);
if (status != noErr) {
- Log(LOG_E, @"CMSampleBufferCreate failed: %d", (int)status);
- CFRelease(blockBuffer);
+ Log(LOG_E, @"CMBlockBufferCreateEmpty failed: %d", (int)status);
+ CFRelease(dataBlockBuffer);
return DR_NEED_IDR;
}
- CFArrayRef attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, YES);
- CFMutableDictionaryRef dict = (CFMutableDictionaryRef)CFArrayGetValueAtIndex(attachments, 0);
+ // H.264 and HEVC formats require NAL prefix fixups from Annex B to length-delimited
+ if (videoFormat & (VIDEO_FORMAT_MASK_H264 | VIDEO_FORMAT_MASK_H265)) {
+ int lastOffset = -1;
+ for (int i = 0; i < length - NALU_START_PREFIX_SIZE; i++) {
+ // Search for a NALU
+ if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 1) {
+ // It's the start of a new NALU
+ if (lastOffset != -1) {
+ // We've seen a start before this so enqueue that NALU
+ [self updateAnnexBBufferForRange:frameBlockBuffer dataBlock:dataBlockBuffer offset:lastOffset length:i - lastOffset];
+ }
+
+ lastOffset = i;
+ }
+ }
+
+ if (lastOffset != -1) {
+ // Enqueue the remaining data
+ [self updateAnnexBBufferForRange:frameBlockBuffer dataBlock:dataBlockBuffer offset:lastOffset length:length - lastOffset];
+ }
+ }
+ else {
+ // For formats that require no length-changing fixups, just append a reference to the raw data block
+ status = CMBlockBufferAppendBufferReference(frameBlockBuffer, dataBlockBuffer, 0, length, 0);
+ if (status != noErr) {
+ Log(LOG_E, @"CMBlockBufferAppendBufferReference failed: %d", (int)status);
+ return DR_NEED_IDR;
+ }
+ }
+
+ CMSampleBufferRef sampleBuffer;
- CFDictionarySetValue(dict, kCMSampleAttachmentKey_DisplayImmediately, kCFBooleanTrue);
- CFDictionarySetValue(dict, kCMSampleAttachmentKey_IsDependedOnByOthers, kCFBooleanTrue);
+ CMSampleTimingInfo sampleTiming = {kCMTimeInvalid, CMTimeMake(du->presentationTimeUs, 1000000), kCMTimeInvalid};
- if (![self isNalReferencePicture:nalType]) {
- // P-frame
- CFDictionarySetValue(dict, kCMSampleAttachmentKey_NotSync, kCFBooleanTrue);
- CFDictionarySetValue(dict, kCMSampleAttachmentKey_DependsOnOthers, kCFBooleanTrue);
- } else {
- // I-frame
- CFDictionarySetValue(dict, kCMSampleAttachmentKey_NotSync, kCFBooleanFalse);
- CFDictionarySetValue(dict, kCMSampleAttachmentKey_DependsOnOthers, kCFBooleanFalse);
+ status = CMSampleBufferCreateReady(kCFAllocatorDefault,
+ frameBlockBuffer,
+ formatDesc, 1, 1,
+ &sampleTiming, 0, NULL,
+ &sampleBuffer);
+ if (status != noErr) {
+ Log(LOG_E, @"CMSampleBufferCreate failed: %d", (int)status);
+ CFRelease(dataBlockBuffer);
+ CFRelease(frameBlockBuffer);
+ return DR_NEED_IDR;
+ }
+
+ // Enqueue the next frame
+ [self->displayLayer enqueueSampleBuffer:sampleBuffer];
+
+ if (du->frameType == FRAME_TYPE_IDR) {
+ // Ensure the layer is visible now
+ self->displayLayer.hidden = NO;
+
+ // Tell our parent VC to hide the progress indicator
+ [self->_callbacks videoContentShown];
}
- [displayLayer enqueueSampleBuffer:sampleBuffer];
-
// Dereference the buffers
- CFRelease(blockBuffer);
+ CFRelease(dataBlockBuffer);
+ CFRelease(frameBlockBuffer);
CFRelease(sampleBuffer);
return DR_OK;
}
+- (void)setHdrMode:(BOOL)enabled {
+ SS_HDR_METADATA hdrMetadata;
+
+ BOOL hasMetadata = enabled && LiGetHdrMetadata(&hdrMetadata);
+ BOOL metadataChanged = NO;
+
+ if (hasMetadata && hdrMetadata.displayPrimaries[0].x != 0 && hdrMetadata.maxDisplayLuminance != 0) {
+ // This data is all in big-endian
+ struct {
+ vector_ushort2 primaries[3];
+ vector_ushort2 white_point;
+ uint32_t luminance_max;
+ uint32_t luminance_min;
+ } __attribute__((packed, aligned(4))) mdcv;
+
+ // mdcv is in GBR order while SS_HDR_METADATA is in RGB order
+ mdcv.primaries[0].x = __builtin_bswap16(hdrMetadata.displayPrimaries[1].x);
+ mdcv.primaries[0].y = __builtin_bswap16(hdrMetadata.displayPrimaries[1].y);
+ mdcv.primaries[1].x = __builtin_bswap16(hdrMetadata.displayPrimaries[2].x);
+ mdcv.primaries[1].y = __builtin_bswap16(hdrMetadata.displayPrimaries[2].y);
+ mdcv.primaries[2].x = __builtin_bswap16(hdrMetadata.displayPrimaries[0].x);
+ mdcv.primaries[2].y = __builtin_bswap16(hdrMetadata.displayPrimaries[0].y);
+
+ mdcv.white_point.x = __builtin_bswap16(hdrMetadata.whitePoint.x);
+ mdcv.white_point.y = __builtin_bswap16(hdrMetadata.whitePoint.y);
+
+ // These luminance values are in 10000ths of a nit
+ mdcv.luminance_max = __builtin_bswap32((uint32_t)hdrMetadata.maxDisplayLuminance * 10000);
+ mdcv.luminance_min = __builtin_bswap32(hdrMetadata.minDisplayLuminance);
+
+ NSData* newMdcv = [NSData dataWithBytes:&mdcv length:sizeof(mdcv)];
+ if (masteringDisplayColorVolume == nil || ![newMdcv isEqualToData:masteringDisplayColorVolume]) {
+ masteringDisplayColorVolume = newMdcv;
+ metadataChanged = YES;
+ }
+ }
+ else if (masteringDisplayColorVolume != nil) {
+ masteringDisplayColorVolume = nil;
+ metadataChanged = YES;
+ }
+
+ if (hasMetadata && hdrMetadata.maxContentLightLevel != 0 && hdrMetadata.maxFrameAverageLightLevel != 0) {
+ // This data is all in big-endian
+ struct {
+ uint16_t max_content_light_level;
+ uint16_t max_frame_average_light_level;
+ } __attribute__((packed, aligned(2))) cll;
+
+ cll.max_content_light_level = __builtin_bswap16(hdrMetadata.maxContentLightLevel);
+ cll.max_frame_average_light_level = __builtin_bswap16(hdrMetadata.maxFrameAverageLightLevel);
+
+ NSData* newCll = [NSData dataWithBytes:&cll length:sizeof(cll)];
+ if (contentLightLevelInfo == nil || ![newCll isEqualToData:contentLightLevelInfo]) {
+ contentLightLevelInfo = newCll;
+ metadataChanged = YES;
+ }
+ }
+ else if (contentLightLevelInfo != nil) {
+ contentLightLevelInfo = nil;
+ metadataChanged = YES;
+ }
+
+ // If the metadata changed, request an IDR frame to re-create the CMVideoFormatDescription
+ if (metadataChanged) {
+ LiRequestIdrFrame();
+ }
+}
+
@end
diff --git a/Limelight/UIAppView.h b/Limelight/UIAppView.h
index 31108c3..9f2504c 100644
--- a/Limelight/UIAppView.h
+++ b/Limelight/UIAppView.h
@@ -11,11 +11,16 @@
@protocol AppCallback
-- (void) appClicked:(TemporaryApp*) app;
+- (void) appClicked:(TemporaryApp*)app view:(UIView*)view;
+- (void) appLongClicked:(TemporaryApp*)app view:(UIView*)view;
@end
-@interface UIAppView : UIView
+#if !TARGET_OS_TV
+@interface UIAppView : UIButton
+#else
+@interface UIAppView : UIButton
+#endif
- (id) initWithApp:(TemporaryApp*)app cache:(NSCache*)cache andCallback:(id)callback;
- (void) updateAppImage;
diff --git a/Limelight/UIAppView.m b/Limelight/UIAppView.m
index 9a006ef..6ad9cf4 100644
--- a/Limelight/UIAppView.m
+++ b/Limelight/UIAppView.m
@@ -7,12 +7,15 @@
//
#import "UIAppView.h"
+#import "AppAssetManager.h"
+
+static const float REFRESH_CYCLE = 1.0f;
@implementation UIAppView {
TemporaryApp* _app;
- UIButton* _appButton;
UILabel* _appLabel;
UIImageView* _appOverlay;
+ UIImageView* _appImage;
NSCache* _artCache;
id _callback;
}
@@ -30,35 +33,121 @@ static UIImage* noImage;
if (noImage == nil) {
noImage = [UIImage imageNamed:@"NoAppImage"];
}
+
+#if TARGET_OS_TV
+ self.frame = CGRectMake(0, 0, 200, 265);
+#else
+ self.frame = CGRectMake(0, 0, 150, 200);
+#endif
- _appButton = [UIButton buttonWithType:UIButtonTypeCustom];
- [_appButton setBackgroundImage:noImage forState:UIControlStateNormal];
- [_appButton setContentEdgeInsets:UIEdgeInsetsMake(0, 4, 0, 4)];
- [_appButton sizeToFit];
- [_appButton addTarget:self action:@selector(appClicked) forControlEvents:UIControlEventTouchUpInside];
+ [self setAlpha:app.hidden ? 0.4 : 1.0];
- [self addSubview:_appButton];
- [self sizeToFit];
+ _appImage = [[UIImageView alloc] initWithFrame:self.frame];
+ [_appImage setImage:noImage];
+ [self addSubview:_appImage];
- _appButton.frame = CGRectMake(0, 0, noImage.size.width, noImage.size.height);
- self.frame = CGRectMake(0, 0, noImage.size.width, noImage.size.height);
+ // Use UIContextMenuInteraction on iOS 13.0+ and a standard UILongPressGestureRecognizer
+ // for tvOS devices and iOS prior to 13.0.
+#if !TARGET_OS_TV
+ if (@available(iOS 13.0, *)) {
+ UIContextMenuInteraction* rightClickInteraction = [[UIContextMenuInteraction alloc] initWithDelegate:self];
+ [self addInteraction:rightClickInteraction];
+ }
+ else
+#endif
+ {
+ UILongPressGestureRecognizer* longPressRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(appLongClicked:)];
+ [self addGestureRecognizer:longPressRecognizer];
+ }
+ [self addTarget:self action:@selector(appClicked:) forControlEvents:UIControlEventPrimaryActionTriggered];
+
+ [self addTarget:self action:@selector(buttonSelected:) forControlEvents:UIControlEventTouchDown];
+ [self addTarget:self action:@selector(buttonDeselected:) forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchCancel | UIControlEventTouchDragExit];
+
+#if TARGET_OS_TV
+ _appImage.adjustsImageWhenAncestorFocused = YES;
+#else
// Rasterizing the cell layer increases rendering performance by quite a bit
+ // but we want it unrasterized for tvOS where it must be scaled.
self.layer.shouldRasterize = YES;
self.layer.rasterizationScale = [UIScreen mainScreen].scale;
+ if (@available(iOS 13.4.1, *)) {
+ // Allow the button style to change when moused over
+ self.pointerInteractionEnabled = YES;
+ }
+#endif
+
+ [self updateAppImage];
+
return self;
}
-- (void) appClicked {
- [_callback appClicked:_app];
+- (void)didMoveToSuperview {
+ // Start our update loop when we are added to our cell
+ if (self.superview != nil) {
+ [self updateLoop];
+ }
}
+- (void) appClicked:(UIView *)view {
+ [_callback appClicked:_app view:view];
+}
+
+- (void) appLongClicked:(UILongPressGestureRecognizer*)gesture {
+ if (gesture.state == UIGestureRecognizerStateBegan) {
+ [_callback appLongClicked:_app view:self];
+ }
+}
+
+#if !TARGET_OS_TV
+- (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction
+ configurationForMenuAtLocation:(CGPoint)location {
+ // We don't want to trigger the primary action at this point, so cancel
+ // tracking touch on this view now. This will also have the (intended)
+ // effect of removing the touch highlight on this view.
+ [self cancelTrackingWithEvent:nil];
+
+ [_callback appLongClicked:_app view:self];
+ return nil;
+}
+#endif
+
- (void) updateAppImage {
if (_appOverlay != nil) {
[_appOverlay removeFromSuperview];
_appOverlay = nil;
}
+ if (_appLabel != nil) {
+ [_appLabel removeFromSuperview];
+ _appLabel = nil;
+ }
+
+ BOOL noAppImage = false;
+
+ // First check the memory cache
+ UIImage* appImage = [_artCache objectForKey:_app];
+ if (appImage == nil) {
+ // Next try to load from the on disk cache
+ appImage = [UIImage imageWithContentsOfFile:[AppAssetManager boxArtPathForApp:_app]];
+ if (appImage != nil) {
+ [_artCache setObject:appImage forKey:_app];
+ }
+ }
+
+ if (appImage != nil) {
+ // This size of image might be blank image received from GameStream.
+ // TODO: Improve no-app image detection
+ if (!(appImage.size.width == 130.f && appImage.size.height == 180.f) && // GFE 2.0
+ !(appImage.size.width == 628.f && appImage.size.height == 888.f)) { // GFE 3.0
+ [_appImage setImage:appImage];
+ } else {
+ noAppImage = true;
+ }
+ } else {
+ noAppImage = true;
+ }
if ([_app.id isEqualToString:_app.host.currentGame]) {
// Only create the app overlay if needed
@@ -66,57 +155,83 @@ static UIImage* noImage;
_appOverlay.layer.shadowColor = [UIColor blackColor].CGColor;
_appOverlay.layer.shadowOffset = CGSizeMake(0, 0);
_appOverlay.layer.shadowOpacity = 1;
- _appOverlay.layer.shadowRadius = 2.0;
-
- [self addSubview:_appOverlay];
-
- _appOverlay.frame = CGRectMake(0, 0, noImage.size.width / 2.f, noImage.size.height / 4.f);
- [_appOverlay setCenter:CGPointMake(self.frame.size.width/2, self.frame.size.height/6)];
- }
-
- // TODO: Improve no-app image detection
- BOOL noAppImage = false;
-
- if (_app.image != nil) {
- // Load the decoded image from the cache
- UIImage* appImage = [_artCache objectForKey:_app];
- if (appImage == nil) {
- // Not cached; we have to decode this now
- appImage = [UIImage imageWithData:_app.image];
- [_artCache setObject:appImage forKey:_app];
- }
-
- // This size of image might be blank image received from GameStream.
- if (!(appImage.size.width == 130.f && appImage.size.height == 180.f) && // GFE 2.0
- !(appImage.size.width == 628.f && appImage.size.height == 888.f)) { // GFE 3.0
- _appButton.frame = CGRectMake(0, 0, appImage.size.width / 2, appImage.size.height / 2);
- self.frame = CGRectMake(0, 0, appImage.size.width / 2, appImage.size.height / 2);
- _appOverlay.frame = CGRectMake(0, 0, self.frame.size.width / 2.f, self.frame.size.height / 4.f);
- _appOverlay.layer.shadowRadius = 4.0;
- [_appOverlay setCenter:CGPointMake(self.frame.size.width/2, self.frame.size.height/6)];
- [_appButton setBackgroundImage:appImage forState:UIControlStateNormal];
- [self setNeedsDisplay];
- } else {
- noAppImage = true;
- }
- } else {
- noAppImage = true;
+ _appOverlay.layer.shadowRadius = 4.0;
+ _appOverlay.contentMode = UIViewContentModeScaleAspectFit;
}
if (noAppImage) {
_appLabel = [[UILabel alloc] init];
- CGFloat padding = 4.f;
- [_appLabel setFrame: CGRectMake(padding, padding, _appButton.frame.size.width - 2 * padding, _appButton.frame.size.height - 2 * padding)];
[_appLabel setTextColor:[UIColor whiteColor]];
- [_appLabel setFont:[UIFont fontWithName:@"Roboto-Regular" size:10.f]];
+ [_appLabel setText:_app.name];
+ [_appLabel setFont:[UIFont systemFontOfSize:24]];
[_appLabel setBaselineAdjustment:UIBaselineAdjustmentAlignCenters];
[_appLabel setTextAlignment:NSTextAlignmentCenter];
[_appLabel setLineBreakMode:NSLineBreakByWordWrapping];
[_appLabel setNumberOfLines:0];
- [_appLabel setText:_app.name];
- [_appButton addSubview:_appLabel];
}
+ [self positionSubviews];
+
+#if TARGET_OS_TV
+ [_appImage.overlayContentView addSubview:_appLabel];
+ [_appImage.overlayContentView addSubview:_appOverlay];
+#else
+ [self addSubview:_appLabel];
+ [self addSubview:_appOverlay];
+#endif
+}
+
+- (void) buttonSelected:(id)sender {
+ _appImage.layer.opacity = 0.5f;
+}
+- (void) buttonDeselected:(id)sender {
+ _appImage.layer.opacity = 1.0f;
+}
+
+- (void) positionSubviews {
+ CGFloat padding = 5.f;
+ CGSize frameSize = _appImage.frame.size;
+ CGPoint center = _appImage.center;
+
+ if (_appLabel != nil) {
+ if (_appOverlay != nil) {
+ _appOverlay.frame = CGRectMake(0, 0, frameSize.width / 3, frameSize.width / 3);
+ _appOverlay.center = CGPointMake(frameSize.width / 2, padding + _appOverlay.frame.size.height / 2);
+
+ [_appLabel setFrame:CGRectMake(padding, _appOverlay.frame.size.height + padding, frameSize.width - 2 * padding, frameSize.height - _appOverlay.frame.size.height - 2 * padding)];
+ }
+ else {
+ [_appLabel setFrame:CGRectMake(padding, padding, frameSize.width - 2 * padding, frameSize.height - 2 * padding)];
+ }
+ }
+ else if (_appOverlay != nil) {
+ _appOverlay.frame = CGRectMake(0, 0, frameSize.width / 2, frameSize.width / 2);
+ _appOverlay.center = center;
+ }
+}
+
+- (void) updateLoop {
+ // Stop immediately if the view has been detached
+ if (self.superview == nil) {
+ return;
+ }
+
+ // Update the app image if neccessary
+ if ((_appOverlay != nil && ![_app.id isEqualToString:_app.host.currentGame]) ||
+ (_appOverlay == nil && [_app.id isEqualToString:_app.host.currentGame])) {
+ [self updateAppImage];
+ }
+
+ // Show no shadow for hidden apps. Because we adjust the opacity of the
+ // cells for hidden apps, it makes them look bad when the shadow draws
+ // through the app tile.
+ self.superview.layer.shadowOpacity = _app.hidden ? 0.0f : 0.5f;
+
+ // Update opacity if neccessary
+ [self setAlpha:_app.hidden ? 0.4 : 1.0];
+
+ // Queue the next refresh cycle
+ [self performSelector:@selector(updateLoop) withObject:self afterDelay:REFRESH_CYCLE];
}
@end
diff --git a/Limelight/UIComputerView.h b/Limelight/UIComputerView.h
index 7bbf698..911c699 100644
--- a/Limelight/UIComputerView.h
+++ b/Limelight/UIComputerView.h
@@ -17,7 +17,11 @@
@end
-@interface UIComputerView : UIView
+#if !TARGET_OS_TV
+@interface UIComputerView : UIButton
+#else
+@interface UIComputerView : UIButton
+#endif
- (id) initWithComputer:(TemporaryHost*)host andCallback:(id)callback;
- (id) initForAddWithCallback:(id)callback;
diff --git a/Limelight/UIComputerView.m b/Limelight/UIComputerView.m
index 364610c..450ce3b 100644
--- a/Limelight/UIComputerView.m
+++ b/Limelight/UIComputerView.m
@@ -10,65 +10,107 @@
@implementation UIComputerView {
TemporaryHost* _host;
- UIButton* _hostButton;
+ UIImageView* _hostIcon;
UILabel* _hostLabel;
- UILabel* _hostStatus;
- UILabel* _hostPairState;
+ UIImageView* _hostOverlay;
+ UIActivityIndicatorView* _hostSpinner;
id _callback;
CGSize _labelSize;
}
static const float REFRESH_CYCLE = 2.0f;
+
+#if TARGET_OS_TV
+static const int ITEM_PADDING = 50;
+static const int LABEL_DY = 40;
+#else
+static const int ITEM_PADDING = 0;
static const int LABEL_DY = 20;
+#endif
- (id) init {
self = [super init];
- _hostButton = [UIButton buttonWithType:UIButtonTypeCustom];
- [_hostButton setContentEdgeInsets:UIEdgeInsetsMake(0, 4, 0, 4)];
- [_hostButton setBackgroundImage:[UIImage imageNamed:@"Computer"] forState:UIControlStateNormal];
- [_hostButton sizeToFit];
+
+#if TARGET_OS_TV
+ self.frame = CGRectMake(0, 0, 400, 400);
+#else
+ if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
+ self.frame = CGRectMake(0, 0, 200, 200);
+ } else {
+ self.frame = CGRectMake(0, 0, 100, 100);
+ }
+#endif
- _hostButton.layer.shadowColor = [[UIColor blackColor] CGColor];
- _hostButton.layer.shadowOffset = CGSizeMake(5,8);
- _hostButton.layer.shadowOpacity = 0.7;
+ _hostIcon = [[UIImageView alloc] initWithFrame:self.frame];
+ [_hostIcon setImage:[UIImage imageNamed:@"Computer"]];
+
+ self.layer.shadowColor = [[UIColor blackColor] CGColor];
+ self.layer.shadowOffset = CGSizeMake(5,8);
+ self.layer.shadowOpacity = 0.3;
+
+ [self addTarget:self action:@selector(hostButtonSelected:) forControlEvents:UIControlEventTouchDown];
+ [self addTarget:self action:@selector(hostButtonDeselected:) forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchCancel | UIControlEventTouchDragExit];
_hostLabel = [[UILabel alloc] init];
- _hostStatus = [[UILabel alloc] init];
- _hostPairState = [[UILabel alloc] init];
- [_hostLabel setFont:[UIFont fontWithName:@"Roboto-Regular" size:[UIFont systemFontSize]]];
- [_hostStatus setFont:[UIFont fontWithName:@"Roboto-Regular" size:[UIFont systemFontSize]]];
- [_hostPairState setFont:[UIFont fontWithName:@"Roboto-Regular" size:[UIFont systemFontSize]]];
+ _hostLabel.textColor = [UIColor whiteColor];
+
+ _hostOverlay = [[UIImageView alloc] initWithFrame:CGRectMake(self.frame.size.width / 3, _hostIcon.frame.size.height / 4, _hostIcon.frame.size.width / 3, self.frame.size.height / 3)];
+ _hostSpinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge];
+ [_hostSpinner setFrame:_hostOverlay.frame];
+ _hostSpinner.userInteractionEnabled = NO;
+ _hostSpinner.hidesWhenStopped = YES;
+
+ [self addSubview:_hostLabel];
+ [self addSubview:_hostIcon];
+
+#if TARGET_OS_TV
+ _hostIcon.clipsToBounds = NO;
+ _hostIcon.adjustsImageWhenAncestorFocused = YES;
+ _hostIcon.masksFocusEffectToContents = YES;
+
+ self.adjustsImageWhenHighlighted = NO;
+
+ _hostOverlay.masksFocusEffectToContents = YES;
+ _hostOverlay.adjustsImageWhenAncestorFocused = NO;
+
+ [_hostIcon.overlayContentView addSubview:_hostOverlay];
+ [_hostIcon.overlayContentView addSubview:_hostSpinner];
+#else
+ [self addSubview:_hostOverlay];
+ [self addSubview:_hostSpinner];
+
+ if (@available(iOS 13.4.1, *)) {
+ // Allow the button style to change when moused over
+ self.pointerInteractionEnabled = YES;
+ }
+#endif
+
return self;
}
+- (void) hostButtonSelected:(id)sender {
+ _hostIcon.layer.opacity = 0.5f;
+ _hostSpinner.layer.opacity = 0.5f;
+ _hostOverlay.layer.opacity = 0.5f;
+}
+- (void) hostButtonDeselected:(id)sender {
+ _hostIcon.layer.opacity = 1.0f;
+ _hostSpinner.layer.opacity = 1.0f;
+ _hostOverlay.layer.opacity = 1.0f;
+}
+
- (id) initForAddWithCallback:(id)callback {
self = [self init];
_callback = callback;
- [_hostButton setBackgroundImage:[UIImage imageNamed:@"Computer"] forState:UIControlStateNormal];
- [_hostButton setContentEdgeInsets:UIEdgeInsetsMake(0, 4, 0, 4)];
- [_hostButton addTarget:self action:@selector(addClicked) forControlEvents:UIControlEventTouchUpInside];
- [_hostButton sizeToFit];
+ [self addTarget:self action:@selector(addClicked) forControlEvents:UIControlEventPrimaryActionTriggered];
- [_hostLabel setText:@"Add Host"];
+ [_hostLabel setText:@"Add Host Manually"];
[_hostLabel sizeToFit];
- _hostLabel.textColor = [UIColor whiteColor];
- _hostLabel.center = CGPointMake(_hostButton.frame.origin.x + (_hostButton.frame.size.width / 2), _hostButton.frame.origin.y + _hostButton.frame.size.height + LABEL_DY);
- UIImageView* addIcon = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"AddComputerIcon"]];
- [addIcon sizeToFit];
- addIcon.center = CGPointMake(_hostButton.frame.origin.x + _hostButton.frame.size.width, _hostButton.frame.origin.y);
-
- // This is required to ensure this button is the same size as the others
- _hostPairState.text = @"None";
- _hostStatus.text = @"None";
- [_hostPairState sizeToFit];
- [_hostStatus sizeToFit];
+ [_hostOverlay setImage:[UIImage imageNamed:@"AddOverlayIcon"]];
[self updateBounds];
- [self addSubview:_hostButton];
- [self addSubview:_hostLabel];
- [self addSubview:addIcon];
-
+
return self;
}
@@ -77,99 +119,115 @@ static const int LABEL_DY = 20;
_host = host;
_callback = callback;
- UILongPressGestureRecognizer* longPressRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(hostLongClicked)];
- [_hostButton addGestureRecognizer:longPressRecognizer];
- [_hostButton addTarget:self action:@selector(hostClicked) forControlEvents:UIControlEventTouchUpInside];
+ // Use UIContextMenuInteraction on iOS 13.0+ and a standard UILongPressGestureRecognizer
+ // for tvOS devices and iOS prior to 13.0.
+#if !TARGET_OS_TV
+ if (@available(iOS 13.0, *)) {
+ UIContextMenuInteraction* rightClickInteraction = [[UIContextMenuInteraction alloc] initWithDelegate:self];
+ [self addInteraction:rightClickInteraction];
+ }
+ else
+#endif
+ {
+ UILongPressGestureRecognizer* longPressRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(hostLongClicked:)];
+ [self addGestureRecognizer:longPressRecognizer];
+ }
+
+ [self addTarget:self action:@selector(hostClicked) forControlEvents:UIControlEventPrimaryActionTriggered];
[self updateContentsForHost:host];
- [self updateBounds];
- [self addSubview:_hostButton];
- [self addSubview:_hostLabel];
- [self addSubview:_hostStatus];
- [self addSubview:_hostPairState];
- [self startUpdateLoop];
return self;
}
+- (void)didMoveToSuperview {
+ // Start our update loop when we are added to our cell
+ if (self.superview != nil && _host != nil) {
+ [self updateLoop];
+ }
+}
+
- (void) updateBounds {
float x = FLT_MAX;
float y = FLT_MAX;
float width = 0;
float height;
- x = MIN(x, _hostButton.frame.origin.x);
+ float iconX = _hostIcon.frame.origin.x + _hostIcon.frame.size.width / 2;
+ _hostLabel.center = CGPointMake(iconX, _hostIcon.frame.origin.y + _hostIcon.frame.size.height + LABEL_DY);
+
+ x = MIN(x, _hostIcon.frame.origin.x);
x = MIN(x, _hostLabel.frame.origin.x);
- x = MIN(x, _hostStatus.frame.origin.x);
- x = MIN(x, _hostPairState.frame.origin.x);
- y = MIN(y, _hostButton.frame.origin.y);
+ y = MIN(y, _hostIcon.frame.origin.y);
y = MIN(y, _hostLabel.frame.origin.y);
- y = MIN(y, _hostStatus.frame.origin.y);
- y = MIN(y, _hostPairState.frame.origin.y);
- width = MAX(width, _hostButton.frame.size.width);
+ width = MAX(width, _hostIcon.frame.size.width);
width = MAX(width, _hostLabel.frame.size.width);
- width = MAX(width, _hostStatus.frame.size.width);
- width = MAX(width, _hostPairState.frame.size.width);
- height = _hostButton.frame.size.height +
+ height = _hostIcon.frame.size.height +
_hostLabel.frame.size.height +
- _hostStatus.frame.size.height +
- _hostPairState.frame.size.height +
LABEL_DY / 2;
- self.bounds = CGRectMake(x, y, width, height);
- self.frame = CGRectMake(x, y, width, height);
+ self.bounds = CGRectMake(x - ITEM_PADDING, y - ITEM_PADDING, width + 2 * ITEM_PADDING, height + 2 * ITEM_PADDING);
}
- (void) updateContentsForHost:(TemporaryHost*)host {
_hostLabel.text = _host.name;
- _hostLabel.textColor = [UIColor whiteColor];
[_hostLabel sizeToFit];
- switch (host.pairState) {
- case PairStateUnknown:
- _hostPairState.text = @"Pair State Unknown";
- break;
- case PairStateUnpaired:
- _hostPairState.text = @"Not Paired";
- break;
- case PairStatePaired:
- _hostPairState.text = @"Paired";
- break;
- }
- _hostPairState.textColor = [UIColor whiteColor];
- [_hostPairState sizeToFit];
-
- if (host.online) {
- _hostStatus.text = @"Online";
- _hostStatus.textColor = [UIColor greenColor];
- } else {
- _hostStatus.text = @"Offline";
- _hostStatus.textColor = [UIColor grayColor];
- }
- [_hostStatus sizeToFit];
-
- float x = _hostButton.frame.origin.x + _hostButton.frame.size.width / 2;
- _hostLabel.center = CGPointMake(x, _hostButton.frame.origin.y + _hostButton.frame.size.height + LABEL_DY);
- _hostPairState.center = CGPointMake(x, _hostLabel.center.y + LABEL_DY);
- _hostStatus.center = CGPointMake(x, _hostPairState.center.y + LABEL_DY);
-}
+ if (host.state == StateOnline) {
+ [_hostSpinner stopAnimating];
-- (void) startUpdateLoop {
- [self performSelector:@selector(updateLoop) withObject:self afterDelay:REFRESH_CYCLE];
+ if (host.pairState == PairStateUnpaired) {
+ [_hostOverlay setImage:[UIImage imageNamed:@"LockedOverlayIcon"]];
+ }
+ else {
+ [_hostOverlay setImage:nil];
+ }
+ }
+ else if (host.state == StateOffline) {
+ [_hostSpinner stopAnimating];
+ [_hostOverlay setImage:[UIImage imageNamed:@"ErrorOverlayIcon"]];
+ }
+ else {
+ [_hostSpinner startAnimating];
+ }
+
+ [self updateBounds];
}
- (void) updateLoop {
+ // Stop immediately if the view has been detached
+ if (self.superview == nil) {
+ return;
+ }
+
[self updateContentsForHost:_host];
+
+ // Queue the next refresh cycle
[self performSelector:@selector(updateLoop) withObject:self afterDelay:REFRESH_CYCLE];
}
-- (void) hostLongClicked {
- [_callback hostLongClicked:_host view:self];
+- (void) hostLongClicked:(UILongPressGestureRecognizer*)gesture {
+ if (gesture.state == UIGestureRecognizerStateBegan) {
+ [_callback hostLongClicked:_host view:self];
+ }
}
+#if !TARGET_OS_TV
+- (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction
+ configurationForMenuAtLocation:(CGPoint)location {
+ // We don't want to trigger the primary action at this point, so cancel
+ // tracking touch on this view now. This will also have the (intended)
+ // effect of removing the touch highlight on this view.
+ [self cancelTrackingWithEvent:nil];
+
+ [_callback hostLongClicked:_host view:self];
+ return nil;
+}
+#endif
+
- (void) hostClicked {
[_callback hostClicked:_host view:self];
}
diff --git a/Limelight/Utility/Logger.m b/Limelight/Utility/Logger.m
index f676313..3d1d004 100644
--- a/Limelight/Utility/Logger.m
+++ b/Limelight/Utility/Logger.m
@@ -8,6 +8,8 @@
#import "Logger.h"
+static LogLevel LoggerLogLevel = LOG_I;
+
void LogTagv(LogLevel level, NSString* tag, NSString* fmt, va_list args);
void Log(LogLevel level, NSString* fmt, ...) {
@@ -27,6 +29,10 @@ void LogTag(LogLevel level, NSString* tag, NSString* fmt, ...) {
void LogTagv(LogLevel level, NSString* tag, NSString* fmt, va_list args) {
NSString* levelPrefix = @"";
+ if (level < LoggerLogLevel) {
+ return;
+ }
+
switch(level) {
case LOG_D:
levelPrefix = PRFX_DEBUG;
@@ -41,6 +47,8 @@ void LogTagv(LogLevel level, NSString* tag, NSString* fmt, va_list args) {
levelPrefix = PRFX_ERROR;
break;
default:
+ levelPrefix = @"";
+ assert(false);
break;
}
NSString* prefixedString;
diff --git a/Limelight/Utility/Utils.h b/Limelight/Utility/Utils.h
index 2ec0ab1..1616bf9 100644
--- a/Limelight/Utility/Utils.h
+++ b/Limelight/Utility/Utils.h
@@ -6,8 +6,6 @@
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
-#import
-
@interface Utils : NSObject
typedef NS_ENUM(int, PairState) {
@@ -16,12 +14,27 @@ typedef NS_ENUM(int, PairState) {
PairStatePaired
};
+typedef NS_ENUM(int, State) {
+ StateUnknown,
+ StateOffline,
+ StateOnline
+};
+
FOUNDATION_EXPORT NSString *const deviceName;
+ (NSData*) randomBytes:(NSInteger)length;
+ (NSString*) bytesToHex:(NSData*)data;
+ (NSData*) hexToBytes:(NSString*) hex;
-+ (int) resolveHost:(NSString*)host;
++ (void) addHelpOptionToDialog:(UIAlertController*)dialog;
++ (BOOL) isActiveNetworkVPN;
++ (BOOL) parseAddressPortString:(NSString*)addressPort address:(NSRange*)address port:(NSRange*)port;
++ (NSString*) addressPortStringToAddress:(NSString*)addressPort;
++ (unsigned short) addressPortStringToPort:(NSString*)addressPort;
++ (NSString*) addressAndPortToAddressPortString:(NSString*)address port:(unsigned short)port;
+
+#if !TARGET_OS_TV
++ (void) launchUrl:(NSString*)urlString;
+#endif
@end
@@ -29,4 +42,4 @@ FOUNDATION_EXPORT NSString *const deviceName;
- (NSString*) trim;
-@end
\ No newline at end of file
+@end
diff --git a/Limelight/Utility/Utils.m b/Limelight/Utility/Utils.m
index 3ce858c..fded11b 100644
--- a/Limelight/Utility/Utils.m
+++ b/Limelight/Utility/Utils.m
@@ -50,27 +50,100 @@ NSString *const deviceName = @"roth";
return hex;
}
-+ (int) resolveHost:(NSString*)host {
- struct hostent *hostent;
-
- if (inet_addr([host UTF8String]) != INADDR_NONE) {
- // Already an IP address
- int addr = inet_addr([host UTF8String]);
- Log(LOG_I, @"host address: %d", addr);
- return addr;
- } else {
- hostent = gethostbyname([host UTF8String]);
- if (hostent != NULL) {
- char* ipstr = inet_ntoa(*(struct in_addr*)hostent->h_addr_list[0]);
- Log(LOG_I, @"Resolved %@ -> %s", host, ipstr);
- int addr = inet_addr(ipstr);
- Log(LOG_I, @"host address: %d", addr);
- return addr;
- } else {
- Log(LOG_W, @"Failed to resolve host: %d", h_errno);
- return 0;
++ (BOOL)isActiveNetworkVPN {
+ NSDictionary *dict = CFBridgingRelease(CFNetworkCopySystemProxySettings());
+ NSArray *keys = [dict[@"__SCOPED__"] allKeys];
+ for (NSString *key in keys) {
+ if ([key containsString:@"tap"] ||
+ [key containsString:@"tun"] ||
+ [key containsString:@"ppp"] ||
+ [key containsString:@"ipsec"]) {
+ return YES;
}
}
+ return NO;
+}
+
+#if !TARGET_OS_TV
++ (void) launchUrl:(NSString*)urlString {
+ [[UIApplication sharedApplication] openURL:[NSURL URLWithString:urlString] options:@{} completionHandler:nil];
+}
+#endif
+
++ (void) addHelpOptionToDialog:(UIAlertController*)dialog {
+#if !TARGET_OS_TV
+ // tvOS doesn't have a browser
+ [dialog addAction:[UIAlertAction actionWithTitle:@"Help" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
+ [Utils launchUrl:@"https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting"];
+ }]];
+#endif
+}
+
++ (BOOL) parseAddressPortString:(NSString*)addressPort address:(NSRange*)address port:(NSRange*)port {
+ if (![addressPort containsString:@":"]) {
+ // If there's no port or IPv6 separator, the whole thing is an address
+ *address = NSMakeRange(0, [addressPort length]);
+ *port = NSMakeRange(NSNotFound, 0);
+ return TRUE;
+ }
+
+ NSInteger locationOfOpeningBracket = [addressPort rangeOfString:@"["].location;
+ NSInteger locationOfClosingBracket = [addressPort rangeOfString:@"]"].location;
+ if (locationOfOpeningBracket != NSNotFound || locationOfClosingBracket != NSNotFound) {
+ // If we have brackets, it's an IPv6 address
+ if (locationOfOpeningBracket == NSNotFound || locationOfClosingBracket == NSNotFound ||
+ locationOfClosingBracket < locationOfOpeningBracket) {
+ // Invalid address format
+ return FALSE;
+ }
+
+ // Cut at the brackets
+ *address = NSMakeRange(locationOfOpeningBracket + 1, locationOfClosingBracket - locationOfOpeningBracket - 1);
+ }
+ else {
+ // It's an IPv4 address, so just cut at the port separator
+ *address = NSMakeRange(0, [addressPort rangeOfString:@":"].location);
+ }
+
+ NSUInteger remainingStringLocation = address->location + address->length;
+ NSRange remainingStringRange = NSMakeRange(remainingStringLocation, [addressPort length] - remainingStringLocation);
+ NSInteger locationOfPortSeparator = [addressPort rangeOfString:@":" options:0 range:remainingStringRange].location;
+ if (locationOfPortSeparator != NSNotFound) {
+ *port = NSMakeRange(locationOfPortSeparator + 1, [addressPort length] - locationOfPortSeparator - 1);
+ }
+ else {
+ *port = NSMakeRange(NSNotFound, 0);
+ }
+
+ return TRUE;
+}
+
++ (NSString*) addressPortStringToAddress:(NSString*)addressPort {
+ NSRange addressRange, portRange;
+ if (![self parseAddressPortString:addressPort address:&addressRange port:&portRange]) {
+ return nil;
+ }
+
+ return [addressPort substringWithRange:addressRange];
+}
+
++ (unsigned short) addressPortStringToPort:(NSString*)addressPort {
+ NSRange addressRange, portRange;
+ if (![self parseAddressPortString:addressPort address:&addressRange port:&portRange] || portRange.location == NSNotFound) {
+ return 47989;
+ }
+
+ return [[addressPort substringWithRange:portRange] integerValue];
+}
+
++ (NSString*) addressAndPortToAddressPortString:(NSString*)address port:(unsigned short)port {
+ if ([address containsString:@":"]) {
+ // IPv6 addresses require escaping
+ return [NSString stringWithFormat:@"[%@]:%u", address, port];
+ }
+ else {
+ return [NSString stringWithFormat:@"%@:%u", address, port];
+ }
}
@end
diff --git a/Limelight/ViewControllers/LoadingFrameViewController.h b/Limelight/ViewControllers/LoadingFrameViewController.h
index 0635725..eb48071 100644
--- a/Limelight/ViewControllers/LoadingFrameViewController.h
+++ b/Limelight/ViewControllers/LoadingFrameViewController.h
@@ -10,5 +10,11 @@
@interface LoadingFrameViewController : UIViewController
+- (void)showLoadingFrame:(void (^)(void))completion;
+
+- (void)dismissLoadingFrame:(void (^)(void))completion;
+
+- (BOOL)isShown;
+
@property (weak, nonatomic) IBOutlet UIActivityIndicatorView *loadingSpinner;
@end
diff --git a/Limelight/ViewControllers/LoadingFrameViewController.m b/Limelight/ViewControllers/LoadingFrameViewController.m
index 340464e..550ea60 100644
--- a/Limelight/ViewControllers/LoadingFrameViewController.m
+++ b/Limelight/ViewControllers/LoadingFrameViewController.m
@@ -8,11 +8,9 @@
#import "LoadingFrameViewController.h"
-@interface LoadingFrameViewController ()
-
-@end
-
-@implementation LoadingFrameViewController
+@implementation LoadingFrameViewController {
+ BOOL presented;
+};
- (void)viewDidLoad {
[super viewDidLoad];
@@ -20,19 +18,66 @@
self.loadingSpinner.center = CGPointMake(self.view.frame.size.width / 2, self.view.frame.size.height / 2);
}
-- (void)didReceiveMemoryWarning {
- [super didReceiveMemoryWarning];
- // Dispose of any resources that can be recreated.
+- (UIViewController*) activeViewController {
+ UIViewController *topController = [UIApplication sharedApplication].keyWindow.rootViewController;
+
+ while (topController.presentedViewController) {
+ topController = topController.presentedViewController;
+ }
+
+ return topController;
}
-/*
-#pragma mark - Navigation
-
-// In a storyboard-based application, you will often want to do a little preparation before navigation
-- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
- // Get the new view controller using [segue destinationViewController].
- // Pass the selected object to the new view controller.
+- (void)showLoadingFrame:(void (^)(void))completion {
+ if (!presented) {
+ Log(LOG_I, @"Loading frame presenting start");
+ presented = YES;
+ [[self activeViewController] presentViewController:self animated:NO completion:^{
+ Log(LOG_I, @"Loading frame presenting complete");
+ if (completion) {
+ completion();
+ }
+ }];
+ }
+ else if (completion) {
+ Log(LOG_E, @"Loading frame already shown!");
+ completion();
+ }
+}
+
+- (void)dismissLoadingFrame:(void (^)(void))completion {
+ if (presented) {
+ Log(LOG_I, @"Loading frame hiding start");
+ [self dismissViewControllerAnimated:NO completion:^{
+ Log(LOG_I, @"Loading frame hiding complete");
+
+ // Since presented is set to NO here rather than
+ // immediately in dismissLoadingFrame, we may
+ // falsely avoid displaying the loading frame if
+ // a dismiss is in progress while attempting to show
+ // the frame. That's preferable to crashing due to
+ // displaying the same VC twice though.
+ //
+ // This scenario can happen if the app is suspended
+ // while the dismiss is in progress then on resume
+ // it attempts to display it again before the dismiss
+ // completes. It can be reproduced by rapidly pressing
+ // Home and switching back to Moonlight while in the app grid.
+ // It reproduces more easily if the VC transitions are animated.
+ self->presented = NO;
+
+ if (completion) {
+ completion();
+ }
+ }];
+ }
+ else if (completion) {
+ completion();
+ }
+}
+
+- (BOOL)isShown {
+ return presented;
}
-*/
@end
diff --git a/Limelight/ViewControllers/MainFrameViewController.h b/Limelight/ViewControllers/MainFrameViewController.h
index 1348b12..5461ec9 100644
--- a/Limelight/ViewControllers/MainFrameViewController.h
+++ b/Limelight/ViewControllers/MainFrameViewController.h
@@ -17,7 +17,9 @@
@interface MainFrameViewController : UICollectionViewController
-@property (strong, nonatomic) IBOutlet UIButton *limelightLogoButton;
-@property (weak, nonatomic) IBOutlet UIBarButtonItem *computerNameButton;
+@property (weak, nonatomic) IBOutlet UIBarButtonItem *settingsButton;
+#if !TARGET_OS_TV
+@property (weak, nonatomic) IBOutlet UIBarButtonItem *upButton;
+#endif
@end
diff --git a/Limelight/ViewControllers/MainFrameViewController.m b/Limelight/ViewControllers/MainFrameViewController.m
index 12c89fd..169bb13 100644
--- a/Limelight/ViewControllers/MainFrameViewController.m
+++ b/Limelight/ViewControllers/MainFrameViewController.m
@@ -15,7 +15,6 @@
#import "Utils.h"
#import "UIComputerView.h"
#import "UIAppView.h"
-#import "SettingsViewController.h"
#import "DataManager.h"
#import "TemporarySettings.h"
#import "WakeOnLanManager.h"
@@ -26,171 +25,226 @@
#import "ComputerScrollView.h"
#import "TemporaryApp.h"
#import "IdManager.h"
+#import "ConnectionHelper.h"
+
+#if !TARGET_OS_TV
+#import "SettingsViewController.h"
+#else
+#import
+#endif
+
+#import
+
+#include
@implementation MainFrameViewController {
NSOperationQueue* _opQueue;
TemporaryHost* _selectedHost;
+ BOOL _showHiddenApps;
NSString* _uniqueId;
- NSData* _cert;
+ NSData* _clientCert;
DiscoveryManager* _discMan;
AppAssetManager* _appManager;
StreamConfiguration* _streamConfig;
UIAlertController* _pairAlert;
+ LoadingFrameViewController* _loadingFrame;
UIScrollView* hostScrollView;
- int currentPosition;
+ FrontViewPosition currentPosition;
NSArray* _sortedAppList;
NSCache* _boxArtCache;
+ bool _background;
+#if TARGET_OS_TV
+ UITapGestureRecognizer* _menuRecognizer;
+#endif
}
static NSMutableSet* hostList;
-- (void)showPIN:(NSString *)PIN {
- dispatch_async(dispatch_get_main_queue(), ^{
- _pairAlert = [UIAlertController alertControllerWithTitle:@"Pairing"
- message:[NSString stringWithFormat:@"Enter the following PIN on the host machine: %@", PIN]
- preferredStyle:UIAlertControllerStyleAlert];
- [_pairAlert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleDestructive handler:^(UIAlertAction* action) {
- _pairAlert = nil;
- [_discMan startDiscovery];
- [self hideLoadingFrame];
+- (void)startPairing:(NSString *)PIN {
+ // Needs to be synchronous to ensure the alert is shown before any potential
+ // failure callback could be invoked.
+ dispatch_sync(dispatch_get_main_queue(), ^{
+ self->_pairAlert = [UIAlertController alertControllerWithTitle:@"Pairing"
+ message:[NSString stringWithFormat:@"Enter the following PIN on the host machine: %@\n\nIf your host PC is running Sunshine, navigate to the Sunshine web UI to enter the PIN.", PIN]
+ preferredStyle:UIAlertControllerStyleAlert];
+ [self->_pairAlert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleDestructive handler:^(UIAlertAction* action) {
+ self->_pairAlert = nil;
+ [self->_discMan startDiscovery];
+ [self hideLoadingFrame: ^{
+ [self showHostSelectionView];
+ }];
}]];
- [self presentViewController:_pairAlert animated:YES completion:nil];
+ [[self activeViewController] presentViewController:self->_pairAlert animated:YES completion:nil];
});
}
-- (void)displayFailureDialog:(NSString *)message {
+- (void)displayPairingFailureDialog:(NSString *)message {
UIAlertController* failedDialog = [UIAlertController alertControllerWithTitle:@"Pairing Failed"
- message:message
- preferredStyle:UIAlertControllerStyleAlert];
- [failedDialog addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDestructive handler:nil]];
- [self presentViewController:failedDialog animated:YES completion:nil];
+ message:message
+ preferredStyle:UIAlertControllerStyleAlert];
+ [Utils addHelpOptionToDialog:failedDialog];
+ [failedDialog addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
[_discMan startDiscovery];
- [self hideLoadingFrame];
+
+ [self hideLoadingFrame: ^{
+ [self showHostSelectionView];
+ [[self activeViewController] presentViewController:failedDialog animated:YES completion:nil];
+ }];
}
- (void)pairFailed:(NSString *)message {
dispatch_async(dispatch_get_main_queue(), ^{
- if (_pairAlert != nil) {
- [_pairAlert dismissViewControllerAnimated:YES completion:^{
- [self displayFailureDialog:message];
+ if (self->_pairAlert != nil) {
+ [self->_pairAlert dismissViewControllerAnimated:YES completion:^{
+ [self displayPairingFailureDialog:message];
}];
- _pairAlert = nil;
- }
- else {
- [self displayFailureDialog:message];
+ self->_pairAlert = nil;
}
});
}
-- (void)pairSuccessful {
+- (void)pairSuccessful:(NSData*)serverCert {
dispatch_async(dispatch_get_main_queue(), ^{
- [_pairAlert dismissViewControllerAnimated:YES completion:nil];
- _pairAlert = nil;
-
- [_discMan startDiscovery];
+ // Store the cert from pairing with the host
+ self->_selectedHost.serverCert = serverCert;
+
+ [self->_pairAlert dismissViewControllerAnimated:YES completion:nil];
+ self->_pairAlert = nil;
+
+ [self->_discMan startDiscovery];
[self alreadyPaired];
});
}
+- (void)disableUpButton {
+#if !TARGET_OS_TV
+ [self->_upButton setTitle:nil];
+#endif
+}
+
+- (void)enableUpButton {
+#if !TARGET_OS_TV
+ [self->_upButton setTitle:@"Select New Host"];
+#endif
+}
+
+- (void)updateTitle {
+ if (_selectedHost != nil) {
+ self.title = _selectedHost.name;
+ }
+ else if ([hostList count] == 0) {
+ self.title = @"Searching for PCs on your network...";
+ }
+ else {
+ self.title = @"Select Host";
+ }
+}
+
- (void)alreadyPaired {
BOOL usingCachedAppList = false;
// Capture the host here because it can change once we
// leave the main thread
TemporaryHost* host = _selectedHost;
+ if (host == nil) {
+ [self hideLoadingFrame: nil];
+ return;
+ }
if ([host.appList count] > 0) {
usingCachedAppList = true;
dispatch_async(dispatch_get_main_queue(), ^{
- if (host != _selectedHost) {
+ if (host != self->_selectedHost) {
+ [self hideLoadingFrame: nil];
return;
}
- _computerNameButton.title = host.name;
- [self.navigationController.navigationBar setNeedsLayout];
-
[self updateAppsForHost:host];
- [self hideLoadingFrame];
+ [self hideLoadingFrame: nil];
});
}
Log(LOG_I, @"Using cached app list: %d", usingCachedAppList);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
- HttpManager* hMan = [[HttpManager alloc] initWithHost:host.activeAddress uniqueId:_uniqueId deviceName:deviceName cert:_cert];
-
// Exempt this host from discovery while handling the applist query
- [_discMan removeHostFromDiscovery:host];
+ [self->_discMan pauseDiscoveryForHost:host];
- // Try up to 5 times to get the app list
- AppListResponse* appListResp;
- for (int i = 0; i < 5; i++) {
- appListResp = [[AppListResponse alloc] init];
- [hMan executeRequestSynchronously:[HttpRequest requestForResponse:appListResp withUrlRequest:[hMan newAppListRequest]]];
- if (appListResp == nil || ![appListResp isStatusOk] || [appListResp getAppList] == nil) {
- Log(LOG_W, @"Failed to get applist on try %d: %@", i, appListResp.statusMessage);
-
- // Wait for one second then retry
- [NSThread sleepForTimeInterval:1];
- }
- else {
- Log(LOG_I, @"App list successfully retreived - took %d tries", i);
- break;
- }
- }
+ AppListResponse* appListResp = [ConnectionHelper getAppListForHost:host];
- [_discMan addHostToDiscovery:host];
+ [self->_discMan resumeDiscoveryForHost:host];
- if (appListResp == nil || ![appListResp isStatusOk] || [appListResp getAppList] == nil) {
+ if (![appListResp isStatusOk] || [appListResp getAppList] == nil) {
Log(LOG_W, @"Failed to get applist: %@", appListResp.statusMessage);
dispatch_async(dispatch_get_main_queue(), ^{
- [self hideLoadingFrame];
-
- if (host != _selectedHost) {
+ if (host != self->_selectedHost) {
+ [self hideLoadingFrame: nil];
return;
}
- UIAlertController* applistAlert = [UIAlertController alertControllerWithTitle:@"Fetching App List Failed"
- message:@"The connection to the PC was interrupted."
+ UIAlertController* applistAlert = [UIAlertController alertControllerWithTitle:@"Connection Interrupted"
+ message:appListResp.statusMessage
preferredStyle:UIAlertControllerStyleAlert];
- [applistAlert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDestructive handler:nil]];
- [self presentViewController:applistAlert animated:YES completion:nil];
- host.online = NO;
- [self showHostSelectionView];
+ [Utils addHelpOptionToDialog:applistAlert];
+ [applistAlert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
+ [self hideLoadingFrame: ^{
+ [self showHostSelectionView];
+ [[self activeViewController] presentViewController:applistAlert animated:YES completion:nil];
+ }];
+ host.state = StateOffline;
});
} else {
dispatch_async(dispatch_get_main_queue(), ^{
[self updateApplist:[appListResp getAppList] forHost:host];
- if (host != _selectedHost) {
+ if (host != self->_selectedHost) {
+ [self hideLoadingFrame: nil];
return;
}
- _computerNameButton.title = host.name;
- [self.navigationController.navigationBar setNeedsLayout];
-
[self updateAppsForHost:host];
- [_appManager stopRetrieving];
- [_appManager retrieveAssetsFromHost:host];
- [self hideLoadingFrame];
+ [self->_appManager stopRetrieving];
+ [self->_appManager retrieveAssetsFromHost:host];
+ [self hideLoadingFrame: nil];
});
}
});
}
+- (void) updateAppEntry:(TemporaryApp*)app forHost:(TemporaryHost*)host {
+ DataManager* database = [[DataManager alloc] init];
+ NSMutableSet* newHostAppList = [NSMutableSet setWithSet:host.appList];
+
+ for (TemporaryApp* savedApp in newHostAppList) {
+ if ([app.id isEqualToString:savedApp.id]) {
+ savedApp.name = app.name;
+ savedApp.hdrSupported = app.hdrSupported;
+ savedApp.hidden = app.hidden;
+
+ host.appList = newHostAppList;
+
+ [database updateAppsForExistingHost:host];
+ return;
+ }
+ }
+}
+
- (void) updateApplist:(NSSet*) newList forHost:(TemporaryHost*)host {
DataManager* database = [[DataManager alloc] init];
+ NSMutableSet* newHostAppList = [NSMutableSet setWithSet:host.appList];
for (TemporaryApp* app in newList) {
BOOL appAlreadyInList = NO;
- for (TemporaryApp* savedApp in host.appList) {
+ for (TemporaryApp* savedApp in newHostAppList) {
if ([app.id isEqualToString:savedApp.id]) {
savedApp.name = app.name;
+ savedApp.hdrSupported = app.hdrSupported;
+ // Don't propagate hidden, because we want the local data to prevail
appAlreadyInList = YES;
break;
}
}
if (!appAlreadyInList) {
app.host = host;
- [host.appList addObject:app];
+ [newHostAppList addObject:app];
}
}
@@ -198,7 +252,7 @@ static NSMutableSet* hostList;
do {
appWasRemoved = NO;
- for (TemporaryApp* app in host.appList) {
+ for (TemporaryApp* app in newHostAppList) {
appWasRemoved = YES;
for (TemporaryApp* mergedApp in newList) {
if ([mergedApp.id isEqualToString:app.id]) {
@@ -210,7 +264,7 @@ static NSMutableSet* hostList;
// Removing the app mutates the list we're iterating (which isn't legal).
// We need to jump out of this loop and restart enumeration.
- [host.appList removeObject:app];
+ [newHostAppList removeObject:app];
// It's important to remove the app record from the database
// since we'll have a constraint violation now that appList
@@ -223,14 +277,31 @@ static NSMutableSet* hostList;
// Keep looping until the list is no longer being mutated
} while (appWasRemoved);
+
+ host.appList = newHostAppList;
[database updateAppsForExistingHost:host];
+
+ // This host may be eligible for a shortcut now that the app list
+ // has been populated
+ [self updateHostShortcuts];
}
- (void)showHostSelectionView {
+#if TARGET_OS_TV
+ // Remove the menu button intercept to allow the app to exit
+ // when at the host selection view.
+ [self.navigationController.view removeGestureRecognizer:_menuRecognizer];
+#endif
+
[_appManager stopRetrieving];
+ _showHiddenApps = NO;
_selectedHost = nil;
- _computerNameButton.title = @"No Host Selected";
+ _sortedAppList = nil;
+
+ [self updateTitle];
+ [self disableUpButton];
+
[self.collectionView reloadData];
[self.view addSubview:hostScrollView];
}
@@ -240,9 +311,6 @@ static NSMutableSet* hostList;
// on the main thread
[self updateBoxArtCacheForApp:app];
- DataManager* dataManager = [[DataManager alloc] init];
- [dataManager updateIconForExistingApp: app];
-
dispatch_async(dispatch_get_main_queue(), ^{
[self.collectionView reloadData];
});
@@ -252,8 +320,9 @@ static NSMutableSet* hostList;
UIAlertController* alert = [UIAlertController alertControllerWithTitle:@"Network Error"
message:@"Failed to resolve host."
preferredStyle:UIAlertControllerStyleAlert];
- [alert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDestructive handler:nil]];
- [self presentViewController:alert animated:YES completion:nil];
+ [Utils addHelpOptionToDialog:alert];
+ [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
+ [[self activeViewController] presentViewController:alert animated:YES completion:nil];
}
- (void) hostClicked:(TemporaryHost *)host view:(UIView *)view {
@@ -261,91 +330,218 @@ static NSMutableSet* hostList;
// This shows the context menu with wake, delete, etc. rather
// than just hanging for a while and failing as we would in this
// code path.
- if (!host.online && view != nil) {
+ if (host.state != StateOnline && view != nil) {
[self hostLongClicked:host view:view];
return;
}
Log(LOG_D, @"Clicked host: %@", host.name);
_selectedHost = host;
+ [self updateTitle];
+ [self enableUpButton];
[self disableNavigation];
+#if TARGET_OS_TV
+ // Intercept the menu key to go back to the host page
+ [self.navigationController.view addGestureRecognizer:_menuRecognizer];
+#endif
+
// If we are online, paired, and have a cached app list, skip straight
// to the app grid without a loading frame. This is the fast path that users
// should hit most. Check for a valid view because we don't want to hit the fast
// path after coming back from streaming, since we need to fetch serverinfo too
// so that our active game data is correct.
- if (host.online && host.pairState == PairStatePaired && host.appList.count > 0 && view != nil) {
+ if (host.state == StateOnline && host.pairState == PairStatePaired && host.appList.count > 0 && view != nil) {
[self alreadyPaired];
return;
}
- [self showLoadingFrame];
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
- HttpManager* hMan = [[HttpManager alloc] initWithHost:host.activeAddress uniqueId:_uniqueId deviceName:deviceName cert:_cert];
- ServerInfoResponse* serverInfoResp = [[ServerInfoResponse alloc] init];
-
- // Exempt this host from discovery while handling the serverinfo request
- [_discMan removeHostFromDiscovery:host];
- [hMan executeRequestSynchronously:[HttpRequest requestForResponse:serverInfoResp withUrlRequest:[hMan newServerInfoRequest]
- fallbackError:401 fallbackRequest:[hMan newHttpServerInfoRequest]]];
- [_discMan addHostToDiscovery:host];
-
- if (serverInfoResp == nil || ![serverInfoResp isStatusOk]) {
- Log(LOG_W, @"Failed to get server info: %@", serverInfoResp.statusMessage);
- dispatch_async(dispatch_get_main_queue(), ^{
- [self hideLoadingFrame];
-
- if (host != _selectedHost) {
- return;
- }
-
- UIAlertController* applistAlert = [UIAlertController alertControllerWithTitle:@"Fetching Server Info Failed"
- message:@"The connection to the PC was interrupted."
- preferredStyle:UIAlertControllerStyleAlert];
- [applistAlert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDestructive handler:nil]];
- [self presentViewController:applistAlert animated:YES completion:nil];
- host.online = NO;
- [self showHostSelectionView];
- });
- } else {
- Log(LOG_D, @"server info pair status: %@", [serverInfoResp getStringTag:@"PairStatus"]);
- if ([[serverInfoResp getStringTag:@"PairStatus"] isEqualToString:@"1"]) {
- Log(LOG_I, @"Already Paired");
- [self alreadyPaired];
- } else {
- Log(LOG_I, @"Trying to pair");
- // Polling the server while pairing causes the server to screw up
- [_discMan stopDiscoveryBlocking];
- PairManager* pMan = [[PairManager alloc] initWithManager:hMan andCert:_cert callback:self];
- [_opQueue addOperation:pMan];
+ [self showLoadingFrame: ^{
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
+ // Wait for the PC's status to be known
+ while (host.state == StateUnknown) {
+ sleep(1);
}
- }
- });
+
+ // Don't bother polling if the server is already offline
+ if (host.state == StateOffline) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self hideLoadingFrame:^{
+ [self showHostSelectionView];
+ }];
+ });
+ return;
+ }
+
+ HttpManager* hMan = [[HttpManager alloc] initWithHost:host];
+ ServerInfoResponse* serverInfoResp = [[ServerInfoResponse alloc] init];
+
+ // Exempt this host from discovery while handling the serverinfo request
+ [self->_discMan pauseDiscoveryForHost:host];
+ [hMan executeRequestSynchronously:[HttpRequest requestForResponse:serverInfoResp withUrlRequest:[hMan newServerInfoRequest:false]
+ fallbackError:401 fallbackRequest:[hMan newHttpServerInfoRequest]]];
+ [self->_discMan resumeDiscoveryForHost:host];
+
+ if (![serverInfoResp isStatusOk]) {
+ Log(LOG_W, @"Failed to get server info: %@", serverInfoResp.statusMessage);
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (host != self->_selectedHost) {
+ [self hideLoadingFrame:nil];
+ return;
+ }
+
+ UIAlertController* applistAlert = [UIAlertController alertControllerWithTitle:@"Connection Failed"
+ message:serverInfoResp.statusMessage
+ preferredStyle:UIAlertControllerStyleAlert];
+ [Utils addHelpOptionToDialog:applistAlert];
+ [applistAlert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
+
+ // Only display an alert if this was the result of a real
+ // user action, not just passively entering the foreground again
+ [self hideLoadingFrame: ^{
+ [self showHostSelectionView];
+ if (view != nil) {
+ [[self activeViewController] presentViewController:applistAlert animated:YES completion:nil];
+ }
+ }];
+
+ host.state = StateOffline;
+ });
+ } else {
+ // Update the host object with this data
+ [serverInfoResp populateHost:host];
+ if (host.pairState == PairStatePaired) {
+ Log(LOG_I, @"Already Paired");
+ [self alreadyPaired];
+ }
+ // Only pair when this was the result of explicit user action
+ else if (view != nil) {
+ Log(LOG_I, @"Trying to pair");
+ // Polling the server while pairing causes the server to screw up
+ [self->_discMan stopDiscoveryBlocking];
+ PairManager* pMan = [[PairManager alloc] initWithManager:hMan clientCert:self->_clientCert callback:self];
+ [self->_opQueue addOperation:pMan];
+ }
+ else {
+ // Not user action, so just return to host screen
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self hideLoadingFrame:^{
+ [self showHostSelectionView];
+ }];
+ });
+ }
+ }
+ });
+ }];
+}
+
+- (UIViewController*) activeViewController {
+ UIViewController *topController = [UIApplication sharedApplication].keyWindow.rootViewController;
+
+ while (topController.presentedViewController) {
+ topController = topController.presentedViewController;
+ }
+
+ return topController;
}
- (void)hostLongClicked:(TemporaryHost *)host view:(UIView *)view {
Log(LOG_D, @"Long clicked host: %@", host.name);
- UIAlertController* longClickAlert = [UIAlertController alertControllerWithTitle:host.name message:@"" preferredStyle:UIAlertControllerStyleActionSheet];
- if (!host.online) {
- [longClickAlert addAction:[UIAlertAction actionWithTitle:@"Wake" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
- UIAlertController* wolAlert = [UIAlertController alertControllerWithTitle:@"Wake On Lan" message:@"" preferredStyle:UIAlertControllerStyleAlert];
+ NSString* message;
+
+ switch (host.state) {
+ case StateOffline:
+ message = @"Offline";
+ break;
+
+ case StateOnline:
+ if (host.pairState == PairStatePaired) {
+ message = @"Online - Paired";
+ }
+ else {
+ message = @"Online - Not Paired";
+ }
+ break;
+
+ case StateUnknown:
+ message = @"Connecting";
+ break;
+
+ default:
+ break;
+ }
+
+ UIAlertController* longClickAlert = [UIAlertController alertControllerWithTitle:host.name message:message preferredStyle:UIAlertControllerStyleActionSheet];
+ if (host.state != StateOnline) {
+ [longClickAlert addAction:[UIAlertAction actionWithTitle:@"Wake PC" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
+ UIAlertController* wolAlert = [UIAlertController alertControllerWithTitle:@"Wake-On-LAN" message:@"" preferredStyle:UIAlertControllerStyleAlert];
[wolAlert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
- if (host.pairState != PairStatePaired) {
- wolAlert.message = @"Cannot wake host because you are not paired";
- } else if (host.mac == nil || [host.mac isEqualToString:@"00:00:00:00:00:00"]) {
+ if (host.mac == nil || [host.mac isEqualToString:@"00:00:00:00:00:00"]) {
wolAlert.message = @"Host MAC unknown, unable to send WOL Packet";
} else {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[WakeOnLanManager wakeHost:host];
});
- wolAlert.message = @"Sent WOL Packet";
+ wolAlert.message = @"Successfully sent wake-up request. It may take a few moments for the PC to wake. If it never wakes up, ensure it's properly configured for Wake-on-LAN.";
}
- [self presentViewController:wolAlert animated:YES completion:nil];
+ [[self activeViewController] presentViewController:wolAlert animated:YES completion:nil];
}]];
}
+ else if (host.pairState == PairStatePaired) {
+ [longClickAlert addAction:[UIAlertAction actionWithTitle:@"View All Apps" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
+ self->_showHiddenApps = YES;
+ [self hostClicked:host view:view];
+ }]];
+
+#if !TARGET_OS_TV
+ if (host.isNvidiaServerSoftware) {
+ [longClickAlert addAction:[UIAlertAction actionWithTitle:@"NVIDIA GameStream End-of-Service" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
+ [Utils launchUrl:@"https://github.com/moonlight-stream/moonlight-docs/wiki/NVIDIA-GameStream-End-Of-Service-Announcement-FAQ"];
+ }]];
+ }
+#endif
+ }
+ [longClickAlert addAction:[UIAlertAction actionWithTitle:@"Test Network" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action) {
+ [self showLoadingFrame:^{
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
+ // Perform the network test on a GCD worker thread. It may take a while.
+ unsigned int portTestResult = LiTestClientConnectivity(CONN_TEST_SERVER, 443, ML_PORT_FLAG_ALL);
+ dispatch_sync(dispatch_get_main_queue(), ^{
+ [self hideLoadingFrame:^{
+ NSString* message;
+
+ if (portTestResult == 0) {
+ message = @"This network does not appear to be blocking Moonlight. If you still have trouble connecting, check your PC's firewall settings.\n\nVisit the Moonlight Setup Guide on GitHub for additional setup help and troubleshooting steps.";
+ }
+ else if (portTestResult == ML_TEST_RESULT_INCONCLUSIVE) {
+ message = @"The network test could not be performed because none of Moonlight's connection testing servers were reachable. Check your Internet connection or try again later.";
+ }
+ else {
+ char blockedPorts[512];
+ LiStringifyPortFlags(portTestResult, "\n", blockedPorts, sizeof(blockedPorts));
+ message = [NSString stringWithFormat:@"Your current network connection seems to be blocking Moonlight. Streaming may not work while connected to this network.\n\nThe following network ports were blocked:\n%s", blockedPorts];
+ }
+
+ UIAlertController* netTestAlert = [UIAlertController alertControllerWithTitle:@"Network Test Complete" message:message preferredStyle:UIAlertControllerStyleAlert];
+ [netTestAlert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
+ [[self activeViewController] presentViewController:netTestAlert animated:YES completion:nil];
+ }];
+ });
+ });
+ }];
+ }]];
+#if !TARGET_OS_TV
+ if (host.state != StateOnline) {
+ [longClickAlert addAction:[UIAlertAction actionWithTitle:@"NVIDIA GameStream End-of-Service" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
+ [Utils launchUrl:@"https://github.com/moonlight-stream/moonlight-docs/wiki/NVIDIA-GameStream-End-Of-Service-Announcement-FAQ"];
+ }]];
+ [longClickAlert addAction:[UIAlertAction actionWithTitle:@"Connection Help" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
+ [Utils launchUrl:@"https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting"];
+ }]];
+ }
+#endif
[longClickAlert addAction:[UIAlertAction actionWithTitle:@"Remove Host" style:UIAlertActionStyleDestructive handler:^(UIAlertAction* action) {
- [_discMan removeHostFromDiscovery:host];
+ [self->_discMan removeHostFromDiscovery:host];
DataManager* dataMan = [[DataManager alloc] init];
[dataMan removeHost:host];
@synchronized(hostList) {
@@ -360,134 +556,307 @@ static NSMutableSet* hostList;
longClickAlert.popoverPresentationController.sourceView = view;
longClickAlert.popoverPresentationController.sourceRect = CGRectMake(view.bounds.size.width / 2.0, view.bounds.size.height / 2.0, 1.0, 1.0); // center of the view
- [self presentViewController:longClickAlert animated:YES completion:^{
- [self updateHosts];
- }];
+ [[self activeViewController] presentViewController:longClickAlert animated:YES completion:nil];
}
- (void) addHostClicked {
Log(LOG_D, @"Clicked add host");
- [self showLoadingFrame];
- UIAlertController* alertController = [UIAlertController alertControllerWithTitle:@"Host Address" message:@"Please enter a hostname or IP address" preferredStyle:UIAlertControllerStyleAlert];
+ UIAlertController* alertController = [UIAlertController alertControllerWithTitle:@"Add Host Manually" message:@"If Moonlight doesn't find your local gaming PC automatically,\nenter the IP address of your PC" preferredStyle:UIAlertControllerStyleAlert];
[alertController addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alertController addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
- NSString* hostAddress = ((UITextField*)[[alertController textFields] objectAtIndex:0]).text;
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
- [_discMan discoverHost:hostAddress withCallback:^(TemporaryHost* host, NSString* error){
- if (host != nil) {
- dispatch_async(dispatch_get_main_queue(), ^{
- @synchronized(hostList) {
- [hostList addObject:host];
+ NSString* hostAddress = [((UITextField*)[[alertController textFields] objectAtIndex:0]).text trim];
+ [self showLoadingFrame:^{
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
+ [self->_discMan discoverHost:hostAddress withCallback:^(TemporaryHost* host, NSString* error){
+ if (host != nil) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self hideLoadingFrame:^{
+ @synchronized(hostList) {
+ [hostList addObject:host];
+ }
+ [self updateHosts];
+ }];
+ });
+ } else {
+ unsigned int portTestResults = LiTestClientConnectivity(CONN_TEST_SERVER, 443,
+ ML_PORT_FLAG_TCP_47984 | ML_PORT_FLAG_TCP_47989);
+ if (portTestResults != ML_TEST_RESULT_INCONCLUSIVE && portTestResults != 0) {
+ error = [error stringByAppendingString:@"\n\nYour device's network connection is blocking Moonlight. Streaming may not work while connected to this network."];
}
- [self updateHosts];
- });
- } else {
- UIAlertController* hostNotFoundAlert = [UIAlertController alertControllerWithTitle:@"Add Host" message:error preferredStyle:UIAlertControllerStyleAlert];
- [hostNotFoundAlert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDestructive handler:nil]];
- dispatch_async(dispatch_get_main_queue(), ^{
- [self presentViewController:hostNotFoundAlert animated:YES completion:nil];
- });
- }
- }];});
+
+ UIAlertController* hostNotFoundAlert = [UIAlertController alertControllerWithTitle:@"Add Host Manually" message:error preferredStyle:UIAlertControllerStyleAlert];
+ [Utils addHelpOptionToDialog:hostNotFoundAlert];
+ [hostNotFoundAlert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self hideLoadingFrame:^{
+ [[self activeViewController] presentViewController:hostNotFoundAlert animated:YES completion:nil];
+ }];
+ });
+ }
+ }];
+ });
+ }];
}]];
[alertController addTextFieldWithConfigurationHandler:nil];
- [self hideLoadingFrame];
- [self presentViewController:alertController animated:YES completion:nil];
+ [[self activeViewController] presentViewController:alertController animated:YES completion:nil];
}
-- (void) appClicked:(TemporaryApp *)app {
- Log(LOG_D, @"Clicked app: %@", app.name);
+- (void) prepareToStreamApp:(TemporaryApp *)app {
_streamConfig = [[StreamConfiguration alloc] init];
_streamConfig.host = app.host.activeAddress;
+ _streamConfig.httpsPort = app.host.httpsPort;
_streamConfig.appID = app.id;
+ _streamConfig.appName = app.name;
+ _streamConfig.serverCert = app.host.serverCert;
DataManager* dataMan = [[DataManager alloc] init];
TemporarySettings* streamSettings = [dataMan getSettings];
_streamConfig.frameRate = [streamSettings.framerate intValue];
- _streamConfig.bitRate = [streamSettings.bitrate intValue];
+ if (@available(iOS 10.3, *)) {
+ // Don't stream more FPS than the display can show
+ if (_streamConfig.frameRate > [UIScreen mainScreen].maximumFramesPerSecond) {
+ _streamConfig.frameRate = (int)[UIScreen mainScreen].maximumFramesPerSecond;
+ Log(LOG_W, @"Clamping FPS to maximum refresh rate: %d", _streamConfig.frameRate);
+ }
+ }
+
_streamConfig.height = [streamSettings.height intValue];
_streamConfig.width = [streamSettings.width intValue];
+#if TARGET_OS_TV
+ // Don't allow streaming 4K on the Apple TV HD
+ struct utsname systemInfo;
+ uname(&systemInfo);
+ if (strcmp(systemInfo.machine, "AppleTV5,3") == 0 && _streamConfig.height >= 2160) {
+ Log(LOG_W, @"4K streaming not supported on Apple TV HD");
+ _streamConfig.width = 1920;
+ _streamConfig.height = 1080;
+ }
+#endif
+
+ _streamConfig.bitRate = [streamSettings.bitrate intValue];
+ _streamConfig.optimizeGameSettings = streamSettings.optimizeGames;
+ _streamConfig.playAudioOnPC = streamSettings.playAudioOnPC;
+ _streamConfig.useFramePacing = streamSettings.useFramePacing;
+ _streamConfig.swapABXYButtons = streamSettings.swapABXYButtons;
+
+ // multiController must be set before calling getConnectedGamepadMask
+ _streamConfig.multiController = streamSettings.multiController;
+ _streamConfig.gamepadMask = [ControllerSupport getConnectedGamepadMask:_streamConfig];
+
+ // Probe for supported channel configurations
+ int physicalOutputChannels = (int)[AVAudioSession sharedInstance].maximumOutputNumberOfChannels;
+ Log(LOG_I, @"Audio device supports %d channels", physicalOutputChannels);
+
+ int numberOfChannels = MIN([streamSettings.audioConfig intValue], physicalOutputChannels);
+ Log(LOG_I, @"Selected number of audio channels %d", numberOfChannels);
+ if (numberOfChannels >= 8) {
+ _streamConfig.audioConfiguration = AUDIO_CONFIGURATION_71_SURROUND;
+ }
+ else if (numberOfChannels >= 6) {
+ _streamConfig.audioConfiguration = AUDIO_CONFIGURATION_51_SURROUND;
+ }
+ else {
+ _streamConfig.audioConfiguration = AUDIO_CONFIGURATION_STEREO;
+ }
+
+ _streamConfig.serverCodecModeSupport = app.host.serverCodecModeSupport;
+
+ switch (streamSettings.preferredCodec) {
+ case CODEC_PREF_AV1:
+#if defined(__IPHONE_16_0) || defined(__TVOS_16_0)
+ if (VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1)) {
+ _streamConfig.supportedVideoFormats |= VIDEO_FORMAT_AV1_MAIN8;
+ }
+#endif
+ // Fall-through
+
+ case CODEC_PREF_AUTO:
+ case CODEC_PREF_HEVC:
+ if (VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC)) {
+ _streamConfig.supportedVideoFormats |= VIDEO_FORMAT_H265;
+ }
+ // Fall-through
+
+ case CODEC_PREF_H264:
+ _streamConfig.supportedVideoFormats |= VIDEO_FORMAT_H264;
+ break;
+ }
+
+ // HEVC is supported if the user wants it (or it's required by the chosen resolution) and the SoC supports it
+ if ((_streamConfig.width > 4096 || _streamConfig.height > 4096 || streamSettings.enableHdr) && VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC)) {
+ _streamConfig.supportedVideoFormats |= VIDEO_FORMAT_H265;
+
+ // HEVC Main10 is supported if the user wants it and the display supports it
+ if (streamSettings.enableHdr && (AVPlayer.availableHDRModes & AVPlayerHDRModeHDR10) != 0) {
+ _streamConfig.supportedVideoFormats |= VIDEO_FORMAT_H265_MAIN10;
+ }
+ }
+
+#if defined(__IPHONE_16_0) || defined(__TVOS_16_0)
+ // Add the AV1 Main10 format if AV1 and HDR are both enabled and supported
+ if ((_streamConfig.supportedVideoFormats & VIDEO_FORMAT_MASK_AV1) && streamSettings.enableHdr &&
+ VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1) && (AVPlayer.availableHDRModes & AVPlayerHDRModeHDR10) != 0) {
+ _streamConfig.supportedVideoFormats |= VIDEO_FORMAT_AV1_MAIN10;
+ }
+#endif
+}
+
+- (void)appLongClicked:(TemporaryApp *)app view:(UIView *)view {
+ Log(LOG_D, @"Long clicked app: %@", app.name);
[_appManager stopRetrieving];
+#if !TARGET_OS_TV
if (currentPosition != FrontViewPositionLeft) {
- [[self revealViewController] revealToggle:self];
+ // This must not be animated because we need the position
+ // to change (and notify our callback to save settings data)
+ // before we call prepareToStreamApp.
+ [[self revealViewController] revealToggleAnimated:NO];
+ }
+#endif
+
+ TemporaryApp* currentApp = [self findRunningApp:app.host];
+
+ NSString* message;
+
+ if (currentApp == nil || [app.id isEqualToString:currentApp.id]) {
+ if (app.hidden) {
+ message = @"Hidden";
+ }
+ else {
+ message = @"";
+ }
+ }
+ else {
+ message = [NSString stringWithFormat:@"%@ is currently running", currentApp.name];
}
- TemporaryApp* currentApp = [self findRunningApp:app.host];
+ UIAlertController* alertController = [UIAlertController
+ alertControllerWithTitle: app.name
+ message:message
+ preferredStyle:UIAlertControllerStyleActionSheet];
+
+ [alertController addAction:[UIAlertAction
+ actionWithTitle:currentApp == nil ? @"Launch App" : ([app.id isEqualToString:currentApp.id] ? @"Resume App" : @"Resume Running App") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
+ if (currentApp != nil) {
+ Log(LOG_I, @"Resuming application: %@", currentApp.name);
+ [self prepareToStreamApp:currentApp];
+ }
+ else {
+ Log(LOG_I, @"Launching application: %@", app.name);
+ [self prepareToStreamApp:app];
+ }
+
+ [self performSegueWithIdentifier:@"createStreamFrame" sender:nil];
+ }]];
+
if (currentApp != nil) {
- UIAlertController* alertController = [UIAlertController
- alertControllerWithTitle: app.name
- message: [app.id isEqualToString:currentApp.id] ? @"" : [NSString stringWithFormat:@"%@ is currently running", currentApp.name]preferredStyle:UIAlertControllerStyleAlert];
- [alertController addAction:[UIAlertAction
- actionWithTitle:[app.id isEqualToString:currentApp.id] ? @"Resume App" : @"Resume Running App" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
- Log(LOG_I, @"Resuming application: %@", currentApp.name);
- [self performSegueWithIdentifier:@"createStreamFrame" sender:nil];
- }]];
[alertController addAction:[UIAlertAction actionWithTitle:
[app.id isEqualToString:currentApp.id] ? @"Quit App" : @"Quit Running App and Start" style:UIAlertActionStyleDestructive handler:^(UIAlertAction* action){
Log(LOG_I, @"Quitting application: %@", currentApp.name);
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
- HttpManager* hMan = [[HttpManager alloc] initWithHost:app.host.activeAddress uniqueId:_uniqueId deviceName:deviceName cert:_cert];
- HttpResponse* quitResponse = [[HttpResponse alloc] init];
- HttpRequest* quitRequest = [HttpRequest requestForResponse: quitResponse withUrlRequest:[hMan newQuitAppRequest]];
-
- // Exempt this host from discovery while handling the quit operation
- [_discMan removeHostFromDiscovery:app.host];
- [hMan executeRequestSynchronously:quitRequest];
- if (quitResponse.statusCode == 200) {
- ServerInfoResponse* serverInfoResp = [[ServerInfoResponse alloc] init];
- [hMan executeRequestSynchronously:[HttpRequest requestForResponse:serverInfoResp withUrlRequest:[hMan newServerInfoRequest]
- fallbackError:401 fallbackRequest:[hMan newHttpServerInfoRequest]]];
- if (![serverInfoResp isStatusOk] || [[serverInfoResp getStringTag:@"state"] hasSuffix:@"_SERVER_BUSY"]) {
- // On newer GFE versions, the quit request succeeds even though the app doesn't
- // really quit if another client tries to kill your app. We'll patch the response
- // to look like the old error in that case, so the UI behaves.
- quitResponse.statusCode = 599;
+ [self showLoadingFrame: ^{
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
+ HttpManager* hMan = [[HttpManager alloc] initWithHost:app.host];
+ HttpResponse* quitResponse = [[HttpResponse alloc] init];
+ HttpRequest* quitRequest = [HttpRequest requestForResponse: quitResponse withUrlRequest:[hMan newQuitAppRequest]];
+
+ // Exempt this host from discovery while handling the quit operation
+ [self->_discMan pauseDiscoveryForHost:app.host];
+ [hMan executeRequestSynchronously:quitRequest];
+ if (quitResponse.statusCode == 200) {
+ ServerInfoResponse* serverInfoResp = [[ServerInfoResponse alloc] init];
+ [hMan executeRequestSynchronously:[HttpRequest requestForResponse:serverInfoResp withUrlRequest:[hMan newServerInfoRequest:false]
+ fallbackError:401 fallbackRequest:[hMan newHttpServerInfoRequest]]];
+ if (![serverInfoResp isStatusOk] || [[serverInfoResp getStringTag:@"state"] hasSuffix:@"_SERVER_BUSY"]) {
+ // On newer GFE versions, the quit request succeeds even though the app doesn't
+ // really quit if another client tries to kill your app. We'll patch the response
+ // to look like the old error in that case, so the UI behaves.
+ quitResponse.statusCode = 599;
+ }
+ else if ([serverInfoResp isStatusOk]) {
+ // Update the host object with this info
+ [serverInfoResp populateHost:app.host];
+ }
+ }
+ [self->_discMan resumeDiscoveryForHost:app.host];
+
+ // If it fails, display an error and stop the current operation
+ if (quitResponse.statusCode != 200) {
+ UIAlertController* alert = [UIAlertController alertControllerWithTitle:@"Quitting App Failed"
+ message:@"Failed to quit app. If this app was started by "
+ "another device, you'll need to quit from that device."
+ preferredStyle:UIAlertControllerStyleAlert];
+ [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self updateAppsForHost:app.host];
+ [self hideLoadingFrame: ^{
+ [[self activeViewController] presentViewController:alert animated:YES completion:nil];
+ }];
+ });
+ }
+ else {
+ app.host.currentGame = @"0";
+ dispatch_async(dispatch_get_main_queue(), ^{
+ // If it succeeds and we're to start streaming, segue to the stream
+ if (![app.id isEqualToString:currentApp.id]) {
+ [self prepareToStreamApp:app];
+ [self hideLoadingFrame: ^{
+ [self performSegueWithIdentifier:@"createStreamFrame" sender:nil];
+ }];
+ }
+ else {
+ // Otherwise, just hide the loading icon
+ [self hideLoadingFrame:nil];
+ }
+ });
}
- }
- [_discMan addHostToDiscovery:app.host];
-
- UIAlertController* alert;
-
- // If it fails, display an error and stop the current operation
- if (quitResponse.statusCode != 200) {
- alert = [UIAlertController alertControllerWithTitle:@"Quitting App Failed"
- message:@"Failed to quit app. If this app was started by "
- "another device, you'll need to quit from that device."
- preferredStyle:UIAlertControllerStyleAlert];
- }
- // If it succeeds and we're to start streaming, segue to the stream and return
- else if (![app.id isEqualToString:currentApp.id]) {
- app.host.currentGame = @"0";
-
- dispatch_async(dispatch_get_main_queue(), ^{
- [self updateAppsForHost:app.host];
- [self performSegueWithIdentifier:@"createStreamFrame" sender:nil];
- });
-
- return;
- }
- // Otherwise, display a dialog to notify the user that the app was quit
- else {
- app.host.currentGame = @"0";
-
- alert = [UIAlertController alertControllerWithTitle:@"Quitting App"
- message:@"The app was quit successfully."
- preferredStyle:UIAlertControllerStyleAlert];
- }
-
- [alert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDestructive handler:nil]];
- dispatch_async(dispatch_get_main_queue(), ^{
- [self updateAppsForHost:app.host];
- [self presentViewController:alert animated:YES completion:nil];
});
- });
+ }];
+
}]];
- [alertController addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
- [self presentViewController:alertController animated:YES completion:nil];
+ }
+
+ if (currentApp == nil || ![app.id isEqualToString:currentApp.id] || app.hidden) {
+ [alertController addAction:[UIAlertAction actionWithTitle:app.hidden ? @"Show App" : @"Hide App"
+ style:app.hidden ? UIAlertActionStyleDefault : UIAlertActionStyleDestructive
+ handler:^(UIAlertAction* action) {
+ app.hidden = !app.hidden;
+ [self updateAppEntry:app forHost:app.host];
+
+ // Don't call updateAppsForHost because that will nuke this
+ // app immediately if we're not showing hidden apps.
+ }]];
+ }
+
+ [alertController addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
+
+ // these two lines are required for iPad support of UIAlertSheet
+ alertController.popoverPresentationController.sourceView = view;
+
+ alertController.popoverPresentationController.sourceRect = CGRectMake(view.bounds.size.width / 2.0, view.bounds.size.height / 2.0, 1.0, 1.0); // center of the view
+ [[self activeViewController] presentViewController:alertController animated:YES completion:nil];
+}
+
+- (void) appClicked:(TemporaryApp *)app view:(UIView *)view {
+ Log(LOG_D, @"Clicked app: %@", app.name);
+
+ [_appManager stopRetrieving];
+
+#if !TARGET_OS_TV
+ if (currentPosition != FrontViewPositionLeft) {
+ // This must not be animated because we need the position
+ // to change (and notify our callback to save settings data)
+ // before we call prepareToStreamApp.
+ [[self revealViewController] revealToggleAnimated:NO];
+ }
+#endif
+
+ if ([self findRunningApp:app.host]) {
+ // If there's a running app, display a menu
+ [self appLongClicked:app view:view];
} else {
+ [self prepareToStreamApp:app];
[self performSegueWithIdentifier:@"createStreamFrame" sender:nil];
}
}
@@ -501,13 +870,22 @@ static NSMutableSet* hostList;
return nil;
}
+#if !TARGET_OS_TV
- (void)revealController:(SWRevealViewController *)revealController didMoveToPosition:(FrontViewPosition)position {
// If we moved back to the center position, we should save the settings
if (position == FrontViewPositionLeft) {
[(SettingsViewController*)[revealController rearViewController] saveSettings];
}
+
currentPosition = position;
}
+#endif
+
+#if TARGET_OS_TV
+- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
+ [self appClicked:_sortedAppList[indexPath.row] view:nil];
+}
+#endif
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
if ([segue.destinationViewController isKindOfClass:[StreamFrameViewController class]]) {
@@ -516,26 +894,44 @@ static NSMutableSet* hostList;
}
}
-- (void) showLoadingFrame {
- LoadingFrameViewController* loadingFrame = [self.storyboard instantiateViewControllerWithIdentifier:@"loadingFrame"];
- [self.navigationController presentViewController:loadingFrame animated:YES completion:nil];
+- (void) showLoadingFrame:(void (^)(void))completion {
+ [_loadingFrame showLoadingFrame:completion];
}
-- (void) hideLoadingFrame {
- [self dismissViewControllerAnimated:YES completion:nil];
+- (void) hideLoadingFrame:(void (^)(void))completion {
[self enableNavigation];
+ [_loadingFrame dismissLoadingFrame:completion];
+}
+
+- (void)adjustScrollViewForSafeArea:(UIScrollView*)view {
+ if (@available(iOS 11.0, *)) {
+ if (self.view.safeAreaInsets.left >= 20 || self.view.safeAreaInsets.right >= 20) {
+ view.contentInset = UIEdgeInsetsMake(0, 20, 0, 20);
+ }
+ }
+}
+
+// Adjust the subviews for the safe area on the iPhone X.
+- (void)viewSafeAreaInsetsDidChange {
+ [super viewSafeAreaInsetsDidChange];
+
+ [self adjustScrollViewForSafeArea:self.collectionView];
+ [self adjustScrollViewForSafeArea:self->hostScrollView];
}
- (void)viewDidLoad
{
[super viewDidLoad];
-
+
+#if !TARGET_OS_TV
// Set the side bar button action. When it's tapped, it'll show the sidebar.
- [_limelightLogoButton addTarget:self.revealViewController action:@selector(revealToggle:) forControlEvents:UIControlEventTouchDown];
+ [_settingsButton setTarget:self.revealViewController];
+ [_settingsButton setAction:@selector(revealToggle:)];
// Set the host name button action. When it's tapped, it'll show the host selection view.
- [_computerNameButton setTarget:self];
- [_computerNameButton setAction:@selector(showHostSelectionView)];
+ [_upButton setTarget:self];
+ [_upButton setAction:@selector(showHostSelectionView)];
+ [self disableUpButton];
// Set the gesture
[self.view addGestureRecognizer:self.revealViewController.panGestureRecognizer];
@@ -543,13 +939,34 @@ static NSMutableSet* hostList;
// Get callbacks associated with the viewController
[self.revealViewController setDelegate:self];
+ // Disable bounce-back on reveal VC otherwise the settings will snap closed
+ // if the user drags all the way off the screen opposite the settings pane.
+ self.revealViewController.bounceBackOnOverdraw = NO;
+#else
+ // The settings button will direct the user into the Settings app on tvOS
+ [_settingsButton setTarget:self];
+ [_settingsButton setAction:@selector(openTvSettings:)];
+
+ // Restore focus on the selected app on view controller pop navigation
+ self.restoresFocusAfterTransition = NO;
+ self.collectionView.remembersLastFocusedIndexPath = YES;
+
+ _menuRecognizer = [[UITapGestureRecognizer alloc] init];
+ [_menuRecognizer addTarget:self action: @selector(showHostSelectionView)];
+ _menuRecognizer.allowedPressTypes = [[NSArray alloc] initWithObjects:[NSNumber numberWithLong:UIPressTypeMenu], nil];
+
+ self.navigationController.navigationBar.titleTextAttributes = [NSDictionary dictionaryWithObject:[UIColor whiteColor] forKey:NSForegroundColorAttributeName];
+#endif
+
+ _loadingFrame = [self.storyboard instantiateViewControllerWithIdentifier:@"loadingFrame"];
+
// Set the current position to the center
currentPosition = FrontViewPositionLeft;
// Set up crypto
- [CryptoManager generateKeyPairUsingSSl];
+ [CryptoManager generateKeyPairUsingSSL];
_uniqueId = [IdManager getUniqueId];
- _cert = [CryptoManager readCertFromFile];
+ _clientCert = [CryptoManager readCertFromFile];
_appManager = [[AppAssetManager alloc] initWithCallback:self];
_opQueue = [[NSOperationQueue alloc] init];
@@ -560,58 +977,128 @@ static NSMutableSet* hostList;
}
_boxArtCache = [[NSCache alloc] init];
-
- [self setAutomaticallyAdjustsScrollViewInsets:NO];
-
+
hostScrollView = [[ComputerScrollView alloc] init];
hostScrollView.frame = CGRectMake(0, self.navigationController.navigationBar.frame.origin.y + self.navigationController.navigationBar.frame.size.height, self.view.frame.size.width, self.view.frame.size.height / 2);
[hostScrollView setShowsHorizontalScrollIndicator:NO];
hostScrollView.delaysContentTouches = NO;
- UIButton* pullArrow = [[UIButton alloc] init];
- [pullArrow addTarget:self.revealViewController action:@selector(revealToggle:) forControlEvents:UIControlEventTouchDown];
- [pullArrow setImage:[UIImage imageNamed:@"PullArrow"] forState:UIControlStateNormal];
- [pullArrow sizeToFit];
- pullArrow.frame = CGRectMake(0,
- self.collectionView.frame.size.height / 6 - pullArrow.frame.size.height / 2 - self.navigationController.navigationBar.frame.size.height,
- pullArrow.frame.size.width,
- pullArrow.frame.size.height);
-
self.collectionView.delaysContentTouches = NO;
self.collectionView.allowsMultipleSelection = NO;
+#if !TARGET_OS_TV
self.collectionView.multipleTouchEnabled = NO;
+#else
+ // This is the only way to get long press events on a UICollectionViewCell :(
+ UILongPressGestureRecognizer* cellLongPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleCollectionViewLongPress:)];
+ cellLongPress.delaysTouchesBegan = YES;
+ [self.collectionView addGestureRecognizer:cellLongPress];
+#endif
[self retrieveSavedHosts];
_discMan = [[DiscoveryManager alloc] initWithHosts:[hostList allObjects] andCallback:self];
-
- [[NSNotificationCenter defaultCenter] addObserver: self
- selector: @selector(handleReturnToForeground)
- name: UIApplicationWillEnterForegroundNotification
- object: nil];
-
- [self updateHosts];
- [self.view addSubview:hostScrollView];
- [self.view addSubview:pullArrow];
+
+ if ([hostList count] == 1) {
+ [self hostClicked:[hostList anyObject] view:nil];
+ }
+ else {
+ [self updateTitle];
+ [self.view addSubview:hostScrollView];
+ }
}
--(void)dealloc
+#if TARGET_OS_TV
+-(void)handleCollectionViewLongPress:(UILongPressGestureRecognizer *)gestureRecognizer
{
- [[NSNotificationCenter defaultCenter] removeObserver:self];
+ // FIXME: Something is delaying touches so we only get to the Begin state
+ // before we actually want to signal the long press.
+ if (gestureRecognizer.state != UIGestureRecognizerStateBegan) {
+ return;
+ }
+
+ CGPoint point = [gestureRecognizer locationInView:self.collectionView];
+ NSIndexPath *indexPath = [self.collectionView indexPathForItemAtPoint:point];
+ if (indexPath != nil) {
+ [self appLongClicked:_sortedAppList[indexPath.row] view:nil];
+ }
+}
+
+- (void)openTvSettings:(id)sender
+{
+ [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:nil];
+}
+#endif
+
+-(void)beginForegroundRefresh
+{
+ if (!_background) {
+ // This will kick off box art caching
+ [self updateHosts];
+
+ // Reset state first so we can rediscover hosts that were deleted before
+ [_discMan resetDiscoveryState];
+ [_discMan startDiscovery];
+
+ // This will refresh the applist when a paired host is selected
+ if (_selectedHost != nil && _selectedHost.pairState == PairStatePaired) {
+ [self hostClicked:_selectedHost view:nil];
+ }
+ }
+}
+
+-(void)handlePendingShortcutAction
+{
+ // Check if we have a pending shortcut action
+ AppDelegate* delegate = (AppDelegate*)[UIApplication sharedApplication].delegate;
+ if (delegate.pcUuidToLoad != nil) {
+ // Find the host it corresponds to
+ TemporaryHost* matchingHost = nil;
+ for (TemporaryHost* host in hostList) {
+ if ([host.uuid isEqualToString:delegate.pcUuidToLoad]) {
+ matchingHost = host;
+ break;
+ }
+ }
+
+ // Clear the pending shortcut action
+ delegate.pcUuidToLoad = nil;
+
+ // Complete the request
+ if (delegate.shortcutCompletionHandler != nil) {
+ delegate.shortcutCompletionHandler(matchingHost != nil);
+ delegate.shortcutCompletionHandler = nil;
+ }
+
+ if (matchingHost != nil && _selectedHost != matchingHost) {
+ // Navigate to the host page
+ [self hostClicked:matchingHost view:nil];
+ }
+ }
}
-(void)handleReturnToForeground
{
- // This will refresh the applist when a paired host is selected
- if (_selectedHost != nil && _selectedHost.pairState == PairStatePaired) {
- [self hostClicked:_selectedHost view:nil];
- }
+ _background = NO;
+
+ [self beginForegroundRefresh];
+
+ // Check for a pending shortcut action when returning to foreground
+ [self handlePendingShortcutAction];
+}
+
+-(void)handleEnterBackground
+{
+ _background = YES;
+
+ [_discMan stopDiscovery];
}
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
+#if !TARGET_OS_TV
[[self revealViewController] setPrimaryViewController:self];
+#endif
[self.navigationController setNavigationBarHidden:NO animated:YES];
@@ -620,19 +1107,52 @@ static NSMutableSet* hostList;
[self.navigationController.navigationBar setShadowImage:fakeImage];
[self.navigationController.navigationBar setBackgroundImage:fakeImage forBarPosition:UIBarPositionAny barMetrics:UIBarMetricsDefault];
- [_discMan startDiscovery];
+ // Check for a pending shortcut action when appearing
+ [self handlePendingShortcutAction];
- [self handleReturnToForeground];
+ [[NSNotificationCenter defaultCenter] addObserver: self
+ selector: @selector(handleReturnToForeground)
+ name: UIApplicationDidBecomeActiveNotification
+ object: nil];
+
+ [[NSNotificationCenter defaultCenter] addObserver: self
+ selector: @selector(handleEnterBackground)
+ name: UIApplicationWillResignActiveNotification
+ object: nil];
+}
+
+- (void)viewWillAppear:(BOOL)animated
+{
+ [super viewWillAppear:animated];
+
+ // We can get here on home press while streaming
+ // since the stream view segues to us just before
+ // entering the background. We can't check the app
+ // state here (since it's in transition), so we have
+ // to use this function that will use our internal
+ // state here to determine whether we're foreground.
+ //
+ // Note that this is neccessary here as we may enter
+ // this view via an error dialog from the stream
+ // view, so we won't get a return to active notification
+ // for that which would normally fire beginForegroundRefresh.
+ [self beginForegroundRefresh];
}
- (void)viewDidDisappear:(BOOL)animated
{
[super viewDidDisappear:animated];
- // when discovery stops, we must create a new instance because you cannot restart an NSOperation when it is finished
+
+ // when discovery stops, we must create a new instance because
+ // you cannot restart an NSOperation when it is finished
[_discMan stopDiscovery];
// Purge the box art cache
[_boxArtCache removeAllObjects];
+
+ // Remove our lifetime observers to avoid triggering them
+ // while streaming
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void) retrieveSavedHosts {
@@ -652,24 +1172,54 @@ static NSMutableSet* hostList;
if (host.activeAddress == nil) {
host.activeAddress = host.address;
}
+ if (host.activeAddress == nil) {
+ host.activeAddress = host.ipv6Address;
+ }
}
}
}
- (void) updateAllHosts:(NSArray *)hosts {
+ // We must copy the array here because it could be modified
+ // before our main thread dispatch happens.
+ NSArray* hostsCopy = [NSArray arrayWithArray:hosts];
dispatch_async(dispatch_get_main_queue(), ^{
Log(LOG_D, @"New host list:");
- for (TemporaryHost* host in hosts) {
- Log(LOG_D, @"Host: \n{\n\t name:%@ \n\t address:%@ \n\t localAddress:%@ \n\t externalAddress:%@ \n\t uuid:%@ \n\t mac:%@ \n\t pairState:%d \n\t online:%d \n\t activeAddress:%@ \n}", host.name, host.address, host.localAddress, host.externalAddress, host.uuid, host.mac, host.pairState, host.online, host.activeAddress);
+ for (TemporaryHost* host in hostsCopy) {
+ Log(LOG_D, @"Host: \n{\n\t name:%@ \n\t address:%@ \n\t localAddress:%@ \n\t externalAddress:%@ \n\t ipv6Address:%@ \n\t uuid:%@ \n\t mac:%@ \n\t pairState:%d \n\t online:%d \n\t activeAddress:%@ \n}", host.name, host.address, host.localAddress, host.externalAddress, host.ipv6Address, host.uuid, host.mac, host.pairState, host.state, host.activeAddress);
}
@synchronized(hostList) {
[hostList removeAllObjects];
- [hostList addObjectsFromArray:hosts];
+ [hostList addObjectsFromArray:hostsCopy];
}
[self updateHosts];
});
}
+- (void)updateHostShortcuts {
+#if !TARGET_OS_TV
+ NSMutableArray* quickActions = [[NSMutableArray alloc] init];
+
+ @synchronized (hostList) {
+ for (TemporaryHost* host in hostList) {
+ // Pair state may be unknown if we haven't polled it yet, but the app list
+ // count will persist from paired PCs
+ if ([host.appList count] > 0) {
+ UIApplicationShortcutItem* shortcut = [[UIApplicationShortcutItem alloc]
+ initWithType:@"PC"
+ localizedTitle:host.name
+ localizedSubtitle:nil
+ icon:[UIApplicationShortcutIcon iconWithType:UIApplicationShortcutIconTypePlay]
+ userInfo:[NSDictionary dictionaryWithObject:host.uuid forKey:@"UUID"]];
+ [quickActions addObject: shortcut];
+ }
+ }
+ }
+
+ [UIApplication sharedApplication].shortcutItems = quickActions;
+#endif
+}
+
- (void)updateHosts {
Log(LOG_I, @"Updating hosts...");
[[hostScrollView subviews] makeObjectsPerformSelector:@selector(removeFromSuperview)];
@@ -684,9 +1234,22 @@ static NSMutableSet* hostList;
compView.center = CGPointMake([self getCompViewX:compView addComp:addComp prevEdge:prevEdge], hostScrollView.frame.size.height / 2);
prevEdge = compView.frame.origin.x + compView.frame.size.width;
[hostScrollView addSubview:compView];
+
+ // Start jobs to decode the box art in advance
+ for (TemporaryApp* app in comp.appList) {
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
+ [self updateBoxArtCacheForApp:app];
+ });
+ }
}
}
+ // Create or delete host shortcuts as needed
+ [self updateHostShortcuts];
+
+ // Update the title in case we now have a PC
+ [self updateTitle];
+
prevEdge = [self getCompViewX:addComp addComp:addComp prevEdge:prevEdge];
addComp.center = CGPointMake(prevEdge, hostScrollView.frame.size.height / 2);
@@ -695,21 +1258,51 @@ static NSMutableSet* hostList;
}
- (float) getCompViewX:(UIComputerView*)comp addComp:(UIComputerView*)addComp prevEdge:(float)prevEdge {
+ float padding;
+
+#if TARGET_OS_TV
+ padding = 100;
+#else
+ padding = addComp.frame.size.width / 2;
+#endif
+
if (prevEdge == -1) {
- return hostScrollView.frame.origin.x + comp.frame.size.width / 2 + addComp.frame.size.width / 2;
+ return hostScrollView.frame.origin.x + comp.frame.size.width / 2 + padding;
} else {
- return prevEdge + addComp.frame.size.width / 2 + comp.frame.size.width / 2;
+ return prevEdge + comp.frame.size.width / 2 + padding;
}
}
// This function forces immediate decoding of the UIImage, rather
// than the default lazy decoding that results in janky scrolling.
+ (UIImage*) loadBoxArtForCaching:(TemporaryApp*)app {
+ UIImage* boxArt;
- CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)app.image, NULL);
- CGImageRef cgImage = CGImageSourceCreateImageAtIndex(source, 0, (__bridge CFDictionaryRef)@{(id)kCGImageSourceShouldCacheImmediately: (id)kCFBooleanTrue});
+ NSData* imageData = [NSData dataWithContentsOfFile:[AppAssetManager boxArtPathForApp:app]];
+ if (imageData == nil) {
+ // No box art on disk
+ return nil;
+ }
- UIImage *boxArt = [UIImage imageWithCGImage:cgImage];
+ CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)imageData, NULL);
+ CGImageRef cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil);
+
+ size_t width = CGImageGetWidth(cgImage);
+ size_t height = CGImageGetHeight(cgImage);
+
+ CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
+ CGContextRef imageContext = CGBitmapContextCreate(NULL, width, height, 8, width * 4, colorSpace,
+ kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Little);
+ CGColorSpaceRelease(colorSpace);
+
+ CGContextDrawImage(imageContext, CGRectMake(0, 0, width, height), cgImage);
+
+ CGImageRef outputImage = CGBitmapContextCreateImage(imageContext);
+
+ boxArt = [UIImage imageWithCGImage:outputImage];
+
+ CGImageRelease(outputImage);
+ CGContextRelease(imageContext);
CGImageRelease(cgImage);
CFRelease(source);
@@ -718,11 +1311,12 @@ static NSMutableSet* hostList;
}
- (void) updateBoxArtCacheForApp:(TemporaryApp*)app {
- if (app.image == nil) {
- [_boxArtCache removeObjectForKey:app];
- }
- else if ([_boxArtCache objectForKey:app] == nil) {
- [_boxArtCache setObject:[MainFrameViewController loadBoxArtForCaching:app] forKey:app];
+ if ([_boxArtCache objectForKey:app] == nil) {
+ UIImage* image = [MainFrameViewController loadBoxArtForCaching:app];
+ if (image != nil) {
+ // Add the image to our cache if it was present
+ [_boxArtCache setObject:image forKey:app];
+ }
}
}
@@ -735,32 +1329,15 @@ static NSMutableSet* hostList;
_sortedAppList = [host.appList allObjects];
_sortedAppList = [_sortedAppList sortedArrayUsingSelector:@selector(compareName:)];
- // Split the sorted array in half to allow 2 jobs to process app assets at once
- NSArray *firstHalf;
- NSArray *secondHalf;
- NSRange range;
-
- range.location = 0;
- range.length = [_sortedAppList count] / 2;
-
- firstHalf = [_sortedAppList subarrayWithRange:range];
-
- range.location = range.length;
- range.length = [_sortedAppList count] - range.length;
-
- secondHalf = [_sortedAppList subarrayWithRange:range];
-
- // Start 2 jobs
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
- for (TemporaryApp* app in firstHalf) {
- [self updateBoxArtCacheForApp:app];
+ if (!_showHiddenApps) {
+ NSMutableArray* visibleAppList = [NSMutableArray array];
+ for (TemporaryApp* app in _sortedAppList) {
+ if (!app.hidden) {
+ [visibleAppList addObject:app];
+ }
}
- });
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
- for (TemporaryApp* app in secondHalf) {
- [self updateBoxArtCacheForApp:app];
- }
- });
+ _sortedAppList = visibleAppList;
+ }
[hostScrollView removeFromSuperview];
[self.collectionView reloadData];
@@ -771,7 +1348,6 @@ static NSMutableSet* hostList;
TemporaryApp* app = _sortedAppList[indexPath.row];
UIAppView* appView = [[UIAppView alloc] initWithApp:app cache:_boxArtCache andCallback:self];
- [appView updateAppImage];
if (appView.bounds.size.width > 10.0) {
CGFloat scale = cell.bounds.size.width / appView.bounds.size.width;
@@ -781,18 +1357,20 @@ static NSMutableSet* hostList;
[cell.subviews.firstObject removeFromSuperview]; // Remove a view that was previously added
[cell addSubview:appView];
-
+ // Shadow opacity is controlled inside UIAppView based on whether the app
+ // is hidden or not during the update cycle.
UIBezierPath *shadowPath = [UIBezierPath bezierPathWithRect:cell.bounds];
cell.layer.masksToBounds = NO;
cell.layer.shadowColor = [UIColor blackColor].CGColor;
cell.layer.shadowOffset = CGSizeMake(1.0f, 5.0f);
- cell.layer.shadowOpacity = 0.5f;
cell.layer.shadowPath = shadowPath.CGPath;
- cell.layer.borderColor = [[UIColor colorWithRed:0 green:0 blue:0 alpha:0.3f] CGColor];
+#if !TARGET_OS_TV
cell.layer.borderWidth = 1;
+ cell.layer.borderColor = [[UIColor colorWithRed:0 green:0 blue:0 alpha:0.3f] CGColor];
cell.exclusiveTouch = YES;
+#endif
return cell;
}
@@ -802,8 +1380,8 @@ static NSMutableSet* hostList;
}
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
- if (_selectedHost != nil) {
- return _selectedHost.appList.count;
+ if (_selectedHost != nil && _sortedAppList != nil) {
+ return _sortedAppList.count;
}
else {
return 0;
@@ -813,7 +1391,9 @@ static NSMutableSet* hostList;
- (void)didReceiveMemoryWarning
{
[super didReceiveMemoryWarning];
- // Dispose of any resources that can be recreated.
+
+ // Purge the box art cache on low memory
+ [_boxArtCache removeAllObjects];
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
@@ -825,16 +1405,36 @@ static NSMutableSet* hostList;
return YES;
}
+#if !TARGET_OS_TV
- (BOOL)shouldAutorotate {
return YES;
}
+#endif
- (void) disableNavigation {
self.navigationController.navigationBar.topItem.rightBarButtonItem.enabled = NO;
+ self.navigationController.navigationBar.topItem.leftBarButtonItem.enabled = NO;
}
- (void) enableNavigation {
self.navigationController.navigationBar.topItem.rightBarButtonItem.enabled = YES;
+ self.navigationController.navigationBar.topItem.leftBarButtonItem.enabled = YES;
+}
+
+#if TARGET_OS_TV
+- (BOOL)canBecomeFocused {
+ return YES;
+}
+#endif
+
+- (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator {
+
+#if !TARGET_OS_TV
+ if (context.nextFocusedView != nil) {
+ [context.nextFocusedView setAlpha:0.8];
+ }
+ [context.previouslyFocusedView setAlpha:1.0];
+#endif
}
@end
diff --git a/Limelight/ViewControllers/SWRevealViewController.m b/Limelight/ViewControllers/SWRevealViewController.m
index 0ef3488..bf64803 100755
--- a/Limelight/ViewControllers/SWRevealViewController.m
+++ b/Limelight/ViewControllers/SWRevealViewController.m
@@ -626,10 +626,10 @@ const int FrontViewPositionNone = 0xff;
_frontViewPosition = FrontViewPositionLeft;
_rearViewPosition = FrontViewPositionLeft;
_rightViewPosition = FrontViewPositionLeft;
- _rearViewRevealWidth = 260.0f;
+ _rearViewRevealWidth = 510.0f;
_rearViewRevealOverdraw = 60.0f;
_rearViewRevealDisplacement = 40.0f;
- _rightViewRevealWidth = 260.0f;
+ _rightViewRevealWidth = 510.0f;
_rightViewRevealOverdraw = 60.0f;
_rightViewRevealDisplacement = 40.0f;
_bounceBackOnOverdraw = YES;
@@ -996,16 +996,32 @@ const int FrontViewPositionNone = 0xff;
- (void)_getRevealWidth:(CGFloat*)pRevealWidth revealOverDraw:(CGFloat*)pRevealOverdraw forSymetry:(int)symetry
{
- if ( symetry < 0 ) *pRevealWidth = _rightViewRevealWidth, *pRevealOverdraw = _rightViewRevealOverdraw;
- else *pRevealWidth = _rearViewRevealWidth, *pRevealOverdraw = _rearViewRevealOverdraw;
+ if ( symetry < 0 )
+ {
+ *pRevealWidth = _rightViewRevealWidth;
+ *pRevealOverdraw = _rightViewRevealOverdraw;
+ }
+ else
+ {
+ *pRevealWidth = _rearViewRevealWidth;
+ *pRevealOverdraw = _rearViewRevealOverdraw;
+ }
if (*pRevealWidth < 0) *pRevealWidth = _contentView.bounds.size.width + *pRevealWidth;
}
- (void)_getBounceBack:(BOOL*)pBounceBack pStableDrag:(BOOL*)pStableDrag forSymetry:(int)symetry
{
- if ( symetry < 0 ) *pBounceBack = _bounceBackOnLeftOverdraw, *pStableDrag = _stableDragOnLeftOverdraw;
- else *pBounceBack = _bounceBackOnOverdraw, *pStableDrag = _stableDragOnOverdraw;
+ if ( symetry < 0 )
+ {
+ *pBounceBack = _bounceBackOnLeftOverdraw;
+ *pStableDrag = _stableDragOnLeftOverdraw;
+ }
+ else
+ {
+ *pBounceBack = _bounceBackOnOverdraw;
+ *pStableDrag = _stableDragOnOverdraw;
+ }
}
- (void)_getAdjustedFrontViewPosition:(FrontViewPosition*)frontViewPosition forSymetry:(int)symetry
@@ -1374,6 +1390,9 @@ const int FrontViewPositionNone = 0xff;
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
[self setNeedsUpdateOfScreenEdgesDeferringSystemGestures];
}
+ if (@available(iOS 14.0, *)) {
+ [self setNeedsUpdateOfPrefersPointerLocked];
+ }
}
- (UIViewController*)childViewControllerForHomeIndicatorAutoHidden
@@ -1386,26 +1405,31 @@ const int FrontViewPositionNone = 0xff;
return _primaryViewController;
}
+- (UIViewController*)childViewControllerForPointerLock
+{
+ return _primaryViewController;
+}
+
#pragma mark Animated view controller deployment and layout
// Primitive method for view controller deployment and animated layout to the given position.
- (void)_setFrontViewPosition:(FrontViewPosition)newPosition withDuration:(NSTimeInterval)duration
{
- void (^rearDeploymentCompletion)() = [self _rearViewDeploymentForNewFrontViewPosition:newPosition];
- void (^rightDeploymentCompletion)() = [self _rightViewDeploymentForNewFrontViewPosition:newPosition];
- void (^frontDeploymentCompletion)() = [self _frontViewDeploymentForNewFrontViewPosition:newPosition];
+ void (^rearDeploymentCompletion)(void) = [self _rearViewDeploymentForNewFrontViewPosition:newPosition];
+ void (^rightDeploymentCompletion)(void) = [self _rightViewDeploymentForNewFrontViewPosition:newPosition];
+ void (^frontDeploymentCompletion)(void) = [self _frontViewDeploymentForNewFrontViewPosition:newPosition];
- void (^animations)() = ^()
+ void (^animations)(void) = ^(void)
{
// Calling this in the animation block causes the status bar to appear/dissapear in sync with our own animation
[self setNeedsStatusBarAppearanceUpdate];
// We call the layoutSubviews method on the contentView view and send a delegate, which will
// occur inside of an animation block if any animated transition is being performed
- [_contentView layoutSubviews];
+ [self->_contentView layoutSubviews];
- if ([_delegate respondsToSelector:@selector(revealController:animateToPosition:)])
- [_delegate revealController:self animateToPosition:_frontViewPosition];
+ if ([self->_delegate respondsToSelector:@selector(revealController:animateToPosition:)])
+ [self->_delegate revealController:self animateToPosition:self->_frontViewPosition];
};
void (^completion)(BOOL) = ^(BOOL finished)
@@ -1448,21 +1472,31 @@ const int FrontViewPositionNone = 0xff;
UIView *view = nil;
if ( operation == SWRevealControllerOperationReplaceRearController )
- old = _rearViewController, _rearViewController = new, view = _contentView.rearView;
-
+ {
+ old = _rearViewController;
+ _rearViewController = new;
+ view = _contentView.rearView;
+ }
else if ( operation == SWRevealControllerOperationReplaceFrontController )
- old = _frontViewController, _frontViewController = new, view = _contentView.frontView;
-
+ {
+ old = _frontViewController;
+ _frontViewController = new;
+ view = _contentView.frontView;
+ }
else if ( operation == SWRevealControllerOperationReplaceRightController )
- old = _rightViewController, _rightViewController = new, view = _contentView.rightView;
+ {
+ old = _rightViewController;
+ _rightViewController = new;
+ view = _contentView.rightView;
+ }
- void (^completion)() = [self _transitionFromViewController:old toViewController:new inView:view];
+ void (^completion)(void) = [self _transitionFromViewController:old toViewController:new inView:view];
- void (^animationCompletion)() = ^
+ void (^animationCompletion)(void) = ^
{
completion();
- if ( [_delegate respondsToSelector:@selector(revealController:didAddViewController:forOperation:animated:)] )
- [_delegate revealController:self didAddViewController:new forOperation:operation animated:animated];
+ if ( [self->_delegate respondsToSelector:@selector(revealController:didAddViewController:forOperation:animated:)] )
+ [self->_delegate revealController:self didAddViewController:new forOperation:operation animated:animated];
[self _dequeue];
};
@@ -1520,16 +1554,16 @@ const int FrontViewPositionNone = 0xff;
_frontViewPosition = newPosition;
- void (^deploymentCompletion)() =
+ void (^deploymentCompletion)(void) =
[self _deploymentForViewController:_frontViewController inView:_contentView.frontView appear:appear disappear:disappear];
- void (^completion)() = ^()
+ void (^completion)(void) = ^(void)
{
deploymentCompletion();
if ( positionIsChanging )
{
- if ( [_delegate respondsToSelector:@selector(revealController:didMoveToPosition:)] )
- [_delegate revealController:self didMoveToPosition:newPosition];
+ if ( [self->_delegate respondsToSelector:@selector(revealController:didMoveToPosition:)] )
+ [self->_delegate revealController:self didMoveToPosition:newPosition];
}
};
@@ -1645,11 +1679,11 @@ const int FrontViewPositionNone = 0xff;
if ( toController ) [self addChildViewController:toController];
- void (^deployCompletion)() = [self _deployForViewController:toController inView:view];
+ void (^deployCompletion)(void) = [self _deployForViewController:toController inView:view];
[fromController willMoveToParentViewController:nil];
- void (^undeployCompletion)() = [self _undeployForViewController:fromController];
+ void (^undeployCompletion)(void) = [self _undeployForViewController:fromController];
void (^completionBlock)(void) = ^(void)
{
diff --git a/Limelight/ViewControllers/SettingsViewController.h b/Limelight/ViewControllers/SettingsViewController.h
index 5fdb155..1a79162 100644
--- a/Limelight/ViewControllers/SettingsViewController.h
+++ b/Limelight/ViewControllers/SettingsViewController.h
@@ -14,7 +14,27 @@
@property (strong, nonatomic) IBOutlet UISlider *bitrateSlider;
@property (strong, nonatomic) IBOutlet UISegmentedControl *framerateSelector;
@property (strong, nonatomic) IBOutlet UISegmentedControl *resolutionSelector;
+@property (strong, nonatomic) IBOutlet UIView *resolutionDisplayView;
+@property (strong, nonatomic) IBOutlet UISegmentedControl *touchModeSelector;
@property (strong, nonatomic) IBOutlet UISegmentedControl *onscreenControlSelector;
+@property (strong, nonatomic) IBOutlet UISegmentedControl *optimizeSettingsSelector;
+@property (strong, nonatomic) IBOutlet UISegmentedControl *multiControllerSelector;
+@property (strong, nonatomic) IBOutlet UISegmentedControl *swapABXYButtonsSelector;
+@property (strong, nonatomic) IBOutlet UISegmentedControl *audioOnPCSelector;
+@property (strong, nonatomic) IBOutlet UISegmentedControl *codecSelector;
+@property (strong, nonatomic) IBOutlet UISegmentedControl *hdrSelector;
+@property (strong, nonatomic) IBOutlet UISegmentedControl *framePacingSelector;
+@property (strong, nonatomic) IBOutlet UISegmentedControl *btMouseSelector;
+@property (strong, nonatomic) IBOutlet UISegmentedControl *statsOverlaySelector;
+@property (strong, nonatomic) IBOutlet UIScrollView *scrollView;
+
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wunguarded-availability"
+
+// This is okay because it's just an enum and access uses @available checks
+@property(nonatomic) UIUserInterfaceStyle overrideUserInterfaceStyle;
+
+#pragma clang diagnostic pop
- (void) saveSettings;
diff --git a/Limelight/ViewControllers/SettingsViewController.m b/Limelight/ViewControllers/SettingsViewController.m
index d060e09..bbe8eb2 100644
--- a/Limelight/ViewControllers/SettingsViewController.m
+++ b/Limelight/ViewControllers/SettingsViewController.m
@@ -10,90 +10,455 @@
#import "TemporarySettings.h"
#import "DataManager.h"
-#define BITRATE_INTERVAL 500 // in kbps
+#import
+#import
@implementation SettingsViewController {
NSInteger _bitrate;
- Boolean _adjustedForSafeArea;
+ NSInteger _lastSelectedResolutionIndex;
}
-static NSString* bitrateFormat = @"Bitrate: %.1f Mbps";
-- (void)viewDidAppear:(BOOL)animated
-{
- [super viewDidAppear:animated];
+@dynamic overrideUserInterfaceStyle;
+
+static NSString* bitrateFormat = @"Bitrate: %.1f Mbps";
+static const int bitrateTable[] = {
+ 500,
+ 1000,
+ 1500,
+ 2000,
+ 2500,
+ 3000,
+ 4000,
+ 5000,
+ 6000,
+ 7000,
+ 8000,
+ 9000,
+ 10000,
+ 12000,
+ 15000,
+ 18000,
+ 20000,
+ 30000,
+ 40000,
+ 50000,
+ 60000,
+ 70000,
+ 80000,
+ 100000,
+ 120000,
+ 150000,
+};
+
+const int RESOLUTION_TABLE_SIZE = 7;
+const int RESOLUTION_TABLE_CUSTOM_INDEX = RESOLUTION_TABLE_SIZE - 1;
+CGSize resolutionTable[RESOLUTION_TABLE_SIZE];
+
+-(int)getSliderValueForBitrate:(NSInteger)bitrate {
+ int i;
- // Adjust the subviews for the safe area on the iPhone X.
- if (!_adjustedForSafeArea) {
- if (@available(iOS 11.0, *)) {
- for (UIView* view in self.view.subviews) {
- // HACK: The official safe area is much too large for our purposes
- // so we'll just use the presence of any safe area to indicate we should
- // pad by 20.
- if (self.view.safeAreaInsets.left >= 20 || self.view.safeAreaInsets.right >= 20) {
- view.frame = CGRectMake(view.frame.origin.x + 20, view.frame.origin.y, view.frame.size.width, view.frame.size.height);
- }
+ for (i = 0; i < (sizeof(bitrateTable) / sizeof(*bitrateTable)); i++) {
+ if (bitrate <= bitrateTable[i]) {
+ return i;
+ }
+ }
+
+ // Return the last entry in the table
+ return i - 1;
+}
+
+// This view is rooted at a ScrollView. To make it scrollable,
+// we'll update content size here.
+-(void)viewDidLayoutSubviews {
+ CGFloat highestViewY = 0;
+
+ // Enumerate the scroll view's subviews looking for the
+ // highest view Y value to set our scroll view's content
+ // size.
+ for (UIView* view in self.scrollView.subviews) {
+ // UIScrollViews have 2 default child views
+ // which represent the horizontal and vertical scrolling
+ // indicators. Ignore any views we don't recognize.
+ if (![view isKindOfClass:[UILabel class]] &&
+ ![view isKindOfClass:[UISegmentedControl class]] &&
+ ![view isKindOfClass:[UISlider class]]) {
+ continue;
+ }
+
+ CGFloat currentViewY = view.frame.origin.y + view.frame.size.height;
+ if (currentViewY > highestViewY) {
+ highestViewY = currentViewY;
+ }
+ }
+
+ // Add a bit of padding so the view doesn't end right at the button of the display
+ self.scrollView.contentSize = CGSizeMake(self.scrollView.contentSize.width,
+ highestViewY + 20);
+}
+
+// Adjust the subviews for the safe area on the iPhone X.
+- (void)viewSafeAreaInsetsDidChange {
+ [super viewSafeAreaInsetsDidChange];
+
+ if (@available(iOS 11.0, *)) {
+ for (UIView* view in self.view.subviews) {
+ // HACK: The official safe area is much too large for our purposes
+ // so we'll just use the presence of any safe area to indicate we should
+ // pad by 20.
+ if (self.view.safeAreaInsets.left >= 20 || self.view.safeAreaInsets.right >= 20) {
+ view.frame = CGRectMake(view.frame.origin.x + 20, view.frame.origin.y, view.frame.size.width, view.frame.size.height);
}
}
-
- _adjustedForSafeArea = true;
}
}
+BOOL isCustomResolution(CGSize res) {
+ if (res.width == 0 && res.height == 0) {
+ return NO;
+ }
+
+ for (int i = 0; i < RESOLUTION_TABLE_CUSTOM_INDEX; i++) {
+ if (res.width == resolutionTable[i].width && res.height == resolutionTable[i].height) {
+ return NO;
+ }
+ }
+
+ return YES;
+}
+
- (void)viewDidLoad {
[super viewDidLoad];
+
+ // Always run settings in dark mode because we want the light fonts
+ if (@available(iOS 13.0, tvOS 13.0, *)) {
+ self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
+ }
DataManager* dataMan = [[DataManager alloc] init];
TemporarySettings* currentSettings = [dataMan getSettings];
- // Bitrate is persisted in kbps
- _bitrate = [currentSettings.bitrate integerValue];
- NSInteger framerate = [currentSettings.framerate integerValue] == 30 ? 0 : 1;
- NSInteger resolution;
- if ([currentSettings.height integerValue] == 720) {
- resolution = 0;
- } else if ([currentSettings.height integerValue] == 1080) {
- resolution = 1;
- } else {
- resolution = 0;
- }
- NSInteger onscreenControls = [currentSettings.onscreenControls integerValue];
+ // Ensure we pick a bitrate that falls exactly onto a slider notch
+ _bitrate = bitrateTable[[self getSliderValueForBitrate:[currentSettings.bitrate intValue]]];
+
+ // Get the size of the screen with and without safe area insets
+ UIWindow *window = UIApplication.sharedApplication.windows.firstObject;
+ CGFloat screenScale = window.screen.scale;
+ CGFloat safeAreaWidth = (window.frame.size.width - window.safeAreaInsets.left - window.safeAreaInsets.right) * screenScale;
+ CGFloat fullScreenWidth = window.frame.size.width * screenScale;
+ CGFloat fullScreenHeight = window.frame.size.height * screenScale;
+ self.resolutionDisplayView.layer.cornerRadius = 10;
+ self.resolutionDisplayView.clipsToBounds = YES;
+ UITapGestureRecognizer *resolutionDisplayViewTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(resolutionDisplayViewTapped:)];
+ [self.resolutionDisplayView addGestureRecognizer:resolutionDisplayViewTap];
+
+ resolutionTable[0] = CGSizeMake(640, 360);
+ resolutionTable[1] = CGSizeMake(1280, 720);
+ resolutionTable[2] = CGSizeMake(1920, 1080);
+ resolutionTable[3] = CGSizeMake(3840, 2160);
+ resolutionTable[4] = CGSizeMake(safeAreaWidth, fullScreenHeight);
+ resolutionTable[5] = CGSizeMake(fullScreenWidth, fullScreenHeight);
+ resolutionTable[6] = CGSizeMake([currentSettings.width integerValue], [currentSettings.height integerValue]); // custom initial value
+
+ // Don't populate the custom entry unless we have a custom resolution
+ if (!isCustomResolution(resolutionTable[6])) {
+ resolutionTable[6] = CGSizeMake(0, 0);
+ }
+
+ NSInteger framerate;
+ switch ([currentSettings.framerate integerValue]) {
+ case 30:
+ framerate = 0;
+ break;
+ default:
+ case 60:
+ framerate = 1;
+ break;
+ case 120:
+ framerate = 2;
+ break;
+ }
+
+ NSInteger resolution = 1;
+ for (int i = 0; i < RESOLUTION_TABLE_SIZE; i++) {
+ if ((int) resolutionTable[i].height == [currentSettings.height intValue]
+ && (int) resolutionTable[i].width == [currentSettings.width intValue]) {
+ resolution = i;
+ break;
+ }
+ }
+
+ // Only show the 120 FPS option if we have a > 60-ish Hz display
+ bool enable120Fps = false;
+ if (@available(iOS 10.3, tvOS 10.3, *)) {
+ if ([UIScreen mainScreen].maximumFramesPerSecond > 62) {
+ enable120Fps = true;
+ }
+ }
+ if (!enable120Fps) {
+ [self.framerateSelector removeSegmentAtIndex:2 animated:NO];
+ }
+
+ // Disable codec selector segments for unsupported codecs
+#if defined(__IPHONE_16_0) || defined(__TVOS_16_0)
+ if (!VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1))
+#endif
+ {
+ [self.codecSelector removeSegmentAtIndex:2 animated:NO];
+ }
+ if (!VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC)) {
+ [self.codecSelector removeSegmentAtIndex:1 animated:NO];
+
+ // Only enable the 4K option for "recent" devices. We'll judge that by whether
+ // they support HEVC decoding (A9 or later).
+ [self.resolutionSelector setEnabled:NO forSegmentAtIndex:3];
+ }
+ switch (currentSettings.preferredCodec) {
+ case CODEC_PREF_AUTO:
+ [self.codecSelector setSelectedSegmentIndex:self.codecSelector.numberOfSegments - 1];
+ break;
+
+ case CODEC_PREF_AV1:
+ [self.codecSelector setSelectedSegmentIndex:2];
+ break;
+
+ case CODEC_PREF_HEVC:
+ [self.codecSelector setSelectedSegmentIndex:1];
+ break;
+
+ case CODEC_PREF_H264:
+ [self.codecSelector setSelectedSegmentIndex:0];
+ break;
+ }
+
+ if (!VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC) || !(AVPlayer.availableHDRModes & AVPlayerHDRModeHDR10)) {
+ [self.hdrSelector removeAllSegments];
+ [self.hdrSelector insertSegmentWithTitle:@"Unsupported on this device" atIndex:0 animated:NO];
+ [self.hdrSelector setEnabled:NO];
+ }
+ else {
+ [self.hdrSelector setSelectedSegmentIndex:currentSettings.enableHdr ? 1 : 0];
+ }
+
+ [self.touchModeSelector setSelectedSegmentIndex:currentSettings.absoluteTouchMode ? 1 : 0];
+ [self.touchModeSelector addTarget:self action:@selector(touchModeChanged) forControlEvents:UIControlEventValueChanged];
+ [self.statsOverlaySelector setSelectedSegmentIndex:currentSettings.statsOverlay ? 1 : 0];
+ [self.btMouseSelector setSelectedSegmentIndex:currentSettings.btMouseSupport ? 1 : 0];
+ [self.optimizeSettingsSelector setSelectedSegmentIndex:currentSettings.optimizeGames ? 1 : 0];
+ [self.framePacingSelector setSelectedSegmentIndex:currentSettings.useFramePacing ? 1 : 0];
+ [self.multiControllerSelector setSelectedSegmentIndex:currentSettings.multiController ? 1 : 0];
+ [self.swapABXYButtonsSelector setSelectedSegmentIndex:currentSettings.swapABXYButtons ? 1 : 0];
+ [self.audioOnPCSelector setSelectedSegmentIndex:currentSettings.playAudioOnPC ? 1 : 0];
+ NSInteger onscreenControls = [currentSettings.onscreenControls integerValue];
+ _lastSelectedResolutionIndex = resolution;
[self.resolutionSelector setSelectedSegmentIndex:resolution];
- [self.resolutionSelector addTarget:self action:@selector(newResolutionFpsChosen) forControlEvents:UIControlEventValueChanged];
+ [self.resolutionSelector addTarget:self action:@selector(newResolutionChosen) forControlEvents:UIControlEventValueChanged];
[self.framerateSelector setSelectedSegmentIndex:framerate];
- [self.framerateSelector addTarget:self action:@selector(newResolutionFpsChosen) forControlEvents:UIControlEventValueChanged];
+ [self.framerateSelector addTarget:self action:@selector(updateBitrate) forControlEvents:UIControlEventValueChanged];
[self.onscreenControlSelector setSelectedSegmentIndex:onscreenControls];
- [self.bitrateSlider setValue:(_bitrate / BITRATE_INTERVAL) animated:YES];
+ [self.onscreenControlSelector setEnabled:!currentSettings.absoluteTouchMode];
+ [self.bitrateSlider setMinimumValue:0];
+ [self.bitrateSlider setMaximumValue:(sizeof(bitrateTable) / sizeof(*bitrateTable)) - 1];
+ [self.bitrateSlider setValue:[self getSliderValueForBitrate:_bitrate] animated:YES];
[self.bitrateSlider addTarget:self action:@selector(bitrateSliderMoved) forControlEvents:UIControlEventValueChanged];
[self updateBitrateText];
+ [self updateResolutionDisplayViewText];
+}
+
+- (void) touchModeChanged {
+ // Disable on-screen controls in absolute touch mode
+ [self.onscreenControlSelector setEnabled:[self.touchModeSelector selectedSegmentIndex] == 0];
+}
+
+- (void) updateBitrate {
+ NSInteger fps = [self getChosenFrameRate];
+ NSInteger width = [self getChosenStreamWidth];
+ NSInteger height = [self getChosenStreamHeight];
+ NSInteger defaultBitrate;
+
+ // This logic is shamelessly stolen from Moonlight Qt:
+ // https://github.com/moonlight-stream/moonlight-qt/blob/master/app/settings/streamingpreferences.cpp
+
+ // Don't scale bitrate linearly beyond 60 FPS. It's definitely not a linear
+ // bitrate increase for frame rate once we get to values that high.
+ float frameRateFactor = (fps <= 60 ? fps : (sqrtf(fps / 60.f) * 60.f)) / 30.f;
+
+ // TODO: Collect some empirical data to see if these defaults make sense.
+ // We're just using the values that the Shield used, as we have for years.
+ struct {
+ NSInteger pixels;
+ int factor;
+ } resTable[] = {
+ { 640 * 360, 1 },
+ { 854 * 480, 2 },
+ { 1280 * 720, 5 },
+ { 1920 * 1080, 10 },
+ { 2560 * 1440, 20 },
+ { 3840 * 2160, 40 },
+ { -1, -1 }
+ };
+
+ // Calculate the resolution factor by linear interpolation of the resolution table
+ float resolutionFactor;
+ NSInteger pixels = width * height;
+ for (int i = 0;; i++) {
+ if (pixels == resTable[i].pixels) {
+ // We can bail immediately for exact matches
+ resolutionFactor = resTable[i].factor;
+ break;
+ }
+ else if (pixels < resTable[i].pixels) {
+ if (i == 0) {
+ // Never go below the lowest resolution entry
+ resolutionFactor = resTable[i].factor;
+ }
+ else {
+ // Interpolate between the entry greater than the chosen resolution (i) and the entry less than the chosen resolution (i-1)
+ resolutionFactor = ((float)(pixels - resTable[i-1].pixels) / (resTable[i].pixels - resTable[i-1].pixels)) * (resTable[i].factor - resTable[i-1].factor) + resTable[i-1].factor;
+ }
+ break;
+ }
+ else if (resTable[i].pixels == -1) {
+ // Never go above the highest resolution entry
+ resolutionFactor = resTable[i-1].factor;
+ break;
+ }
+ }
+
+ defaultBitrate = round(resolutionFactor * frameRateFactor) * 1000;
+ _bitrate = MIN(defaultBitrate, 100000);
+ [self.bitrateSlider setValue:[self getSliderValueForBitrate:_bitrate] animated:YES];
+
+ [self updateBitrateText];
}
-- (void) newResolutionFpsChosen {
- NSInteger frameRate = [self getChosenFrameRate];
- NSInteger resHeight = [self getChosenStreamHeight];
- NSInteger defaultBitrate;
-
- // 1080p60 is 20 Mbps
- if (frameRate == 60 && resHeight == 1080) {
- defaultBitrate = 20000;
+- (void) newResolutionChosen {
+ BOOL lastSegmentSelected = [self.resolutionSelector selectedSegmentIndex] + 1 == [self.resolutionSelector numberOfSegments];
+ if (lastSegmentSelected) {
+ [self promptCustomResolutionDialog];
}
- // 720p60 and 1080p30 are 10 Mbps
- else if (frameRate == 60 || resHeight == 1080) {
- defaultBitrate = 10000;
- }
- // 720p30 is 5 Mbps
else {
- defaultBitrate = 5000;
+ [self updateBitrate];
+ [self updateResolutionDisplayViewText];
+ _lastSelectedResolutionIndex = [self.resolutionSelector selectedSegmentIndex];
}
+}
+
+- (void) promptCustomResolutionDialog {
+ UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Enter Custom Resolution" message:nil preferredStyle:UIAlertControllerStyleAlert];
+
+ [alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) {
+ textField.placeholder = @"Video Width";
+ textField.clearButtonMode = UITextFieldViewModeAlways;
+ textField.borderStyle = UITextBorderStyleRoundedRect;
+ textField.keyboardType = UIKeyboardTypeNumberPad;
+
+ if (resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX].width == 0) {
+ textField.text = @"";
+ }
+ else {
+ textField.text = [NSString stringWithFormat:@"%d", (int) resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX].width];
+ }
+ }];
+
+ [alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) {
+ textField.placeholder = @"Video Height";
+ textField.clearButtonMode = UITextFieldViewModeAlways;
+ textField.borderStyle = UITextBorderStyleRoundedRect;
+ textField.keyboardType = UIKeyboardTypeNumberPad;
+
+ if (resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX].height == 0) {
+ textField.text = @"";
+ }
+ else {
+ textField.text = [NSString stringWithFormat:@"%d", (int) resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX].height];
+ }
+ }];
+
+ [alertController addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) {
+ NSArray * textfields = alertController.textFields;
+ UITextField *widthField = textfields[0];
+ UITextField *heightField = textfields[1];
+
+ long width = [widthField.text integerValue];
+ long height = [heightField.text integerValue];
+ if (width <= 0 || height <= 0) {
+ // Restore the previous selection
+ [self.resolutionSelector setSelectedSegmentIndex:self->_lastSelectedResolutionIndex];
+ return;
+ }
+
+ // H.264 maximum
+ int maxResolutionDimension = 4096;
+ if (@available(iOS 11.0, tvOS 11.0, *)) {
+ if (VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC)) {
+ // HEVC maximum
+ maxResolutionDimension = 8192;
+ }
+ }
+
+ // Cap to maximum valid dimensions
+ width = MIN(width, maxResolutionDimension);
+ height = MIN(height, maxResolutionDimension);
+
+ // Cap to minimum valid dimensions
+ width = MAX(width, 256);
+ height = MAX(height, 256);
+
+ resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX] = CGSizeMake(width, height);
+ [self updateBitrate];
+ [self updateResolutionDisplayViewText];
+ self->_lastSelectedResolutionIndex = [self.resolutionSelector selectedSegmentIndex];
+
+ UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Custom Resolution Selected" message: @"Custom resolutions are not officially supported by GeForce Experience, so it will not set your host display resolution. You will need to set it manually while in game.\n\nResolutions that are not supported by your client or host PC may cause streaming errors." preferredStyle:UIAlertControllerStyleAlert];
+ [alertController addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
+ [self presentViewController:alertController animated:YES completion:nil];
+ }]];
+
+ [alertController addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) {
+ // Restore the previous selection
+ [self.resolutionSelector setSelectedSegmentIndex:self->_lastSelectedResolutionIndex];
+ }]];
+
+ [self presentViewController:alertController animated:YES completion:nil];
+}
+
+- (void)resolutionDisplayViewTapped:(UITapGestureRecognizer *)sender {
+ NSURL *url = [NSURL URLWithString:@"https://moonlight-stream.org/custom-resolution"];
+ if ([[UIApplication sharedApplication] canOpenURL:url]) {
+ [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
+ }
+}
+
+- (void) updateResolutionDisplayViewText {
+ NSInteger width = [self getChosenStreamWidth];
+ NSInteger height = [self getChosenStreamHeight];
+ CGFloat viewFrameWidth = self.resolutionDisplayView.frame.size.width;
+ CGFloat viewFrameHeight = self.resolutionDisplayView.frame.size.height;
+ CGFloat padding = 10;
+ CGFloat fontSize = [UIFont smallSystemFontSize];
- _bitrate = defaultBitrate;
- [self.bitrateSlider setValue:defaultBitrate / BITRATE_INTERVAL animated:YES];
-
- [self updateBitrateText];
+ for (UIView *subview in self.resolutionDisplayView.subviews) {
+ [subview removeFromSuperview];
+ }
+ UILabel *label1 = [[UILabel alloc] init];
+ label1.text = @"Set PC/Game resolution: ";
+ label1.font = [UIFont systemFontOfSize:fontSize];
+ [label1 sizeToFit];
+ label1.frame = CGRectMake(padding, (viewFrameHeight - label1.frame.size.height) / 2, label1.frame.size.width, label1.frame.size.height);
+
+ UILabel *label2 = [[UILabel alloc] init];
+ label2.text = [NSString stringWithFormat:@"%ld x %ld", (long)width, (long)height];
+ [label2 sizeToFit];
+ label2.frame = CGRectMake(viewFrameWidth - label2.frame.size.width - padding, (viewFrameHeight - label2.frame.size.height) / 2, label2.frame.size.width, label2.frame.size.height);
+
+ [self.resolutionDisplayView addSubview:label1];
+ [self.resolutionDisplayView addSubview:label2];
}
- (void) bitrateSliderMoved {
- _bitrate = BITRATE_INTERVAL * (int)self.bitrateSlider.value;
+ assert(self.bitrateSlider.value < (sizeof(bitrateTable) / sizeof(*bitrateTable)));
+ _bitrate = bitrateTable[(int)self.bitrateSlider.value];
[self updateBitrateText];
}
@@ -103,15 +468,58 @@ static NSString* bitrateFormat = @"Bitrate: %.1f Mbps";
}
- (NSInteger) getChosenFrameRate {
- return [self.framerateSelector selectedSegmentIndex] == 0 ? 30 : 60;
+ switch ([self.framerateSelector selectedSegmentIndex]) {
+ case 0:
+ return 30;
+ case 1:
+ return 60;
+ case 2:
+ return 120;
+ default:
+ abort();
+ }
+}
+
+- (uint32_t) getChosenCodecPreference {
+ // Auto is always the last segment
+ if (self.codecSelector.selectedSegmentIndex == self.codecSelector.numberOfSegments - 1) {
+ return CODEC_PREF_AUTO;
+ }
+ else {
+ switch (self.codecSelector.selectedSegmentIndex) {
+ case 0:
+ return CODEC_PREF_H264;
+
+ case 1:
+ return CODEC_PREF_HEVC;
+
+ case 2:
+ return CODEC_PREF_AV1;
+
+ default:
+ abort();
+ }
+ }
}
- (NSInteger) getChosenStreamHeight {
- return [self.resolutionSelector selectedSegmentIndex] == 0 ? 720 : 1080;
+ // because the 4k resolution can be removed
+ BOOL lastSegmentSelected = [self.resolutionSelector selectedSegmentIndex] + 1 == [self.resolutionSelector numberOfSegments];
+ if (lastSegmentSelected) {
+ return resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX].height;
+ }
+
+ return resolutionTable[[self.resolutionSelector selectedSegmentIndex]].height;
}
- (NSInteger) getChosenStreamWidth {
- return [self getChosenStreamHeight] == 720 ? 1280 : 1920;
+ // because the 4k resolution can be removed
+ BOOL lastSegmentSelected = [self.resolutionSelector selectedSegmentIndex] + 1 == [self.resolutionSelector numberOfSegments];
+ if (lastSegmentSelected) {
+ return resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX].width;
+ }
+
+ return resolutionTable[[self.resolutionSelector selectedSegmentIndex]].width;
}
- (void) saveSettings {
@@ -120,7 +528,32 @@ static NSString* bitrateFormat = @"Bitrate: %.1f Mbps";
NSInteger height = [self getChosenStreamHeight];
NSInteger width = [self getChosenStreamWidth];
NSInteger onscreenControls = [self.onscreenControlSelector selectedSegmentIndex];
- [dataMan saveSettingsWithBitrate:_bitrate framerate:framerate height:height width:width onscreenControls:onscreenControls];
+ BOOL optimizeGames = [self.optimizeSettingsSelector selectedSegmentIndex] == 1;
+ BOOL multiController = [self.multiControllerSelector selectedSegmentIndex] == 1;
+ BOOL swapABXYButtons = [self.swapABXYButtonsSelector selectedSegmentIndex] == 1;
+ BOOL audioOnPC = [self.audioOnPCSelector selectedSegmentIndex] == 1;
+ uint32_t preferredCodec = [self getChosenCodecPreference];
+ BOOL btMouseSupport = [self.btMouseSelector selectedSegmentIndex] == 1;
+ BOOL useFramePacing = [self.framePacingSelector selectedSegmentIndex] == 1;
+ BOOL absoluteTouchMode = [self.touchModeSelector selectedSegmentIndex] == 1;
+ BOOL statsOverlay = [self.statsOverlaySelector selectedSegmentIndex] == 1;
+ BOOL enableHdr = [self.hdrSelector selectedSegmentIndex] == 1;
+ [dataMan saveSettingsWithBitrate:_bitrate
+ framerate:framerate
+ height:height
+ width:width
+ audioConfig:2 // Stereo
+ onscreenControls:onscreenControls
+ optimizeGames:optimizeGames
+ multiController:multiController
+ swapABXYButtons:swapABXYButtons
+ audioOnPC:audioOnPC
+ preferredCodec:preferredCodec
+ useFramePacing:useFramePacing
+ enableHdr:enableHdr
+ btMouseSupport:btMouseSupport
+ absoluteTouchMode:absoluteTouchMode
+ statsOverlay:statsOverlay];
}
- (void)didReceiveMemoryWarning {
diff --git a/Limelight/ViewControllers/StreamFrameViewController.h b/Limelight/ViewControllers/StreamFrameViewController.h
index f9bf163..dc9e5a8 100644
--- a/Limelight/ViewControllers/StreamFrameViewController.h
+++ b/Limelight/ViewControllers/StreamFrameViewController.h
@@ -12,9 +12,15 @@
#import
-@interface StreamFrameViewController : UIViewController
-@property (strong, nonatomic) IBOutlet UILabel *stageLabel;
-@property (strong, nonatomic) IBOutlet UIActivityIndicatorView *spinner;
+#if TARGET_OS_TV
+@import GameController;
+
+@interface StreamFrameViewController : GCEventViewController
+#else
+@interface StreamFrameViewController : UIViewController
+#endif
@property (nonatomic) StreamConfiguration* streamConfig;
+-(void)updatePreferredDisplayMode:(BOOL)streamActive;
+
@end
diff --git a/Limelight/ViewControllers/StreamFrameViewController.m b/Limelight/ViewControllers/StreamFrameViewController.m
index b1230f2..a4c8f34 100644
--- a/Limelight/ViewControllers/StreamFrameViewController.m
+++ b/Limelight/ViewControllers/StreamFrameViewController.m
@@ -11,39 +11,148 @@
#import "VideoDecoderRenderer.h"
#import "StreamManager.h"
#import "ControllerSupport.h"
+#import "DataManager.h"
#include
#include
#include
+#include
+
+#if TARGET_OS_TV
+#import
+#import
+#import
+#endif
+
+@interface AVDisplayCriteria()
+@property(readonly) int videoDynamicRange;
+@property(readonly, nonatomic) float refreshRate;
+- (id)initWithRefreshRate:(float)arg1 videoDynamicRange:(int)arg2;
+@end
@implementation StreamFrameViewController {
ControllerSupport *_controllerSupport;
StreamManager *_streamMan;
+ TemporarySettings *_settings;
+ NSTimer *_inactivityTimer;
+ NSTimer *_statsUpdateTimer;
+ UITapGestureRecognizer *_menuTapGestureRecognizer;
+ UITapGestureRecognizer *_menuDoubleTapGestureRecognizer;
+ UITapGestureRecognizer *_playPauseTapGestureRecognizer;
+ UITextView *_overlayView;
+ UILabel *_stageLabel;
+ UILabel *_tipLabel;
+ UIActivityIndicatorView *_spinner;
+ StreamView *_streamView;
+ UIScrollView *_scrollView;
+ BOOL _userIsInteracting;
+ CGSize _keyboardSize;
+
+#if !TARGET_OS_TV
+ UIScreenEdgePanGestureRecognizer *_exitSwipeRecognizer;
+#endif
}
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
+#if !TARGET_OS_TV
[[self revealViewController] setPrimaryViewController:self];
+#endif
}
+#if TARGET_OS_TV
+- (void)controllerPauseButtonPressed:(id)sender { }
+- (void)controllerPauseButtonDoublePressed:(id)sender {
+ Log(LOG_I, @"Menu double-pressed -- backing out of stream");
+ [self returnToMainFrame];
+}
+- (void)controllerPlayPauseButtonPressed:(id)sender {
+ Log(LOG_I, @"Play/Pause button pressed -- backing out of stream");
+ [self returnToMainFrame];
+}
+#endif
+
+
- (void)viewDidLoad
{
[super viewDidLoad];
[self.navigationController setNavigationBarHidden:YES animated:YES];
- [self.stageLabel setText:@"Starting App"];
- [self.stageLabel sizeToFit];
- self.stageLabel.center = CGPointMake(self.view.frame.size.width / 2, self.view.frame.size.height / 2);
- self.spinner.center = CGPointMake(self.view.frame.size.width / 2, self.view.frame.size.height / 2 - self.stageLabel.frame.size.height - self.spinner.frame.size.height);
[UIApplication sharedApplication].idleTimerDisabled = YES;
- _controllerSupport = [[ControllerSupport alloc] init];
+ _settings = [[[DataManager alloc] init] getSettings];
+
+ _stageLabel = [[UILabel alloc] init];
+ [_stageLabel setUserInteractionEnabled:NO];
+ [_stageLabel setText:[NSString stringWithFormat:@"Starting %@...", self.streamConfig.appName]];
+ [_stageLabel sizeToFit];
+ _stageLabel.textAlignment = NSTextAlignmentCenter;
+ _stageLabel.textColor = [UIColor whiteColor];
+ _stageLabel.center = CGPointMake(self.view.frame.size.width / 2, self.view.frame.size.height / 2);
+
+ _spinner = [[UIActivityIndicatorView alloc] init];
+ [_spinner setUserInteractionEnabled:NO];
+#if TARGET_OS_TV
+ [_spinner setActivityIndicatorViewStyle:UIActivityIndicatorViewStyleWhiteLarge];
+#else
+ [_spinner setActivityIndicatorViewStyle:UIActivityIndicatorViewStyleWhite];
+#endif
+ [_spinner sizeToFit];
+ [_spinner startAnimating];
+ _spinner.center = CGPointMake(self.view.frame.size.width / 2, self.view.frame.size.height / 2 - _stageLabel.frame.size.height - _spinner.frame.size.height);
+
+ _controllerSupport = [[ControllerSupport alloc] initWithConfig:self.streamConfig delegate:self];
+ _inactivityTimer = nil;
+
+ _streamView = [[StreamView alloc] initWithFrame:self.view.frame];
+ [_streamView setupStreamView:_controllerSupport interactionDelegate:self config:self.streamConfig];
+
+#if TARGET_OS_TV
+ if (!_menuTapGestureRecognizer || !_menuDoubleTapGestureRecognizer || !_playPauseTapGestureRecognizer) {
+ _menuTapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(controllerPauseButtonPressed:)];
+ _menuTapGestureRecognizer.allowedPressTypes = @[@(UIPressTypeMenu)];
+
+ _playPauseTapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(controllerPlayPauseButtonPressed:)];
+ _playPauseTapGestureRecognizer.allowedPressTypes = @[@(UIPressTypePlayPause)];
+
+ _menuDoubleTapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(controllerPauseButtonDoublePressed:)];
+ _menuDoubleTapGestureRecognizer.numberOfTapsRequired = 2;
+ [_menuTapGestureRecognizer requireGestureRecognizerToFail:_menuDoubleTapGestureRecognizer];
+ _menuDoubleTapGestureRecognizer.allowedPressTypes = @[@(UIPressTypeMenu)];
+ }
+
+ [self.view addGestureRecognizer:_menuTapGestureRecognizer];
+ [self.view addGestureRecognizer:_menuDoubleTapGestureRecognizer];
+ [self.view addGestureRecognizer:_playPauseTapGestureRecognizer];
+
+#else
+ _exitSwipeRecognizer = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(edgeSwiped)];
+ _exitSwipeRecognizer.edges = UIRectEdgeLeft;
+ _exitSwipeRecognizer.delaysTouchesBegan = NO;
+ _exitSwipeRecognizer.delaysTouchesEnded = NO;
+
+ [self.view addGestureRecognizer:_exitSwipeRecognizer];
+#endif
+
+ _tipLabel = [[UILabel alloc] init];
+ [_tipLabel setUserInteractionEnabled:NO];
+
+#if TARGET_OS_TV
+ [_tipLabel setText:@"Tip: Tap the Play/Pause button on the Apple TV Remote to disconnect from your PC"];
+#else
+ [_tipLabel setText:@"Tip: Swipe from the left edge to disconnect from your PC"];
+#endif
+
+ [_tipLabel sizeToFit];
+ _tipLabel.textColor = [UIColor whiteColor];
+ _tipLabel.textAlignment = NSTextAlignmentCenter;
+ _tipLabel.center = CGPointMake(self.view.frame.size.width / 2, self.view.frame.size.height * 0.9);
_streamMan = [[StreamManager alloc] initWithConfig:self.streamConfig
- renderView:self.view
+ renderView:_streamView
connectionCallbacks:self];
NSOperationQueue* opQueue = [[NSOperationQueue alloc] init];
[opQueue addOperation:_streamMan];
@@ -52,40 +161,307 @@
selector:@selector(applicationWillResignActive:)
name:UIApplicationWillResignActiveNotification
object:nil];
+
+ [[NSNotificationCenter defaultCenter] addObserver: self
+ selector: @selector(applicationDidBecomeActive:)
+ name: UIApplicationDidBecomeActiveNotification
+ object: nil];
+
+ [[NSNotificationCenter defaultCenter] addObserver: self
+ selector: @selector(applicationDidEnterBackground:)
+ name: UIApplicationDidEnterBackgroundNotification
+ object: nil];
+
+#if 0
+ // FIXME: This doesn't work reliably on iPad for some reason. Showing and hiding the keyboard
+ // several times in a row will not correctly restore the state of the UIScrollView.
+ [[NSNotificationCenter defaultCenter] addObserver: self
+ selector: @selector(keyboardWillShow:)
+ name: UIKeyboardWillShowNotification
+ object: nil];
+
+ [[NSNotificationCenter defaultCenter] addObserver: self
+ selector: @selector(keyboardWillHide:)
+ name: UIKeyboardWillHideNotification
+ object: nil];
+#endif
+
+ // Only enable scroll and zoom in absolute touch mode
+ if (_settings.absoluteTouchMode) {
+ _scrollView = [[UIScrollView alloc] initWithFrame:self.view.frame];
+#if !TARGET_OS_TV
+ [_scrollView.panGestureRecognizer setMinimumNumberOfTouches:2];
+#endif
+ [_scrollView setShowsHorizontalScrollIndicator:NO];
+ [_scrollView setShowsVerticalScrollIndicator:NO];
+ [_scrollView setDelegate:self];
+ [_scrollView setMaximumZoomScale:10.0f];
+
+ // Add StreamView inside a UIScrollView for absolute mode
+ [_scrollView addSubview:_streamView];
+ [self.view addSubview:_scrollView];
+ }
+ else {
+ // Add StreamView directly in relative mode
+ [self.view addSubview:_streamView];
+ }
+
+ [self.view addSubview:_stageLabel];
+ [self.view addSubview:_spinner];
+ [self.view addSubview:_tipLabel];
+}
+
+- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView {
+ return _streamView;
+}
+
+- (void)willMoveToParentViewController:(UIViewController *)parent {
+ // Only cleanup when we're being destroyed
+ if (parent == nil) {
+ [_controllerSupport cleanup];
+ [UIApplication sharedApplication].idleTimerDisabled = NO;
+ [_streamMan stopStream];
+ if (_inactivityTimer != nil) {
+ [_inactivityTimer invalidate];
+ _inactivityTimer = nil;
+ }
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+ }
+}
+
+#if 0
+- (void)keyboardWillShow:(NSNotification *)notification {
+ _keyboardSize = [[[notification userInfo] objectForKey:UIKeyboardFrameBeginUserInfoKey] CGRectValue].size;
+
+ [UIView animateWithDuration:0.3 animations:^{
+ CGRect frame = self->_scrollView.frame;
+ frame.size.height -= self->_keyboardSize.height;
+ self->_scrollView.frame = frame;
+ }];
+}
+
+-(void)keyboardWillHide:(NSNotification *)notification {
+ // NOTE: UIKeyboardFrameEndUserInfoKey returns a different keyboard size
+ // than UIKeyboardFrameBeginUserInfoKey, so it's unsuitable for use here
+ // to undo the changes made by keyboardWillShow.
+
+ [UIView animateWithDuration:0.3 animations:^{
+ CGRect frame = self->_scrollView.frame;
+ frame.size.height += self->_keyboardSize.height;
+ self->_scrollView.frame = frame;
+ }];
+}
+#endif
+
+- (void)updateStatsOverlay {
+ NSString* overlayText = [self->_streamMan getStatsOverlayText];
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self updateOverlayText:overlayText];
+ });
+}
+
+- (void)updateOverlayText:(NSString*)text {
+ if (_overlayView == nil) {
+ _overlayView = [[UITextView alloc] init];
+#if !TARGET_OS_TV
+ [_overlayView setEditable:NO];
+#endif
+ [_overlayView setUserInteractionEnabled:NO];
+ [_overlayView setSelectable:NO];
+ [_overlayView setScrollEnabled:NO];
+
+ // HACK: If not using stats overlay, center the text
+ if (_statsUpdateTimer == nil) {
+ [_overlayView setTextAlignment:NSTextAlignmentCenter];
+ }
+
+ [_overlayView setTextColor:[UIColor lightGrayColor]];
+ [_overlayView setBackgroundColor:[UIColor blackColor]];
+#if TARGET_OS_TV
+ [_overlayView setFont:[UIFont systemFontOfSize:24]];
+#else
+ [_overlayView setFont:[UIFont systemFontOfSize:12]];
+#endif
+ [_overlayView setAlpha:0.5];
+ [self.view addSubview:_overlayView];
+ }
+
+ if (text != nil) {
+ // We set our bounds to the maximum width in order to work around a bug where
+ // sizeToFit interacts badly with the UITextView's line breaks, causing the
+ // width to get smaller and smaller each time as more line breaks are inserted.
+ [_overlayView setBounds:CGRectMake(self.view.frame.origin.x,
+ _overlayView.frame.origin.y,
+ self.view.frame.size.width,
+ _overlayView.frame.size.height)];
+ [_overlayView setText:text];
+ [_overlayView sizeToFit];
+ [_overlayView setCenter:CGPointMake(self.view.frame.size.width / 2, _overlayView.frame.size.height / 2)];
+ [_overlayView setHidden:NO];
+ }
+ else {
+ [_overlayView setHidden:YES];
+ }
}
- (void) returnToMainFrame {
- [_controllerSupport cleanup];
+ // Reset display mode back to default
+ [self updatePreferredDisplayMode:NO];
+
+ [_statsUpdateTimer invalidate];
+ _statsUpdateTimer = nil;
+
[self.navigationController popToRootViewControllerAnimated:YES];
}
+// This will fire if the user opens control center or gets a low battery message
- (void)applicationWillResignActive:(NSNotification *)notification {
- [_streamMan stopStream];
+ if (_inactivityTimer != nil) {
+ [_inactivityTimer invalidate];
+ }
+
+#if !TARGET_OS_TV
+ // Terminate the stream if the app is inactive for 60 seconds
+ Log(LOG_I, @"Starting inactivity termination timer");
+ _inactivityTimer = [NSTimer scheduledTimerWithTimeInterval:60
+ target:self
+ selector:@selector(inactiveTimerExpired:)
+ userInfo:nil
+ repeats:NO];
+#endif
+}
+
+- (void)inactiveTimerExpired:(NSTimer*)timer {
+ Log(LOG_I, @"Terminating stream after inactivity");
+
+ [self returnToMainFrame];
+
+ _inactivityTimer = nil;
+}
+
+- (void)applicationDidBecomeActive:(NSNotification *)notification {
+ // Stop the background timer, since we're foregrounded again
+ if (_inactivityTimer != nil) {
+ Log(LOG_I, @"Stopping inactivity timer after becoming active again");
+ [_inactivityTimer invalidate];
+ _inactivityTimer = nil;
+ }
+}
+
+// This fires when the home button is pressed
+- (void)applicationDidEnterBackground:(UIApplication *)application {
+ Log(LOG_I, @"Terminating stream immediately for backgrounding");
+
+ if (_inactivityTimer != nil) {
+ [_inactivityTimer invalidate];
+ _inactivityTimer = nil;
+ }
+
[self returnToMainFrame];
}
- (void)edgeSwiped {
- Log(LOG_D, @"User swiped to end stream");
- [_streamMan stopStream];
+ Log(LOG_I, @"User swiped to end stream");
+
[self returnToMainFrame];
}
- (void) connectionStarted {
Log(LOG_I, @"Connection started");
dispatch_async(dispatch_get_main_queue(), ^{
- [self.spinner stopAnimating];
- [self.stageLabel setText:@"Waiting for first frame..."];
- [self.stageLabel sizeToFit];
- [(StreamView*)self.view setupOnScreenControls: _controllerSupport swipeDelegate:self];
+ // Leave the spinner spinning until it's obscured by
+ // the first frame of video.
+ self->_stageLabel.hidden = YES;
+ self->_tipLabel.hidden = YES;
+
+ [self->_streamView showOnScreenControls];
+
+ [self->_controllerSupport connectionEstablished];
+
+ if (self->_settings.statsOverlay) {
+ self->_statsUpdateTimer = [NSTimer scheduledTimerWithTimeInterval:1.0f
+ target:self
+ selector:@selector(updateStatsOverlay)
+ userInfo:nil
+ repeats:YES];
+ }
});
}
-- (void)connectionTerminated:(long)errorCode {
- Log(LOG_I, @"Connection terminated: %ld", errorCode);
+- (void)connectionTerminated:(int)errorCode {
+ Log(LOG_I, @"Connection terminated: %d", errorCode);
+
+ unsigned int portFlags = LiGetPortFlagsFromTerminationErrorCode(errorCode);
+ unsigned int portTestResults = LiTestClientConnectivity(CONN_TEST_SERVER, 443, portFlags);
dispatch_async(dispatch_get_main_queue(), ^{
- UIAlertController* conTermAlert = [UIAlertController alertControllerWithTitle:@"Connection Terminated" message:@"The connection was terminated" preferredStyle:UIAlertControllerStyleAlert];
- [conTermAlert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDestructive handler:^(UIAlertAction* action){
+ // Allow the display to go to sleep now
+ [UIApplication sharedApplication].idleTimerDisabled = NO;
+
+ NSString* title;
+ NSString* message;
+
+ if (portTestResults != ML_TEST_RESULT_INCONCLUSIVE && portTestResults != 0) {
+ title = @"Connection Error";
+ message = @"Your device's network connection is blocking Moonlight. Streaming may not work while connected to this network.";
+ }
+ else {
+ switch (errorCode) {
+ case ML_ERROR_GRACEFUL_TERMINATION:
+ [self returnToMainFrame];
+ return;
+
+ case ML_ERROR_NO_VIDEO_TRAFFIC:
+ title = @"Connection Error";
+ message = @"No video received from host.";
+ if (portFlags != 0) {
+ char failingPorts[256];
+ LiStringifyPortFlags(portFlags, "\n", failingPorts, sizeof(failingPorts));
+ message = [message stringByAppendingString:[NSString stringWithFormat:@"\n\nCheck your firewall and port forwarding rules for port(s):\n%s", failingPorts]];
+ }
+ break;
+
+ case ML_ERROR_NO_VIDEO_FRAME:
+ title = @"Connection Error";
+ message = @"Your network connection isn't performing well. Reduce your video bitrate setting or try a faster connection.";
+ break;
+
+ case ML_ERROR_UNEXPECTED_EARLY_TERMINATION:
+ case ML_ERROR_PROTECTED_CONTENT:
+ title = @"Connection Error";
+ message = @"Something went wrong on your host PC when starting the stream.\n\nMake sure you don't have any DRM-protected content open on your host PC. You can also try restarting your host PC.\n\nIf the issue persists, try reinstalling your GPU drivers and GeForce Experience.";
+ break;
+
+ case ML_ERROR_FRAME_CONVERSION:
+ title = @"Connection Error";
+ message = @"The host PC reported a fatal video encoding error.\n\nTry disabling HDR mode, changing the streaming resolution, or changing your host PC's display resolution.";
+ break;
+
+ default:
+ {
+ NSString* errorString;
+ if (abs(errorCode) > 1000) {
+ // We'll assume large errors are hex values
+ errorString = [NSString stringWithFormat:@"%08X", (uint32_t)errorCode];
+ }
+ else {
+ // Smaller values will just be printed as decimal (probably errno.h values)
+ errorString = [NSString stringWithFormat:@"%d", errorCode];
+ }
+
+ title = @"Connection Terminated";
+ message = [NSString stringWithFormat: @"The connection was terminated\n\nError code: %@", errorString];
+ break;
+ }
+ }
+ }
+
+ UIAlertController* conTermAlert = [UIAlertController alertControllerWithTitle:title
+ message:message
+ preferredStyle:UIAlertControllerStyleAlert];
+ [Utils addHelpOptionToDialog:conTermAlert];
+ [conTermAlert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
[self returnToMainFrame];
}]];
[self presentViewController:conTermAlert animated:YES completion:nil];
@@ -99,24 +475,39 @@
dispatch_async(dispatch_get_main_queue(), ^{
NSString* lowerCase = [NSString stringWithFormat:@"%s in progress...", stageName];
NSString* titleCase = [[[lowerCase substringToIndex:1] uppercaseString] stringByAppendingString:[lowerCase substringFromIndex:1]];
- [self.stageLabel setText:titleCase];
- [self.stageLabel sizeToFit];
- self.stageLabel.center = CGPointMake(self.view.frame.size.width / 2, self.stageLabel.center.y);
+ [self->_stageLabel setText:titleCase];
+ [self->_stageLabel sizeToFit];
+ self->_stageLabel.center = CGPointMake(self.view.frame.size.width / 2, self->_stageLabel.center.y);
});
}
- (void) stageComplete:(const char*)stageName {
}
-- (void) stageFailed:(const char*)stageName withError:(long)errorCode {
- Log(LOG_I, @"Stage %s failed: %ld", stageName, errorCode);
+- (void) stageFailed:(const char*)stageName withError:(int)errorCode portTestFlags:(int)portTestFlags {
+ Log(LOG_I, @"Stage %s failed: %d", stageName, errorCode);
+
+ unsigned int portTestResults = LiTestClientConnectivity(CONN_TEST_SERVER, 443, portTestFlags);
dispatch_async(dispatch_get_main_queue(), ^{
+ // Allow the display to go to sleep now
+ [UIApplication sharedApplication].idleTimerDisabled = NO;
+
+ NSString* message = [NSString stringWithFormat:@"%s failed with error %d", stageName, errorCode];
+ if (portTestFlags != 0) {
+ char failingPorts[256];
+ LiStringifyPortFlags(portTestFlags, "\n", failingPorts, sizeof(failingPorts));
+ message = [message stringByAppendingString:[NSString stringWithFormat:@"\n\nCheck your firewall and port forwarding rules for port(s):\n%s", failingPorts]];
+ }
+ if (portTestResults != ML_TEST_RESULT_INCONCLUSIVE && portTestResults != 0) {
+ message = [message stringByAppendingString:@"\n\nYour device's network connection is blocking Moonlight. Streaming may not work while connected to this network."];
+ }
+
UIAlertController* alert = [UIAlertController alertControllerWithTitle:@"Connection Failed"
- message:[NSString stringWithFormat:@"%s failed with error %ld",
- stageName, errorCode]
+ message:message
preferredStyle:UIAlertControllerStyleAlert];
- [alert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDestructive handler:^(UIAlertAction* action){
+ [Utils addHelpOptionToDialog:alert];
+ [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
[self returnToMainFrame];
}]];
[self presentViewController:alert animated:YES completion:nil];
@@ -129,22 +520,109 @@
Log(LOG_I, @"Launch failed: %@", message);
dispatch_async(dispatch_get_main_queue(), ^{
- UIAlertController* alert = [UIAlertController alertControllerWithTitle:@"Connection Failed"
+ // Allow the display to go to sleep now
+ [UIApplication sharedApplication].idleTimerDisabled = NO;
+
+ UIAlertController* alert = [UIAlertController alertControllerWithTitle:@"Connection Error"
message:message
preferredStyle:UIAlertControllerStyleAlert];
- [alert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDestructive handler:^(UIAlertAction* action){
+ [Utils addHelpOptionToDialog:alert];
+ [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){
[self returnToMainFrame];
}]];
[self presentViewController:alert animated:YES completion:nil];
});
}
-- (void) displayMessage:(const char*)message {
- Log(LOG_I, @"Display message: %s", message);
+- (void)rumble:(unsigned short)controllerNumber lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor {
+ Log(LOG_I, @"Rumble on gamepad %d: %04x %04x", controllerNumber, lowFreqMotor, highFreqMotor);
+
+ [_controllerSupport rumble:controllerNumber lowFreqMotor:lowFreqMotor highFreqMotor:highFreqMotor];
}
-- (void) displayTransientMessage:(const char*)message {
- Log(LOG_I, @"Display transient message: %s", message);
+- (void) rumbleTriggers:(uint16_t)controllerNumber leftTrigger:(uint16_t)leftTrigger rightTrigger:(uint16_t)rightTrigger {
+ Log(LOG_I, @"Trigger rumble on gamepad %d: %04x %04x", controllerNumber, leftTrigger, rightTrigger);
+
+ [_controllerSupport rumbleTriggers:controllerNumber leftTrigger:leftTrigger rightTrigger:rightTrigger];
+}
+
+- (void) setMotionEventState:(uint16_t)controllerNumber motionType:(uint8_t)motionType reportRateHz:(uint16_t)reportRateHz {
+ Log(LOG_I, @"Set motion state on gamepad %d: %02x %u Hz", controllerNumber, motionType, reportRateHz);
+
+ [_controllerSupport setMotionEventState:controllerNumber motionType:motionType reportRateHz:reportRateHz];
+}
+
+- (void) setControllerLed:(uint16_t)controllerNumber r:(uint8_t)r g:(uint8_t)g b:(uint8_t)b {
+ Log(LOG_I, @"Set controller LED on gamepad %d: l%02x%02x%02x", controllerNumber, r, g, b);
+
+ [_controllerSupport setControllerLed:controllerNumber r:r g:g b:b];
+}
+
+- (void)connectionStatusUpdate:(int)status {
+ Log(LOG_W, @"Connection status update: %d", status);
+
+ // The stats overlay takes precedence over these warnings
+ if (_statsUpdateTimer != nil) {
+ return;
+ }
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ switch (status) {
+ case CONN_STATUS_OKAY:
+ [self updateOverlayText:nil];
+ break;
+
+ case CONN_STATUS_POOR:
+ if (self->_streamConfig.bitRate > 5000) {
+ [self updateOverlayText:@"Slow connection to PC\nReduce your bitrate"];
+ }
+ else {
+ [self updateOverlayText:@"Poor connection to PC"];
+ }
+ break;
+ }
+ });
+}
+
+- (void) updatePreferredDisplayMode:(BOOL)streamActive {
+#if TARGET_OS_TV
+ if (@available(tvOS 11.2, *)) {
+ UIWindow* window = [[[UIApplication sharedApplication] delegate] window];
+ AVDisplayManager* displayManager = [window avDisplayManager];
+
+ // This logic comes from Kodi and MrMC
+ if (streamActive) {
+ int dynamicRange;
+
+ if (LiGetCurrentHostDisplayHdrMode()) {
+ dynamicRange = 2; // HDR10
+ }
+ else {
+ dynamicRange = 0; // SDR
+ }
+
+ AVDisplayCriteria* displayCriteria = [[AVDisplayCriteria alloc] initWithRefreshRate:[_settings.framerate floatValue]
+ videoDynamicRange:dynamicRange];
+ displayManager.preferredDisplayCriteria = displayCriteria;
+ }
+ else {
+ // Switch back to the default display mode
+ displayManager.preferredDisplayCriteria = nil;
+ }
+ }
+#endif
+}
+
+- (void) setHdrMode:(bool)enabled {
+ Log(LOG_I, @"HDR is now: %s", enabled ? "active" : "inactive");
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self updatePreferredDisplayMode:YES];
+ });
+}
+
+- (void) videoContentShown {
+ [_spinner stopAnimating];
+ [self.view setBackgroundColor:[UIColor blackColor]];
}
- (void)didReceiveMemoryWarning
@@ -153,12 +631,84 @@
// Dispose of any resources that can be recreated.
}
+- (void)gamepadPresenceChanged {
+#if !TARGET_OS_TV
+ if (@available(iOS 11.0, *)) {
+ [self setNeedsUpdateOfHomeIndicatorAutoHidden];
+ }
+#endif
+}
+
+- (void)mousePresenceChanged {
+#if !TARGET_OS_TV
+ if (@available(iOS 14.0, *)) {
+ [self setNeedsUpdateOfPrefersPointerLocked];
+ }
+#endif
+}
+
+- (void) streamExitRequested {
+ Log(LOG_I, @"Gamepad combo requested stream exit");
+
+ [self returnToMainFrame];
+}
+
+- (void)userInteractionBegan {
+ // Disable hiding home bar when user is interacting.
+ // iOS will force it to be shown anyway, but it will
+ // also discard our edges deferring system gestures unless
+ // we willingly give up home bar hiding preference.
+ _userIsInteracting = YES;
+#if !TARGET_OS_TV
+ if (@available(iOS 11.0, *)) {
+ [self setNeedsUpdateOfHomeIndicatorAutoHidden];
+ }
+#endif
+}
+
+- (void)userInteractionEnded {
+ // Enable home bar hiding again if conditions allow
+ _userIsInteracting = NO;
+#if !TARGET_OS_TV
+ if (@available(iOS 11.0, *)) {
+ [self setNeedsUpdateOfHomeIndicatorAutoHidden];
+ }
+#endif
+}
+
+#if !TARGET_OS_TV
// Require a confirmation when streaming to activate a system gesture
- (UIRectEdge)preferredScreenEdgesDeferringSystemGestures {
return UIRectEdgeAll;
}
+- (BOOL)prefersHomeIndicatorAutoHidden {
+ if ([_controllerSupport getConnectedGamepadCount] > 0 &&
+ [_streamView getCurrentOscState] == OnScreenControlsLevelOff &&
+ _userIsInteracting == NO) {
+ // Autohide the home bar when a gamepad is connected
+ // and the on-screen controls are disabled. We can't
+ // do this all the time because any touch on the display
+ // will cause the home indicator to reappear, and our
+ // preferredScreenEdgesDeferringSystemGestures will also
+ // be suppressed (leading to possible errant exits of the
+ // stream).
+ return YES;
+ }
+
+ return NO;
+}
+
- (BOOL)shouldAutorotate {
return YES;
}
+
+- (BOOL)prefersPointerLocked {
+ // Pointer lock breaks the UIKit mouse APIs, which is a problem because
+ // GCMouse is horribly broken on iOS 14.0 for certain mice. Only lock
+ // the cursor if there is a GCMouse present.
+ return [GCMouse mice].count > 0;
+}
+#endif
+
@end
diff --git a/Limelight/main.m b/Limelight/main.m
index b41a40e..0dbc0e3 100644
--- a/Limelight/main.m
+++ b/Limelight/main.m
@@ -10,9 +10,13 @@
#import "AppDelegate.h"
+#define SDL_MAIN_HANDLED
+#import
+
int main(int argc, char * argv[])
{
@autoreleasepool {
+ SDL_SetMainReady();
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..6707298
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv",
+ "filename" : "bg-app.jpg"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/bg-app.jpg b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/bg-app.jpg
new file mode 100644
index 0000000..7d956c0
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/bg-app.jpg differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json
new file mode 100644
index 0000000..da4a164
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json
new file mode 100644
index 0000000..d29f024
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json
@@ -0,0 +1,17 @@
+{
+ "layers" : [
+ {
+ "filename" : "Front.imagestacklayer"
+ },
+ {
+ "filename" : "Middle.imagestacklayer"
+ },
+ {
+ "filename" : "Back.imagestacklayer"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..48ecb4f
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json
new file mode 100644
index 0000000..da4a164
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..ece4178
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv",
+ "filename" : "wedges-app.png"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/wedges-app.png b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/wedges-app.png
new file mode 100644
index 0000000..6704ca2
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/wedges-app.png differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json
new file mode 100644
index 0000000..da4a164
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..f103f41
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv",
+ "filename" : "bg.jpg",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "tv",
+ "filename" : "bg@2.jpg",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/bg.jpg b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/bg.jpg
new file mode 100644
index 0000000..745d47e
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/bg.jpg differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/bg@2.jpg b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/bg@2.jpg
new file mode 100644
index 0000000..6b4890e
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/bg@2.jpg differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json
new file mode 100644
index 0000000..da4a164
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json
new file mode 100644
index 0000000..d29f024
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json
@@ -0,0 +1,17 @@
+{
+ "layers" : [
+ {
+ "filename" : "Front.imagestacklayer"
+ },
+ {
+ "filename" : "Middle.imagestacklayer"
+ },
+ {
+ "filename" : "Back.imagestacklayer"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..16a370d
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json
new file mode 100644
index 0000000..da4a164
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..2bddca3
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv",
+ "filename" : "Wedges.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "tv",
+ "filename" : "Wedges@2.png",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Wedges.png b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Wedges.png
new file mode 100644
index 0000000..c45bdb3
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Wedges.png differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Wedges@2.png b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Wedges@2.png
new file mode 100644
index 0000000..925cc3a
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Wedges@2.png differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json
new file mode 100644
index 0000000..da4a164
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json
new file mode 100644
index 0000000..db288f3
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json
@@ -0,0 +1,32 @@
+{
+ "assets" : [
+ {
+ "size" : "1280x768",
+ "idiom" : "tv",
+ "filename" : "App Icon - App Store.imagestack",
+ "role" : "primary-app-icon"
+ },
+ {
+ "size" : "400x240",
+ "idiom" : "tv",
+ "filename" : "App Icon.imagestack",
+ "role" : "primary-app-icon"
+ },
+ {
+ "size" : "2320x720",
+ "idiom" : "tv",
+ "filename" : "Top Shelf Image Wide.imageset",
+ "role" : "top-shelf-image-wide"
+ },
+ {
+ "size" : "1920x720",
+ "idiom" : "tv",
+ "filename" : "Top Shelf Image.imageset",
+ "role" : "top-shelf-image"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json
new file mode 100644
index 0000000..44273a5
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json
@@ -0,0 +1,28 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv",
+ "filename" : "topshelf-wide@1.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "tv",
+ "filename" : "topshelf-wide@2.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "tv-marketing",
+ "filename" : "topshelf-wide@1-1.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "tv-marketing",
+ "filename" : "topshelf-wide@2-1.png",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@1-1.png b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@1-1.png
new file mode 100644
index 0000000..a14ef59
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@1-1.png differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@1.png b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@1.png
new file mode 100644
index 0000000..a14ef59
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@1.png differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@2-1.png b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@2-1.png
new file mode 100644
index 0000000..5e532f4
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@2-1.png differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@2.png b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@2.png
new file mode 100644
index 0000000..5e532f4
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/topshelf-wide@2.png differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json
new file mode 100644
index 0000000..bd8b0b6
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json
@@ -0,0 +1,28 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv",
+ "filename" : "topshelf@1.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "tv",
+ "filename" : "topshelf@2.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "tv-marketing",
+ "filename" : "topshelf@1-1.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "tv-marketing",
+ "filename" : "topshelf@2-1.png",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@1-1.png b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@1-1.png
new file mode 100644
index 0000000..99646a7
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@1-1.png differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@1.png b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@1.png
new file mode 100644
index 0000000..99646a7
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@1.png differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@2-1.png b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@2-1.png
new file mode 100644
index 0000000..830a274
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@2-1.png differ
diff --git a/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@2.png b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@2.png
new file mode 100644
index 0000000..830a274
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/topshelf@2.png differ
diff --git a/Moonlight TV/Assets.xcassets/Contents.json b/Moonlight TV/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..da4a164
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/Launch Image.launchimage/Contents.json b/Moonlight TV/Assets.xcassets/Launch Image.launchimage/Contents.json
new file mode 100644
index 0000000..c446e92
--- /dev/null
+++ b/Moonlight TV/Assets.xcassets/Launch Image.launchimage/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "orientation" : "landscape",
+ "idiom" : "tv",
+ "filename" : "Simulator Screen Shot - Apple TV 4K - 2019-09-22 at 20.39.12.png",
+ "extent" : "full-screen",
+ "minimum-system-version" : "11.0",
+ "scale" : "2x"
+ },
+ {
+ "orientation" : "landscape",
+ "idiom" : "tv",
+ "extent" : "full-screen",
+ "minimum-system-version" : "9.0",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/Moonlight TV/Assets.xcassets/Launch Image.launchimage/Simulator Screen Shot - Apple TV 4K - 2019-09-22 at 20.39.12.png b/Moonlight TV/Assets.xcassets/Launch Image.launchimage/Simulator Screen Shot - Apple TV 4K - 2019-09-22 at 20.39.12.png
new file mode 100644
index 0000000..c518271
Binary files /dev/null and b/Moonlight TV/Assets.xcassets/Launch Image.launchimage/Simulator Screen Shot - Apple TV 4K - 2019-09-22 at 20.39.12.png differ
diff --git a/Moonlight TV/Base.lproj/Main.storyboard b/Moonlight TV/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..7c0413f
--- /dev/null
+++ b/Moonlight TV/Base.lproj/Main.storyboard
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Moonlight TV/Info.plist b/Moonlight TV/Info.plist
new file mode 100644
index 0000000..c1f0bee
--- /dev/null
+++ b/Moonlight TV/Info.plist
@@ -0,0 +1,62 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Moonlight
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ GCSupportedGameControllers
+
+
+ ProfileName
+ ExtendedGamepad
+
+
+ GCSupportsControllerUserInteraction
+
+ ITSAppUsesNonExemptEncryption
+
+ LSRequiresIPhoneOS
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ NSBluetoothAlwaysUsageDescription
+ Bluetooth access allows Moonlight to connect to Citrix X1 mice.
+ NSBluetoothPeripheralUsageDescription
+ Bluetooth access allows Moonlight to connect to Citrix X1 mice.
+ NSBonjourServices
+
+ _nvstream._tcp
+
+ NSLocalNetworkUsageDescription
+ Moonlight uses the local network to connect to your gaming PC for streaming.
+ UIMainStoryboardFile
+ Main
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ LSSupportsGameMode
+
+ UIUserInterfaceStyle
+ Dark
+
+
diff --git a/Moonlight TV/Settings.bundle/Root.plist b/Moonlight TV/Settings.bundle/Root.plist
new file mode 100644
index 0000000..8088188
--- /dev/null
+++ b/Moonlight TV/Settings.bundle/Root.plist
@@ -0,0 +1,259 @@
+
+
+
+
+ StringsTable
+ Root
+ PreferenceSpecifiers
+
+
+ Type
+ PSGroupSpecifier
+ Title
+ Basic Configuration
+
+
+ Type
+ PSMultiValueSpecifier
+ Title
+ Stream Resolution
+ Key
+ streamResolution
+ DefaultValue
+ 1
+ Values
+
+ 0
+ 1
+ 3
+ 2
+
+ Titles
+
+ 720p
+ 1080p
+ 1440p
+ 4K
+
+
+
+ Type
+ PSMultiValueSpecifier
+ Title
+ Frame Rate
+ Key
+ framerate
+ DefaultValue
+ 60
+ Values
+
+ 30
+ 60
+
+ Titles
+
+ 30 FPS
+ 60 FPS
+
+
+
+ Type
+ PSMultiValueSpecifier
+ Title
+ Bitrate
+ Key
+ bitrate
+ DefaultValue
+ 20000
+ Titles
+
+ 0.5 Mbps
+ 1 Mbps
+ 2 Mbps
+ 5 Mbps
+ 10 Mbps
+ 20 Mbps
+ 25 Mbps
+ 50 Mbps
+ 70 Mbps
+ 80 Mbps
+ 100 Mbps
+ 150 Mbps
+
+ Values
+
+ 500
+ 1000
+ 2000
+ 5000
+ 10000
+ 20000
+ 25000
+ 50000
+ 70000
+ 80000
+ 100000
+ 150000
+
+
+
+ Type
+ PSMultiValueSpecifier
+ Title
+ Audio Configuration
+ Key
+ audioConfig
+ DefaultValue
+ 2
+ Titles
+
+ Stereo
+ LPCM 5.1
+ LPCM 7.1
+
+ Values
+
+ 2
+ 6
+ 8
+
+
+
+ Type
+ PSGroupSpecifier
+ Title
+ Host Options
+
+
+ Type
+ PSToggleSwitchSpecifier
+ DefaultValue
+
+ Title
+ Play Audio on Host PC
+ Key
+ audioOnPC
+
+
+ Type
+ PSToggleSwitchSpecifier
+ Title
+ Optimize Game Settings
+ Key
+ optimizeGames
+ DefaultValue
+
+
+
+ Type
+ PSGroupSpecifier
+ Title
+ Input Options
+
+
+ Type
+ PSToggleSwitchSpecifier
+ Title
+ Automatic Game Controller Detection
+ Key
+ multipleControllers
+ DefaultValue
+
+
+
+ Type
+ PSToggleSwitchSpecifier
+ Title
+ Swap A/B and X/Y Buttons
+ Key
+ swapABXYButtons
+ DefaultValue
+
+
+
+ Type
+ PSToggleSwitchSpecifier
+ Title
+ Citrix X1 Mouse Support
+ Key
+ btMouseSupport
+ DefaultValue
+
+
+
+ Type
+ PSGroupSpecifier
+ Title
+ Video Options
+
+
+ Type
+ PSMultiValueSpecifier
+ Title
+ Preferred Codec
+ Key
+ preferredCodec
+ DefaultValue
+ 0
+ Values
+
+ 0
+ 1
+ 2
+
+ Titles
+
+ Auto
+ H.264
+ HEVC
+
+
+
+ Type
+ PSMultiValueSpecifier
+ Title
+ Frame Pacing Preference
+ Key
+ useFramePacing
+ DefaultValue
+ 0
+ Values
+
+ 0
+ 1
+
+ Titles
+
+ Lowest Latency
+ Smoothest Video
+
+
+
+ Type
+ PSToggleSwitchSpecifier
+ Title
+ Request HDR Video
+ Key
+ enableHdr
+ DefaultValue
+
+
+
+ Type
+ PSGroupSpecifier
+ Title
+ Troubleshooting
+
+
+ Type
+ PSToggleSwitchSpecifier
+ Title
+ Statistics Overlay
+ Key
+ statsOverlay
+ DefaultValue
+
+
+
+
+
diff --git a/Moonlight TV/Settings.bundle/en.lproj/Root.strings b/Moonlight TV/Settings.bundle/en.lproj/Root.strings
new file mode 100644
index 0000000..8cd87b9
Binary files /dev/null and b/Moonlight TV/Settings.bundle/en.lproj/Root.strings differ
diff --git a/Moonlight TV/main.m b/Moonlight TV/main.m
new file mode 100644
index 0000000..df13448
--- /dev/null
+++ b/Moonlight TV/main.m
@@ -0,0 +1,20 @@
+//
+// main.m
+// Moonlight TV
+//
+// Created by Diego Waxemberg on 8/25/18.
+// Copyright © 2018 Moonlight Game Streaming Project. All rights reserved.
+//
+
+#import
+#import "AppDelegate.h"
+
+#define SDL_MAIN_HANDLED
+#import
+
+int main(int argc, char * argv[]) {
+ @autoreleasepool {
+ SDL_SetMainReady();
+ return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
+ }
+}
diff --git a/Moonlight.xcodeproj/project.pbxproj b/Moonlight.xcodeproj/project.pbxproj
index 875ed3b..e686ec1 100644
--- a/Moonlight.xcodeproj/project.pbxproj
+++ b/Moonlight.xcodeproj/project.pbxproj
@@ -3,23 +3,90 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 47;
+ objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
+ 693B3A9B218638CD00982F7B /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 693B3A9A218638CD00982F7B /* Settings.bundle */; };
+ 98181BE92791277400E43572 /* libSDL2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 98181BE82791275D00E43572 /* libSDL2.a */; };
+ 98181BEB2791278F00E43572 /* libSDL2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 98181BEA2791278300E43572 /* libSDL2.a */; };
+ 98181BED2791281100E43572 /* CoreMotion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 98181BEC2791281100E43572 /* CoreMotion.framework */; };
+ 9819CC14254F107A008A7C8E /* RelativeTouchHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 9819CC13254F107A008A7C8E /* RelativeTouchHandler.m */; };
+ 9819CC1D254F1730008A7C8E /* RelativeTouchHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 9819CC13254F107A008A7C8E /* RelativeTouchHandler.m */; };
+ 9819CC22254F180F008A7C8E /* AbsoluteTouchHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 9819CC21254F180F008A7C8E /* AbsoluteTouchHandler.m */; };
+ 9827E7A32514366900F25707 /* HapticContext.m in Sources */ = {isa = PBXBuildFile; fileRef = 9827E7A22514366900F25707 /* HapticContext.m */; };
+ 9827E7A42514366900F25707 /* HapticContext.m in Sources */ = {isa = PBXBuildFile; fileRef = 9827E7A22514366900F25707 /* HapticContext.m */; };
9832D1361BBCD5C50036EF48 /* TemporaryApp.m in Sources */ = {isa = PBXBuildFile; fileRef = 9832D1351BBCD5C50036EF48 /* TemporaryApp.m */; };
+ 9865DC30213260B40005B9B9 /* libmoonlight-common-tv.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FB1A68152132509400507771 /* libmoonlight-common-tv.a */; };
+ 9865DC31213260F10005B9B9 /* mkcert.c in Sources */ = {isa = PBXBuildFile; fileRef = FB89460719F646E200339C8A /* mkcert.c */; };
+ 9865DC36213287F30005B9B9 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = FB290D0319B2C406004C83CF /* AppDelegate.m */; };
+ 9865DC37213287FE0005B9B9 /* StreamFrameViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89462719F646E200339C8A /* StreamFrameViewController.m */; };
+ 9865DC38213287FE0005B9B9 /* LoadingFrameViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FB4A23B71A9D3637004D2EF2 /* LoadingFrameViewController.m */; };
+ 9865DC3C2132922E0005B9B9 /* GameController.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9865DC3B2132922E0005B9B9 /* GameController.framework */; };
+ 9865DC3E21332D660005B9B9 /* MainFrameViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89462519F646E200339C8A /* MainFrameViewController.m */; };
+ 98882A062AF60F7000C5A11C /* libavcodec.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 98882A032AF60F5300C5A11C /* libavcodec.a */; };
+ 98882A072AF60F7200C5A11C /* libavformat.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 98882A052AF60F5300C5A11C /* libavformat.a */; };
+ 98882A082AF60F7400C5A11C /* libavutil.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 98882A042AF60F5300C5A11C /* libavutil.a */; };
+ 98882A0C2AF60F9E00C5A11C /* libavcodec.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 98882A0A2AF60F9000C5A11C /* libavcodec.a */; };
+ 98882A0D2AF60FA000C5A11C /* libavformat.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 98882A0B2AF60F9000C5A11C /* libavformat.a */; };
+ 98882A0E2AF60FA300C5A11C /* libavutil.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 98882A092AF60F9000C5A11C /* libavutil.a */; };
+ 988FCD41293B091B003050E2 /* KeyboardInputField.m in Sources */ = {isa = PBXBuildFile; fileRef = 988FCD40293B091B003050E2 /* KeyboardInputField.m */; };
+ 988FCD42293B091B003050E2 /* KeyboardInputField.m in Sources */ = {isa = PBXBuildFile; fileRef = 988FCD40293B091B003050E2 /* KeyboardInputField.m */; };
+ 9890CF6B203B7EE1006C4B06 /* libxml2.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 9890CF6A203B7EE1006C4B06 /* libxml2.tbd */; };
+ 9896219723D15C7000211983 /* X1Kit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9896219623D15C7000211983 /* X1Kit.swift */; };
+ 9896219A23D56E8100211983 /* X1Kit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9896219623D15C7000211983 /* X1Kit.swift */; };
+ 9897B6A1221260EF00966419 /* Controller.m in Sources */ = {isa = PBXBuildFile; fileRef = 9897B6A0221260EF00966419 /* Controller.m */; };
+ 9897B6A62212732C00966419 /* Controller.m in Sources */ = {isa = PBXBuildFile; fileRef = 9897B6A0221260EF00966419 /* Controller.m */; };
+ 98B9CE6D27B2144B00B473C4 /* AVKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 98B9CE6C27B2144B00B473C4 /* AVKit.framework */; };
98CFB82F1CAD481B0048EF74 /* libmoonlight-common.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 98AB2E841CAD46840089BB98 /* libmoonlight-common.a */; };
98D5856D1C0EA79600F6CC00 /* TemporaryHost.m in Sources */ = {isa = PBXBuildFile; fileRef = 98D5856C1C0EA79600F6CC00 /* TemporaryHost.m */; };
98D585701C0ED0E800F6CC00 /* TemporarySettings.m in Sources */ = {isa = PBXBuildFile; fileRef = 98D5856F1C0ED0E800F6CC00 /* TemporarySettings.m */; };
- 9E5D600B1A5A5A3900689918 /* Apache License.txt in Resources */ = {isa = PBXBuildFile; fileRef = 9E5D5FF81A5A5A3900689918 /* Apache License.txt */; };
- 9E5D600C1A5A5A3900689918 /* Roboto-Black.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 9E5D5FF91A5A5A3900689918 /* Roboto-Black.ttf */; };
- 9E5D600E1A5A5A3900689918 /* Roboto-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 9E5D5FFB1A5A5A3900689918 /* Roboto-Bold.ttf */; };
- 9E5D60111A5A5A3900689918 /* Roboto-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 9E5D5FFE1A5A5A3900689918 /* Roboto-Light.ttf */; };
- 9E5D60131A5A5A3900689918 /* Roboto-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 9E5D60001A5A5A3900689918 /* Roboto-Medium.ttf */; };
- 9E5D60151A5A5A3900689918 /* Roboto-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 9E5D60021A5A5A3900689918 /* Roboto-Regular.ttf */; };
- 9E5D60161A5A5A3900689918 /* Roboto-Thin.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 9E5D60031A5A5A3900689918 /* Roboto-Thin.ttf */; };
- D46A73AD1CBC7D090039F1EE /* ControllerUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D46A73AC1CBC7D090039F1EE /* ControllerUnitTests.swift */; };
- D4746EEC1CBC740C006FB401 /* Controller.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4746EEB1CBC740C006FB401 /* Controller.swift */; };
+ DC1F5A07206436B20037755F /* ConnectionHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = DC1F5A06206436B20037755F /* ConnectionHelper.m */; };
+ F74BEF9C2C1A705600224667 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F74BEF9B2C1A705600224667 /* OpenSSL */; };
+ F74BEFA42C1A80E400224667 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F74BEFA32C1A80E400224667 /* OpenSSL */; };
+ FB1A674D2131E65900507771 /* KeyboardSupport.m in Sources */ = {isa = PBXBuildFile; fileRef = FB1A674C2131E65900507771 /* KeyboardSupport.m */; };
+ FB1A67602132419700507771 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FB1A675E2132419700507771 /* Main.storyboard */; };
+ FB1A67622132419A00507771 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FB1A67612132419A00507771 /* Assets.xcassets */; };
+ FB1A67A12132458C00507771 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = FB1A67642132419A00507771 /* main.m */; };
+ FB1A67A3213245BD00507771 /* Connection.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89461719F646E200339C8A /* Connection.m */; };
+ FB1A67A5213245BD00507771 /* StreamConfiguration.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89461919F646E200339C8A /* StreamConfiguration.m */; };
+ FB1A67A7213245BD00507771 /* StreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89461B19F646E200339C8A /* StreamManager.m */; };
+ FB1A67A9213245BD00507771 /* VideoDecoderRenderer.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89461D19F646E200339C8A /* VideoDecoderRenderer.m */; };
+ FB1A67AB213245C500507771 /* CryptoManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89460619F646E200339C8A /* CryptoManager.m */; };
+ FB1A67AF213245C500507771 /* IdManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FB53E1421BE5DC4400CD6ECE /* IdManager.m */; };
+ FB1A67B1213245D500507771 /* HttpManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89461019F646E200339C8A /* HttpManager.m */; };
+ FB1A67B3213245D500507771 /* HttpRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = FB9AFD361A7E02DB00872C98 /* HttpRequest.m */; };
+ FB1A67B5213245D500507771 /* HttpResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = FB9AFD271A7C84ED00872C98 /* HttpResponse.m */; };
+ FB1A67B7213245D500507771 /* ServerInfoResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = FB9AFD391A7E05CE00872C98 /* ServerInfoResponse.m */; };
+ FB1A67B9213245D500507771 /* AppAssetResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = FB9AFD3C1A7E111600872C98 /* AppAssetResponse.m */; };
+ FB1A67BB213245D500507771 /* AppListResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = FB9AFD3F1A7E127D00872C98 /* AppListResponse.m */; };
+ FB1A67BD213245E000507771 /* DiscoveryManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FB4678F91A55FFAD00377732 /* DiscoveryManager.m */; };
+ FB1A67BF213245E000507771 /* DiscoveryWorker.m in Sources */ = {isa = PBXBuildFile; fileRef = FB6549551A57907E001C8F39 /* DiscoveryWorker.m */; };
+ FB1A67C1213245E000507771 /* MDNSManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89461219F646E200339C8A /* MDNSManager.m */; };
+ FB1A67C3213245EA00507771 /* AppAssetManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBD3494219FC9C04002D2A60 /* AppAssetManager.m */; };
+ FB1A67C5213245EA00507771 /* AppAssetRetriever.m in Sources */ = {isa = PBXBuildFile; fileRef = FB9AFD311A7D867C00872C98 /* AppAssetRetriever.m */; };
+ FB1A67C7213245EA00507771 /* PairManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89461419F646E200339C8A /* PairManager.m */; };
+ FB1A67C9213245EA00507771 /* WakeOnLanManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FB4678FE1A565DAC00377732 /* WakeOnLanManager.m */; };
+ FB1A67CB213245EA00507771 /* ConnectionHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = DC1F5A06206436B20037755F /* ConnectionHelper.m */; };
+ FB1A67CD213245F800507771 /* DataManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBD349611A0089F6002D2A60 /* DataManager.m */; };
+ FB1A67CF213245F800507771 /* TemporaryApp.m in Sources */ = {isa = PBXBuildFile; fileRef = 9832D1351BBCD5C50036EF48 /* TemporaryApp.m */; };
+ FB1A67D1213245F800507771 /* TemporaryHost.m in Sources */ = {isa = PBXBuildFile; fileRef = 98D5856C1C0EA79600F6CC00 /* TemporaryHost.m */; };
+ FB1A67D3213245F800507771 /* TemporarySettings.m in Sources */ = {isa = PBXBuildFile; fileRef = 98D5856F1C0ED0E800F6CC00 /* TemporarySettings.m */; };
+ FB1A67D52132460400507771 /* ControllerSupport.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89460B19F646E200339C8A /* ControllerSupport.m */; };
+ FB1A67D82132460400507771 /* StreamView.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89460D19F646E200339C8A /* StreamView.m */; };
+ FB1A67DA2132460400507771 /* OnScreenControls.m in Sources */ = {isa = PBXBuildFile; fileRef = FB4678EC1A50C40900377732 /* OnScreenControls.m */; };
+ FB1A67DC2132460400507771 /* KeyboardSupport.m in Sources */ = {isa = PBXBuildFile; fileRef = FB1A674C2131E65900507771 /* KeyboardSupport.m */; };
+ FB1A67DE2132460A00507771 /* Utils.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89462219F646E200339C8A /* Utils.m */; };
+ FB1A67E02132460A00507771 /* Logger.m in Sources */ = {isa = PBXBuildFile; fileRef = FBD1C8E11A8AD71400C6703C /* Logger.m */; };
+ FB1A67E32132498A00507771 /* Limelight.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FB290D0519B2C406004C83CF /* Limelight.xcdatamodeld */; };
+ FB1A67E521324A1F00507771 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB1A67E421324A1F00507771 /* CoreData.framework */; };
+ FB1A67E821324DE300507771 /* libopus.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FB8946EA19F6AFB800339C8A /* libopus.a */; };
+ FB1A67EA21324DF300507771 /* libxml2.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = FB1A67E921324DF300507771 /* libxml2.tbd */; };
+ FB1A6819213284FB00507771 /* UIComputerView.m in Sources */ = {isa = PBXBuildFile; fileRef = FBDE86DF19F7A837001C18A8 /* UIComputerView.m */; };
+ FB1A681A213284FB00507771 /* UIAppView.m in Sources */ = {isa = PBXBuildFile; fileRef = FBDE86E519F82297001C18A8 /* UIAppView.m */; };
+ FB1A681B213284FB00507771 /* ComputerScrollView.m in Sources */ = {isa = PBXBuildFile; fileRef = FB1D59961BBCCB6400F482CA /* ComputerScrollView.m */; };
+ FB1A681C213284FB00507771 /* AppCollectionView.m in Sources */ = {isa = PBXBuildFile; fileRef = FB1D59991BBCCD7E00F482CA /* AppCollectionView.m */; };
+ FB1A681E21328A1B00507771 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FB290D0819B2C406004C83CF /* Images.xcassets */; };
FB1D59971BBCCB6400F482CA /* ComputerScrollView.m in Sources */ = {isa = PBXBuildFile; fileRef = FB1D59961BBCCB6400F482CA /* ComputerScrollView.m */; };
FB1D599A1BBCCD7E00F482CA /* AppCollectionView.m in Sources */ = {isa = PBXBuildFile; fileRef = FB1D59991BBCCD7E00F482CA /* AppCollectionView.m */; };
FB290CF219B2C406004C83CF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB290CF119B2C406004C83CF /* Foundation.framework */; };
@@ -31,9 +98,6 @@
FB290D0419B2C406004C83CF /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = FB290D0319B2C406004C83CF /* AppDelegate.m */; };
FB290D0719B2C406004C83CF /* Limelight.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FB290D0519B2C406004C83CF /* Limelight.xcdatamodeld */; };
FB290D0919B2C406004C83CF /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FB290D0819B2C406004C83CF /* Images.xcassets */; };
- FB290DB719B2C870004C83CF /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = FB290DB619B2C870004C83CF /* libz.dylib */; };
- FB290DB919B2C877004C83CF /* libbz2.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = FB290DB819B2C877004C83CF /* libbz2.dylib */; };
- FB290DC419B2E98F004C83CF /* libxml2.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = FB290DC319B2E98F004C83CF /* libxml2.dylib */; };
FB290E7919B37D81004C83CF /* iPad.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FB290E7819B37D81004C83CF /* iPad.storyboard */; };
FB290E7B19B38036004C83CF /* iPhone.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FB290E7A19B38036004C83CF /* iPhone.storyboard */; };
FB4678ED1A50C40900377732 /* OnScreenControls.m in Sources */ = {isa = PBXBuildFile; fileRef = FB4678EC1A50C40900377732 /* OnScreenControls.m */; };
@@ -43,7 +107,6 @@
FB4A23B81A9D3637004D2EF2 /* LoadingFrameViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FB4A23B71A9D3637004D2EF2 /* LoadingFrameViewController.m */; };
FB53E1431BE5DC4400CD6ECE /* IdManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FB53E1421BE5DC4400CD6ECE /* IdManager.m */; };
FB6549561A57907E001C8F39 /* DiscoveryWorker.m in Sources */ = {isa = PBXBuildFile; fileRef = FB6549551A57907E001C8F39 /* DiscoveryWorker.m */; };
- FB7E794419C8B71B00A15F68 /* libiconv.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = FB7E794319C8B71B00A15F68 /* libiconv.dylib */; };
FB89462819F646E200339C8A /* CryptoManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89460619F646E200339C8A /* CryptoManager.m */; };
FB89462919F646E200339C8A /* mkcert.c in Sources */ = {isa = PBXBuildFile; fileRef = FB89460719F646E200339C8A /* mkcert.c */; };
FB89462A19F646E200339C8A /* ControllerSupport.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89460B19F646E200339C8A /* ControllerSupport.m */; };
@@ -59,8 +122,6 @@
FB89463519F646E200339C8A /* MainFrameViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89462519F646E200339C8A /* MainFrameViewController.m */; };
FB89463619F646E200339C8A /* StreamFrameViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FB89462719F646E200339C8A /* StreamFrameViewController.m */; };
FB89463819F6473800339C8A /* Launch Screen.xib in Resources */ = {isa = PBXBuildFile; fileRef = FB89463719F6473800339C8A /* Launch Screen.xib */; };
- FB8946EB19F6AFE100339C8A /* libcrypto.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FB8946E019F6AFB800339C8A /* libcrypto.a */; };
- FB8946EC19F6AFE400339C8A /* libssl.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FB8946E119F6AFB800339C8A /* libssl.a */; };
FB8946ED19F6AFE800339C8A /* libopus.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FB8946EA19F6AFB800339C8A /* libopus.a */; };
FB9AFD281A7C84ED00872C98 /* HttpResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = FB9AFD271A7C84ED00872C98 /* HttpResponse.m */; };
FB9AFD321A7D867C00872C98 /* AppAssetRetriever.m in Sources */ = {isa = PBXBuildFile; fileRef = FB9AFD311A7D867C00872C98 /* AppAssetRetriever.m */; };
@@ -72,12 +133,9 @@
FBD3494319FC9C04002D2A60 /* AppAssetManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBD3494219FC9C04002D2A60 /* AppAssetManager.m */; };
FBD3495019FF2174002D2A60 /* SettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FBD3494F19FF2174002D2A60 /* SettingsViewController.m */; };
FBD3495319FF36FB002D2A60 /* SWRevealViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FBD3495219FF36FB002D2A60 /* SWRevealViewController.m */; };
- FBD3495E1A004412002D2A60 /* Settings.m in Sources */ = {isa = PBXBuildFile; fileRef = FBD3495D1A004412002D2A60 /* Settings.m */; };
FBD349621A0089F6002D2A60 /* DataManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBD349611A0089F6002D2A60 /* DataManager.m */; };
FBDE86E019F7A837001C18A8 /* UIComputerView.m in Sources */ = {isa = PBXBuildFile; fileRef = FBDE86DF19F7A837001C18A8 /* UIComputerView.m */; };
FBDE86E619F82297001C18A8 /* UIAppView.m in Sources */ = {isa = PBXBuildFile; fileRef = FBDE86E519F82297001C18A8 /* UIAppView.m */; };
- FBFCB3311B50B29400089F8A /* Host.m in Sources */ = {isa = PBXBuildFile; fileRef = FBFCB3301B50B29400089F8A /* Host.m */; };
- FBFCB3351B50B29400089F8A /* App.m in Sources */ = {isa = PBXBuildFile; fileRef = FBFCB3341B50B29400089F8A /* App.m */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -95,36 +153,77 @@
remoteGlobalIDString = FB290E2D19B37A4E004C83CF;
remoteInfo = "moonlight-common";
};
- D46A73A51CBC7CB60039F1EE /* PBXContainerItemProxy */ = {
+ FB1A68142132509400507771 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
- containerPortal = FB290CE619B2C406004C83CF /* Project object */;
+ containerPortal = 98AB2E7F1CAD46830089BB98 /* moonlight-common.xcodeproj */;
+ proxyType = 2;
+ remoteGlobalIDString = FB1A67EF21324EE300507771;
+ remoteInfo = "moonlight-common-tv";
+ };
+ FB1A68162132509800507771 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 98AB2E7F1CAD46830089BB98 /* moonlight-common.xcodeproj */;
proxyType = 1;
- remoteGlobalIDString = FB290CED19B2C406004C83CF;
- remoteInfo = Moonlight;
+ remoteGlobalIDString = FB1A67EE21324EE300507771;
+ remoteInfo = "moonlight-common-tv";
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
+ 566E9D2B2770B23A00EF7BFE /* Moonlight v1.7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.7.xcdatamodel"; sourceTree = ""; };
+ 693B3A9A218638CD00982F7B /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; };
+ 9803CCAB254F9EAF00EE185E /* ConnectionCallbacks.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ConnectionCallbacks.h; sourceTree = ""; };
+ 98132E8C20BC9A62007A053F /* Moonlight v1.1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.1.xcdatamodel"; sourceTree = ""; };
+ 98181BE82791275D00E43572 /* libSDL2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libSDL2.a; path = libs/SDL2/lib/iOS/libSDL2.a; sourceTree = ""; };
+ 98181BEA2791278300E43572 /* libSDL2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libSDL2.a; path = libs/SDL2/lib/tvOS/libSDL2.a; sourceTree = ""; };
+ 98181BEC2791281100E43572 /* CoreMotion.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMotion.framework; path = System/Library/Frameworks/CoreMotion.framework; sourceTree = SDKROOT; };
+ 9819CC12254F107A008A7C8E /* RelativeTouchHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RelativeTouchHandler.h; sourceTree = ""; };
+ 9819CC13254F107A008A7C8E /* RelativeTouchHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RelativeTouchHandler.m; sourceTree = ""; };
+ 9819CC20254F180F008A7C8E /* AbsoluteTouchHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AbsoluteTouchHandler.h; sourceTree = ""; };
+ 9819CC21254F180F008A7C8E /* AbsoluteTouchHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsoluteTouchHandler.m; sourceTree = ""; };
+ 9819CC25254F2734008A7C8E /* Moonlight v1.6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.6.xcdatamodel"; sourceTree = ""; };
+ 9827E7A22514366900F25707 /* HapticContext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HapticContext.m; sourceTree = ""; };
+ 9827E7A7251436EA00F25707 /* HapticContext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HapticContext.h; sourceTree = ""; };
9832D1341BBCD5C50036EF48 /* TemporaryApp.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = TemporaryApp.h; path = Database/TemporaryApp.h; sourceTree = ""; };
9832D1351BBCD5C50036EF48 /* TemporaryApp.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TemporaryApp.m; path = Database/TemporaryApp.m; sourceTree = ""; };
+ 984CD023288A310D0097D2D4 /* Moonlight v1.8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.8.xcdatamodel"; sourceTree = ""; };
+ 98517B1B21CE0A9000481377 /* Moonlight v1.3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.3.xcdatamodel"; sourceTree = ""; };
+ 98608BDD22DC0C2C000E5672 /* Moonlight v1.4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.4.xcdatamodel"; sourceTree = ""; };
+ 9865DC3B2132922E0005B9B9 /* GameController.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GameController.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS11.4.sdk/System/Library/Frameworks/GameController.framework; sourceTree = DEVELOPER_DIR; };
+ 986CCE6C2133E45300168291 /* Moonlight v1.2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.2.xcdatamodel"; sourceTree = ""; };
+ 986E28A528EA989100758361 /* Moonlight v1.9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.9.xcdatamodel"; sourceTree = ""; };
+ 9874E8962AE95E1D00130A3C /* Moonlight v1.10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.10.xcdatamodel"; sourceTree = ""; };
+ 98783FEA242EAC5D00F00EF4 /* Moonlight v1.5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.5.xcdatamodel"; sourceTree = ""; };
+ 98882A032AF60F5300C5A11C /* libavcodec.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libavcodec.a; path = libs/FFmpeg/lib/iOS/libavcodec.a; sourceTree = ""; };
+ 98882A042AF60F5300C5A11C /* libavutil.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libavutil.a; path = libs/FFmpeg/lib/iOS/libavutil.a; sourceTree = ""; };
+ 98882A052AF60F5300C5A11C /* libavformat.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libavformat.a; path = libs/FFmpeg/lib/iOS/libavformat.a; sourceTree = ""; };
+ 98882A092AF60F9000C5A11C /* libavutil.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libavutil.a; path = libs/FFmpeg/lib/tvOS/libavutil.a; sourceTree = ""; };
+ 98882A0A2AF60F9000C5A11C /* libavcodec.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libavcodec.a; path = libs/FFmpeg/lib/tvOS/libavcodec.a; sourceTree = ""; };
+ 98882A0B2AF60F9000C5A11C /* libavformat.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libavformat.a; path = libs/FFmpeg/lib/tvOS/libavformat.a; sourceTree = ""; };
+ 988FCD3F293B091B003050E2 /* KeyboardInputField.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyboardInputField.h; sourceTree = ""; };
+ 988FCD40293B091B003050E2 /* KeyboardInputField.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KeyboardInputField.m; sourceTree = ""; };
+ 9890CF6A203B7EE1006C4B06 /* libxml2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libxml2.tbd; path = usr/lib/libxml2.tbd; sourceTree = SDKROOT; };
+ 9896219623D15C7000211983 /* X1Kit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = X1Kit.swift; path = X1Kit/Sources/X1Kit/X1Kit.swift; sourceTree = ""; };
+ 9897B6A0221260EF00966419 /* Controller.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Controller.m; sourceTree = ""; };
+ 9897B6A32212610800966419 /* Controller.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Controller.h; sourceTree = ""; };
98AB2E7F1CAD46830089BB98 /* moonlight-common.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = "moonlight-common.xcodeproj"; path = "moonlight-common/moonlight-common.xcodeproj"; sourceTree = ""; };
+ 98B9CE6C27B2144B00B473C4 /* AVKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVKit.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.2.sdk/System/Library/Frameworks/AVKit.framework; sourceTree = DEVELOPER_DIR; };
98D5856B1C0EA79600F6CC00 /* TemporaryHost.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = TemporaryHost.h; path = Database/TemporaryHost.h; sourceTree = ""; };
98D5856C1C0EA79600F6CC00 /* TemporaryHost.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TemporaryHost.m; path = Database/TemporaryHost.m; sourceTree = ""; };
98D5856E1C0ED0E800F6CC00 /* TemporarySettings.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = TemporarySettings.h; path = Database/TemporarySettings.h; sourceTree = ""; };
98D5856F1C0ED0E800F6CC00 /* TemporarySettings.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TemporarySettings.m; path = Database/TemporarySettings.m; sourceTree = ""; };
- 9E5D5FF81A5A5A3900689918 /* Apache License.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "Apache License.txt"; sourceTree = ""; };
- 9E5D5FF91A5A5A3900689918 /* Roboto-Black.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-Black.ttf"; sourceTree = ""; };
- 9E5D5FFB1A5A5A3900689918 /* Roboto-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-Bold.ttf"; sourceTree = ""; };
- 9E5D5FFE1A5A5A3900689918 /* Roboto-Light.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-Light.ttf"; sourceTree = ""; };
- 9E5D60001A5A5A3900689918 /* Roboto-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-Medium.ttf"; sourceTree = ""; };
- 9E5D60021A5A5A3900689918 /* Roboto-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-Regular.ttf"; sourceTree = ""; };
- 9E5D60031A5A5A3900689918 /* Roboto-Thin.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-Thin.ttf"; sourceTree = ""; };
- D46A73A01CBC7CB60039F1EE /* MoonlightUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MoonlightUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
- D46A73A41CBC7CB60039F1EE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
- D46A73AB1CBC7D080039F1EE /* MoonlightUnitTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "MoonlightUnitTests-Bridging-Header.h"; sourceTree = ""; };
- D46A73AC1CBC7D090039F1EE /* ControllerUnitTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ControllerUnitTests.swift; path = Input/ControllerUnitTests.swift; sourceTree = ""; };
D4746EEA1CBC740C006FB401 /* Moonlight-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Moonlight-Bridging-Header.h"; path = "Input/Moonlight-Bridging-Header.h"; sourceTree = ""; };
- D4746EEB1CBC740C006FB401 /* Controller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Controller.swift; sourceTree = ""; };
+ DC1F5A05206436B10037755F /* ConnectionHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConnectionHelper.h; sourceTree = ""; };
+ DC1F5A06206436B20037755F /* ConnectionHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConnectionHelper.m; sourceTree = ""; };
+ FB1A674B2131E65900507771 /* KeyboardSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyboardSupport.h; sourceTree = ""; };
+ FB1A674C2131E65900507771 /* KeyboardSupport.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KeyboardSupport.m; sourceTree = ""; };
+ FB1A67532132419700507771 /* Moonlight TV.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Moonlight TV.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ FB1A675F2132419700507771 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ FB1A67612132419A00507771 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ FB1A67632132419A00507771 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ FB1A67642132419A00507771 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; };
+ FB1A67E421324A1F00507771 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS11.4.sdk/System/Library/Frameworks/CoreData.framework; sourceTree = DEVELOPER_DIR; };
+ FB1A67E921324DF300507771 /* libxml2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libxml2.tbd; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS11.4.sdk/usr/lib/libxml2.tbd; sourceTree = DEVELOPER_DIR; };
FB1D59951BBCCB6400F482CA /* ComputerScrollView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ComputerScrollView.h; sourceTree = ""; };
FB1D59961BBCCB6400F482CA /* ComputerScrollView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ComputerScrollView.m; sourceTree = ""; };
FB1D59981BBCCD7E00F482CA /* AppCollectionView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppCollectionView.h; sourceTree = ""; };
@@ -142,9 +241,6 @@
FB290D0319B2C406004C83CF /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; };
FB290D0619B2C406004C83CF /* Limelight.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Limelight.xcdatamodel; sourceTree = ""; };
FB290D0819B2C406004C83CF /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; };
- FB290DB619B2C870004C83CF /* libz.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libz.dylib; path = usr/lib/libz.dylib; sourceTree = SDKROOT; };
- FB290DB819B2C877004C83CF /* libbz2.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libbz2.dylib; path = usr/lib/libbz2.dylib; sourceTree = SDKROOT; };
- FB290DC319B2E98F004C83CF /* libxml2.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libxml2.dylib; path = usr/lib/libxml2.dylib; sourceTree = SDKROOT; };
FB290E7819B37D81004C83CF /* iPad.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = iPad.storyboard; sourceTree = SOURCE_ROOT; };
FB290E7A19B38036004C83CF /* iPhone.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = iPhone.storyboard; sourceTree = SOURCE_ROOT; };
FB4678EB1A50C40900377732 /* OnScreenControls.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OnScreenControls.h; sourceTree = ""; };
@@ -163,8 +259,6 @@
FB6549541A57907E001C8F39 /* DiscoveryWorker.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DiscoveryWorker.h; sourceTree = ""; };
FB6549551A57907E001C8F39 /* DiscoveryWorker.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DiscoveryWorker.m; sourceTree = ""; };
FB6549621A60B4A9001C8F39 /* Limelight 0.3.1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Limelight 0.3.1.xcdatamodel"; sourceTree = ""; };
- FB6DDEF31AE739100096499A /* libmoonlight-common.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libmoonlight-common.a"; path = "limelight-common-c/build/Debug-iphoneos/libmoonlight-common.a"; sourceTree = ""; };
- FB7E794319C8B71B00A15F68 /* libiconv.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libiconv.dylib; path = usr/lib/libiconv.dylib; sourceTree = SDKROOT; };
FB89460519F646E200339C8A /* CryptoManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CryptoManager.h; sourceTree = ""; };
FB89460619F646E200339C8A /* CryptoManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CryptoManager.m; sourceTree = ""; };
FB89460719F646E200339C8A /* mkcert.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = mkcert.c; sourceTree = ""; };
@@ -194,83 +288,6 @@
FB89462619F646E200339C8A /* StreamFrameViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = StreamFrameViewController.h; sourceTree = ""; };
FB89462719F646E200339C8A /* StreamFrameViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = StreamFrameViewController.m; sourceTree = ""; };
FB89463719F6473800339C8A /* Launch Screen.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = "Launch Screen.xib"; sourceTree = SOURCE_ROOT; };
- FB89469419F6AFB800339C8A /* aes.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = aes.h; sourceTree = ""; };
- FB89469519F6AFB800339C8A /* asn1.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = asn1.h; sourceTree = ""; };
- FB89469619F6AFB800339C8A /* asn1_mac.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = asn1_mac.h; sourceTree = ""; };
- FB89469719F6AFB800339C8A /* asn1t.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = asn1t.h; sourceTree = ""; };
- FB89469819F6AFB800339C8A /* bio.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bio.h; sourceTree = ""; };
- FB89469919F6AFB800339C8A /* blowfish.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = blowfish.h; sourceTree = ""; };
- FB89469A19F6AFB800339C8A /* bn.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bn.h; sourceTree = ""; };
- FB89469B19F6AFB800339C8A /* buffer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = buffer.h; sourceTree = ""; };
- FB89469C19F6AFB800339C8A /* camellia.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = camellia.h; sourceTree = ""; };
- FB89469D19F6AFB800339C8A /* cast.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = cast.h; sourceTree = ""; };
- FB89469E19F6AFB800339C8A /* cmac.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = cmac.h; sourceTree = ""; };
- FB89469F19F6AFB800339C8A /* cms.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = cms.h; sourceTree = ""; };
- FB8946A019F6AFB800339C8A /* comp.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = comp.h; sourceTree = ""; };
- FB8946A119F6AFB800339C8A /* conf.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = conf.h; sourceTree = ""; };
- FB8946A219F6AFB800339C8A /* conf_api.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = conf_api.h; sourceTree = ""; };
- FB8946A319F6AFB800339C8A /* crypto.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = crypto.h; sourceTree = "