Skip to content

Add support for tvOS input - #65

Draft
omgitsalexl wants to merge 27 commits into
owenselles:mainfrom
omgitsalexl:tvos-controller-keyboard
Draft

Add support for tvOS input#65
omgitsalexl wants to merge 27 commits into
owenselles:mainfrom
omgitsalexl:tvos-controller-keyboard

Conversation

@omgitsalexl

Copy link
Copy Markdown

When streaming certain games, they require text input from the user to continue (e.g. Inputting a password to join a dedicated server in Enshrouded, Palworld, or other games). This pull request adds the ability to pull up the tvOS native keyboard, receive input, and then play this input back to the stream as a sequence of characters.

There are settings added to allow customizing the shortcut keys to pull up the native keyboard, and to adjust the hold time needed to display the keyboard; to help prevent crossover between games that may have the same button sequence.

@aarikmudgal
aarikmudgal self-requested a review July 10, 2026 14:00

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1c59d721b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CloudNow/Streaming/InputSender.swift Outdated
Comment on lines +1064 to +1065
if now >= state.deadline {
keyboardShortcutStates[slot] = nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Don't resuppress held shortcut buttons after timeout

When a user holds only one button from the text-input chord, such as the default Y button for an in-game hold action, the state is cleared as soon as the delay expires. On the next sample the still-held button starts a new shortcut state, so the button is suppressed again; the stream sees brief repeated taps instead of a continuous hold. After the timeout for an incomplete chord, keep that partial press in a pass-through state until all involved buttons are released.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, Tested in FH6. Could you make sure that if only part of the shortcut is held, the button continues behaving like a normal held button until it’s released? At the moment, it might be sent as repeated taps.

Comment thread CloudNow/Streaming/InputSender.swift Outdated
Comment on lines +1102 to +1103
if shortcutButtons == textInputTriggerSequence.asSet() {
notifyControllerKeyboardShortcut()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the hold delay before opening text entry

For the common case where the full chord is pressed together, this branch calls notifyControllerKeyboardShortcut() immediately instead of waiting for textInputTriggerDelayMs. That makes the new hold-delay setting ineffective and still lets quick accidental chord presses open the Apple TV keyboard, contrary to the setting description that the buttons must be held for the configured duration.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should apply the configured hold delay after the full shortcut is pressed? A quick press of the shortcut shouldn’t open the keyboard.

@aarikmudgal

Copy link
Copy Markdown
Collaborator

Hi @omgitsalexl Could you please run the SwiftLint, swiftformat on the branch while I test.

@aarikmudgal aarikmudgal added the enhancement New feature or request label Jul 10, 2026
@aarikmudgal
aarikmudgal requested a review from owenselles July 10, 2026 14:13

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dad7c125ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CloudNow/Streaming/InputSender.swift Outdated
let shortcutGraceWindow = UInt64(textInputTriggerDelayMs) * 1_000_000
let shortcutButtons = physicalKeyboardShortcutButtons(on: controller)
if var state = keyboardShortcutStates[slot] {
state.pendingButtons.formUnion(shortcutButtons)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't merge sequential shortcut taps into a chord

When a user taps one configured shortcut button, releases it, and then taps another before the delay expires, this line keeps the released button in pendingButtons. At the timeout, keyboardShortcutReplayButtons(for: state.pendingButtons) replays every accumulated button as one chord, so with the default Options+Y shortcut the stream receives a synthetic Options+Y tap instead of the two separate game inputs. Clear or replay the released partial before unioning later presses.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should preserve separate button presses here. If someone taps Y and then Options, the game should receive two individual taps rather than one combined shortcut.

Comment thread CloudNow/Streaming/InputSender.swift Outdated
Comment on lines +1559 to +1561
guard let (usage, requiresShift) = Self.usageForCharacter(character) else {
inputLog.info("Skipping unsupported replay character: \(String(character), privacy: .public)")
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't submit text after dropping unsupported characters

When the Apple TV keyboard returns characters outside this hard-coded HID map (for example accented letters, CJK IME output, or emoji), this path silently skips them; replaySubmittedText still appends Return afterward, so the remote game/chat can receive a truncated or empty submission. Either reject/disable sending when any character is unmapped, or use an input path that preserves the full String.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This I couldn't test yet but maybe you can validate the text before sending it? If it contains unsupported characters, keep the keyboard open and show an error instead of sending incomplete text.
Also, please avoid writing the character to logs since it could be part of a password.

@aarikmudgal

Copy link
Copy Markdown
Collaborator

@omgitsalexl I didn't get the chance to test in a game where I need to type which I will do in sometime, need to look for a game where I can input. But did high level testing and noticed that the review comments from the bots are valid. Also, maybe the UI overlay for the text input could be styled a bit better, like centre the text, etc. Also, after entering the text, and clicking on "done" closes the entire overlay, and I was never able to "send" it.

@aarikmudgal

Copy link
Copy Markdown
Collaborator

Codex:
I think the safest approach is to treat shortcut detection as a complete button gesture rather than accumulating every button seen during the delay. Once the full shortcut is pressed, start the hold timer and open the keyboard only if those buttons stay pressed for the configured duration. If the shortcut is incomplete or released early, forward the original button presses normally and preserve their order, so a held Y remains held, while Y followed by Options remains two separate taps.
Before replaying entered text, first confirm that every character can be converted to a remote key event. If any character is unsupported, keep the text-entry screen open and show an error instead of sending truncated text or Enter. Raw characters should not be logged because the input may contain a password.

@omgitsalexl

Copy link
Copy Markdown
Author

I really appreciate the thorough review! I had only tested it myself in a very specific manner, inputting server details mostly.

Regarding the done button vs a send button: pressing done closes the window and sends input at the same time. I could definitely make this more clear to the user.

The Codex bot does indeed have some good points as well, and yours regarding the potential logging issues.

I'll take all these into account and try to get it cleaned up!

@aarikmudgal
aarikmudgal marked this pull request as draft July 11, 2026 09:48
@aarikmudgal aarikmudgal self-assigned this Jul 11, 2026
@omgitsalexl

Copy link
Copy Markdown
Author

Both swiftlint and swiftformat pass on my system with no files requiring of formatting and no violations. I have verified that my .swiftformat file matches that within the upstream repo. I am not sure why the CI is failing since "it works on my machine" 😅

@aarikmudgal

Copy link
Copy Markdown
Collaborator

@omgitsalexl By chance did you check the failing logs?

Running SwiftFormat...
(lint mode - no files will be changed.)
SwiftFormat completed in 2.3s.
Warning: Remove redundant explicit Sendable conformance from non-public structs and enums. (redundantSendable)
Source input did not pass lint check.

Warning: Remove redundant explicit Sendable conformance from non-public structs and enums. (redundantSendable)
1/72 files require formatting.

Let me test it once

@aarikmudgal

aarikmudgal commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

here's the fixed file @omgitsalexl
InputSender.swift.zip

Since I can edit the PR, you can extract and push it.

@omgitsalexl

Copy link
Copy Markdown
Author

I think I did a poor job getting across what I had meant in my comment regarding the failure; my apologies.

I had seen that the logs pointed towards Sendable being the culprit. My confusion, or questioning, was more along the lines of why the CI would give different output than the same rules file ran from my machine.

Hope that makes sense!

@aarikmudgal

Copy link
Copy Markdown
Collaborator

I think I did a poor job getting across what I had meant in my comment regarding the failure; my apologies.

I had seen that the logs pointed towards Sendable being the culprit. My confusion, or questioning, was more along the lines of why the CI would give different output than the same rules file ran from my machine.

Hope that makes sense!

Yes indeed and most probably that is happening because you might be using different version of swiftlint and Swift-Format locally as compared to CI, you should check the documentation. I think somewhere it's mentioned which exact versions to use. Hope this helps.

@aarikmudgal

Copy link
Copy Markdown
Collaborator

@omgitsalexl

These commands require the exact tool versions pinned by CI: SwiftFormat 0.61.1 and SwiftLint 0.63.3. See Linting for installation and version details.

From Readme.md

@omgitsalexl
omgitsalexl marked this pull request as ready for review July 20, 2026 03:09

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f69c24e9aa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CloudNow/UI/StreamView.swift Outdated
Comment on lines +391 to +392
let shouldPause = state == .textEntry
|| (state == .pauseMenu && streamController.remoteMode != .gamepadMouse)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pause gamepad input while the pause menu is open

When Remote Mode is gamepadMouse, opening the pause menu leaves shouldPause false, but InputSender.tick() still forwards every extended-controller snapshot regardless of remote mode while VideoSurfaceViewRepresentable also enables controller focus for the overlay. In that mode, using the controller D-pad/A to navigate or select pause-menu buttons also sends those buttons to the game underneath; only the Siri Remote mouse is suppressed by overlayVisible. The pause menu should still pause controller/gamepad forwarding in this mode.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8db6d89909

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +706 to +708
.onExitCommand {
dismiss()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't dismiss while recording shortcut buttons

When the capture screen is active, pressing any controller button that tvOS maps to Exit/Back (for example B/Circle, and often Menu) invokes this handler and dismisses the screen before monitorControllerButtons() can save the sequence. The streaming path explicitly disables tvOS system gestures for these same buttons in InputSender.claimControllerInput, but this settings flow never does, so some advertised shortcut buttons cannot actually be configured. Gate this exit handler while isCapturing or claim/release controller gestures for the capture session.

Useful? React with 👍 / 👎.

@aarikmudgal

Copy link
Copy Markdown
Collaborator

The latest commit fixes the narrow Back/Menu capture issue, but 2 P1 and 5 P2 issues remain.

🔴 [P1] Preserve button history while resolving the shortcut chord

Location: CloudNow/Streaming/InputSender.swift:1114-1164

The shortcut state machine still does not fully satisfy the requested input behavior:

  1. Hold Y: the game receives Y down.
  2. Press Options: the complete chord is detected, both buttons are masked, and the game receives Y up.
  3. Release Options before the hold deadline: the code replays Y + Options as a simultaneous 17 ms tap.
  4. The still-held Y is then sent down again.

This interrupts the original Y hold, changes event order, and can trigger an unintended combined game action. A successful staggered chord can also leak the initial Y tap before opening text entry.

Required change: Buffer ordered shortcut-button transitions while the gesture is unresolved. If the chord succeeds, consume them. If it fails or releases early, replay the original down/up edges in order and preserve any currently held button continuously.

🔴 [P1] Make submitted text respect the configured keyboard layout

Location: CloudNow/Streaming/InputSender.swift:1884-1954

keyboardReplayPlan uses a fixed US HID/scancode mapping, but the remote session is configured using settings.keyboardLayout, and that layout is not passed into InputSender.

This silently produces incorrect text for non-US layouts, including:

  • German Y/Z
  • UK @/"
  • French AZERTY digits and punctuation
  • Symbols requiring AltGr

This can corrupt usernames or passwords and still submit them with Enter.

Required change: Pass the selected layout into InputSender and use a layout-aware reverse character map, including AltGr/dead-key handling, or use protocol-level Unicode text injection. At minimum, reject unsupported layouts instead of silently sending incorrect text.

Focused tests should cover at least en-US, en-GB, de-DE, and fr-FR.

🟡 [P2] Provide a way to cancel controller capture

Location: CloudNow/UI/SettingsView.swift:706-713

The latest guard !isCapturing correctly prevents B/Circle/Menu from dismissing the screen before capture, but it can now trap the user:

  • After Start Listening, all controls disappear.
  • Back/Menu is swallowed.
  • There is no Cancel button or timeout.
  • With no extended controller—or after controller disconnect—the screen remains in .waiting indefinitely.

Required change: Add a cancellation/timeout path and explicit no-controller handling. Prefer temporarily claiming the selected controller’s system gestures while leaving a Siri Remote or UI cancellation route available.

🟡 [P2] Revalidate the shortcut when related settings change

Locations:

  • CloudNow/UI/SettingsView.swift:291-308
  • CloudNow/UI/SettingsView.swift:359-367

Shortcut conflicts are validated only during capture. A valid single-button shortcut can become invalid later when the user:

  • Changes overlayTriggerButton, or
  • Enables the Steam overlay gesture.

That can make the text shortcut permanently consume the configured overlay or Steam button.

Required change: Revalidate or reset the sequence whenever either related setting changes. Conflicting persisted settings should also be normalized during decoding.

🟡 [P2] Capture from the controller that actually produces input

Location: CloudNow/UI/SettingsView.swift:837-840

Capture always polls the first extended controller. Controllers 2–4 are ignored despite CloudNow supporting four simultaneous controllers.

Pressing buttons on another connected controller also leads directly into the non-cancellable capture state described above.

Required change: Detect and pin whichever connected controller first produces input for the capture session, or explicitly let the user select a controller.

🟡 [P2] Add the new localization keys to every locale

Location: CloudNow/Localization/L10nEN.swift:145-238

All 40 new keys exist only in English. Each of the 33 non-English locale tables is missing all 40 keys.

This produces mixed-English UI and violates the repository requirement in README.md:48:

Every locale table must contain the complete English key set.

Required change: Add all new keys to every locale table. A parity check comparing each table against L10nEN should also be added to prevent recurrence.

🟡 [P2] Disable automatic text transformations

Location: CloudNow/UI/StreamView.swift:493-501

The generic remote text field uses the default autocorrection and capitalization behavior. These can modify usernames, passwords, commands, or other exact game input before replay.

Required change:

TextField(
    L10n.text("controller_text_entry_placeholder"),
    text: $textEntryText
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)

🟢 [P3] Bound or make long text replay cancellable

Location: CloudNow/Streaming/InputSender.swift:1478-1521

Replay length is unlimited. Each character generates 2–4 events at 18 ms intervals while all game input remains paused.

A 1,000-character lowercase paste takes roughly 36 seconds. The overlay closes immediately, and the user has no progress indicator or cancellation path while controller input remains blocked.

Recommended change: Apply a character/event limit, or keep replay visible with progress and cancellation support.

Previous feedback status

Confirmed fixed

  • A partial shortcut that never completes no longer becomes repeated taps.
  • The full shortcut chord now honors the configured hold delay.
  • Non-overlapping Y followed by Options taps remain separate.
  • Unsupported text is fully validated before replay.
  • Enter is not sent after unsupported text is rejected.
  • Unsupported input keeps the overlay open and shows an error.
  • Raw submitted characters are not logged.
  • Done moves focus to Send instead of closing the entire overlay.
  • Pause-menu controller leakage, including gamepadMouse, is fixed.
  • The latest Back/Menu capture dismissal concern is narrowly fixed.

Still incomplete

  • Early-release and overlapping chord event ordering.
  • Held-button continuity when a shortcut attempt fails.
  • Preventing the first shortcut button from leaking into the game.
  • Capture cancellation.
  • Multi-controller capture.
  • Non-US keyboard-layout support.

@aarikmudgal
aarikmudgal marked this pull request as draft July 31, 2026 07:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants