diff --git a/README.md b/README.md index 989113cd0..74d6ad854 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,226 @@ +# JetKVM Android Support Fork + +> [!NOTE] +> This Android support fork is work in progress. The current branch is usable +> for local testing, but it is still being refined through real device testing. +> Bug reports, focused review, and help with Android target/controller edge +> cases are welcome. + +This fork adds an Android-focused control path to JetKVM. It is built for the +case where the JetKVM target is an Android device and the operator wants the +same practical control surface that a physical touchscreen, keyboard, and mouse +would provide. + +The current branch is based on a validated known-good touchscreen and aspect +baseline. The important baseline properties are: + +- Android digitizer input is routed through USB HID touchscreen emulation. +- Touch coordinates are aligned with the captured video and feel smooth in use. +- The Android target aspect ratio is preserved for the phone controller view. +- Desktop JetKVM usability remains available for non-Android workflows. + +## What This Fork Adds + +- **Android USB touchscreen target support** - JetKVM can expose a direct-touch + HID digitizer path for Android targets instead of treating touch as generic + mouse input. +- **Android controller APK** - A native Android wrapper for operating JetKVM + from a phone. It supplies the Android-specific login flow, immersive view, + native OSK integration, and compact controller defaults. +- **Compact controller mode** - The phone controller gets a reduced UI without + the desktop chrome and button strip. Android-only controls are placed in a + draggable floating menu. +- **Floating control overlay** - The overlay includes target actions such as + paste text, virtual media, Wake-on-LAN, virtual keyboard, display toggle, + logout, settings, and connection tools while preserving the video surface for + touch input. +- **Native Android login activity** - The controller APK owns the login + experience instead of relying on the vanilla web auth page. This keeps + password managers and Autofill useful on the controller phone. +- **Android OSK bridge** - In the controller APK, the compact virtual keyboard + action opens Android's own input method and forwards committed text through + JetKVM's existing HID keyboard macro path. Desktop browsers still use the + regular web virtual keyboard. +- **Display toggle for Android targets** - The UI can send a harmless HID key + event to wake the display, or the Android display power shortcut when + available. +- **Relative HID wheel scrolling** - Mouse wheel input is routed through the + relative HID mouse path when Android exposes one, preserving touchscreen + alignment while restoring useful wheel behavior. +- **Android target companion APK** - A small target-side helper runs on the + Android phone being controlled. It handles the Android keyguard edge cases + that USB HID input alone cannot solve, without using root, ADB, Accessibility, + Shizuku, or screen capture. + +## How It Works + +The backend keeps JetKVM's normal video, WebRTC, keyboard, virtual media, and +device-management paths. Android-specific input is layered on top where it is +needed: + +1. The JetKVM device exposes HID endpoints suitable for an Android target. +2. Touchscreen events from the viewer are mapped to the captured Android frame + and sent through the absolute HID digitizer path. +3. Wheel events use the relative mouse HID path when present, because Android + handles wheel scrolling differently from direct touchscreen gestures. +4. The Android controller APK identifies itself to the backend by opening the + controller URL with Android compact-mode parameters. +5. The controller APK replaces the web auth page with a native login activity + so Autofill and Android keyboard behavior work naturally. +6. The compact overlay keeps Android-only actions close to the controller view + without polluting the desktop JetKVM interface. +7. The companion APK pairs with trusted JetKVM devices, watches Android's own + view of the paired JetKVM USB/display identity, and grants the backend a + short Android target lease only while that physical evidence is present. + +## Why This Exists + +Vanilla JetKVM is designed as a general KVM over IP. Android targets are +different enough that the generic desktop assumptions are not enough: + +- Android distinguishes direct touchscreen input from mouse input. +- A phone-shaped captured display needs strict aspect handling or touches drift. +- Android lockscreen behavior has policy boundaries that generic HID input + cannot always cross cleanly. +- A phone controller needs a different UI density from the desktop browser UI. +- Android users expect Autofill, the native OSK, and immersive full-screen app + behavior instead of a desktop-style login form. + +This fork keeps those Android-specific decisions explicit. The goal is not to +replace JetKVM's normal UI; it is to add a focused Android target/controller +path while leaving the vanilla experience recognizable. + +## Target Companion + +The companion APK is installed on the Android target, not on the controller +phone. Its job is narrow: make JetKVM-controlled Android targets recover +cleanly from display wake and soft keyguard states while keeping the trust and +presence boundary on the target device. + +JetKVM can send touchscreen, keyboard, mouse, wheel, and display-toggle input +over USB HID. That is enough once Android is interactive. The lockscreen is the +exception. On stock Android, secondary-display keyguard behavior is governed by +Android multi-display policy, and the external display shown through JetKVM may +not be allowed to dismiss a trusted keyguard purely from the external USB +digitizer. That is an Android policy boundary, not a broken touch coordinate +path. + +The companion uses public Android APIs and an authenticated pairing flow to +bridge that boundary: + +- Pairing establishes trust between the Android companion and one or more + JetKVM endpoints. The backend rejects companion target declarations unless + they authenticate with a paired companion token. +- Each JetKVM exposes a stable per-device identity through the USB gadget + serial/product strings and through the default EDID monitor name/serial. On + Android this appears in input devices such as `JetKVM USB Emulation Device + ` and in the external display name as `JKVM `. +- The companion stores the paired JetKVM endpoint, paired token, and expected + JetKVM identity. +- Its foreground service reflects state: before pairing it waits for a device + to be paired, after pairing it waits for matching peripherals, and once + physical evidence is present it monitors display-on events. +- Presence is OR-based. Matching keyboard, digitizer/touchscreen, mouse/pointer, + or monitor evidence is enough to activate the companion path. +- While matching evidence remains visible to Android, the companion refreshes an + authenticated Android target lease to the paired JetKVM. The lease reports the + JetKVM identity, Android target type, preferred digitizer mode, display + dimensions/aspect, and the evidence list. Disconnection is reported + immediately when evidence disappears; lease expiry is the backend fallback for + companion crashes or network loss. +- When the target wakes, it launches a transparent `showWhenLocked` activity and + calls `KeyguardManager.requestDismissKeyguard()`. Android decides whether the + keyguard can be dismissed. +- Around display-on events, the companion briefly creates a transparent 1x1 + `Presentation` on the JetKVM external display to keep that display path awake + without dimming or presenting UI. +- It can optionally use Android's overlay permission as a non-touchable launch + assist so background wake-unlock remains reliable after the display turns on. + +What it deliberately does not do: + +- It does not inject input. +- It does not capture or read the screen. +- It does not use ADB. +- It does not require root, Shizuku, Accessibility, device-owner privileges, or + OEM-only APIs. +- It does not accept generic USB or generic display metadata as proof. Evidence + must match the paired JetKVM identity before Android mode is granted. + +The recommended mode is normal Android keyguard with the companion foreground +service installed, notification permission granted where Android requires it, +unrestricted battery enabled for reliability, at least one JetKVM paired, and +launch-on-boot enabled if the target should recover after reboots. A trusted +state such as Extend Unlock can make wake recovery automatic. Without a trusted +state, the companion can still bring up Android's credential bouncer after wake; +the user can then enter the PIN or password through JetKVM keyboard input or the +Android controller's OSK bridge. + +## Current Validation State + +The current support branch has been locally validated with: + +- Android digitizer touch input. +- Correct phone-controller aspect and crop behavior. +- Desktop viewer behavior preserved. +- Compact overlay actions. +- Display wake/toggle actions. +- Native Android login and logout flow. +- Autofill password entry with OSK collapse handling. +- Companion app permission UI cleanup. +- Relative HID mouse wheel scrolling. +- Controller APK Android OSK text forwarding. +- Authenticated companion pairing and Android lease renewal. +- JetKVM identity binding through USB gadget strings and EDID display identity. +- Companion activation from matching keyboard, digitizer, mouse, or monitor + evidence. + +Non-trivial changes to this fork should be built, deployed, and tested on the +JetKVM device plus the controller/target phones before being committed or +published. + +## Components + +- `jetkvm-android/` - Android controller APK. +- `jetkvm-companion/` - Android target companion APK. +- `ui/src/components/AndroidCompactControls.tsx` - compact Android controller + overlay. +- Backend HID/RPC changes live in the normal JetKVM backend tree. + +## Build Notes + +Build the JetKVM backend and device UI: + +```bash +make build_dev +``` + +Build the Android controller APK: + +```bash +./jetkvm-android/build.sh release +``` + +Build the Android companion APK: + +```bash +./jetkvm-companion/build.sh release +``` + +Install APKs with ADB as usual: + +```bash +adb install -r jetkvm-android/build/JetKVM-release.apk +adb install -r jetkvm-companion/build/JetKVM-Companion-release.apk +``` + +## Upstream README + +The section below is the vanilla upstream JetKVM README, kept intact for +project context. + +--- +
JetKVM logo diff --git a/config.go b/config.go index 32b3b659b..29f6d006a 100644 --- a/config.go +++ b/config.go @@ -1,6 +1,7 @@ package kvm import ( + "encoding/hex" "encoding/json" "fmt" "io" @@ -29,6 +30,16 @@ type WakeOnLanDevice struct { BroadcastIP string `json:"broadcastIP,omitempty"` } +type CompanionPairingConfig struct { + Token string `json:"token,omitempty"` + Tokens []string `json:"tokens,omitempty"` + Companions map[string]CompanionAuthorization `json:"companions,omitempty"` +} + +type CompanionAuthorization struct { + PublicKey string `json:"public_key"` +} + // Constants for keyboard macro limits const ( MaxMacrosPerDevice = 25 @@ -88,41 +99,43 @@ func (m *KeyboardMacro) Validate() error { } type Config struct { - CloudURL string `json:"cloud_url"` - UpdateAPIURL string `json:"update_api_url"` - CloudAppURL string `json:"cloud_app_url"` - CloudToken string `json:"cloud_token"` - TailscaleControlURL string `json:"tailscale_control_url,omitempty"` - GoogleIdentity string `json:"google_identity"` - JigglerEnabled bool `json:"jiggler_enabled"` - JigglerConfig *JigglerConfig `json:"jiggler_config"` - AutoUpdateEnabled bool `json:"auto_update_enabled"` - IncludePreRelease bool `json:"include_pre_release"` - HashedPassword string `json:"hashed_password"` - LocalAuthToken string `json:"local_auth_token"` - LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration - LocalLoopbackOnly bool `json:"local_loopback_only"` - WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` - KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` - KeyboardLayout string `json:"keyboard_layout"` - EdidString string `json:"hdmi_edid_string"` - ActiveExtension string `json:"active_extension"` - DisplayRotation string `json:"display_rotation"` - DisplayMaxBrightness int `json:"display_max_brightness"` - DisplayDimAfterSec int `json:"display_dim_after_sec"` - DisplayOffAfterSec int `json:"display_off_after_sec"` - TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", "" - UsbConfig *usbgadget.Config `json:"usb_config"` - UsbDevices *usbgadget.Devices `json:"usb_devices"` - NetworkConfig *types.NetworkConfig `json:"network_config"` - DefaultLogLevel string `json:"default_log_level"` - VideoSleepAfterSec int `json:"video_sleep_after_sec"` - VideoQualityFactor float64 `json:"video_quality_factor"` - VideoCodecPreference string `json:"video_codec_preference"` - HideDisplayWhenIdle bool `json:"host_display_disable_when_idle"` - NativeMaxRestart uint `json:"native_max_restart_attempts"` - MqttConfig *MQTTConfig `json:"mqtt_config"` - AudioEnabled bool `json:"audio_enabled"` + CloudURL string `json:"cloud_url"` + UpdateAPIURL string `json:"update_api_url"` + CloudAppURL string `json:"cloud_app_url"` + CloudToken string `json:"cloud_token"` + TailscaleControlURL string `json:"tailscale_control_url,omitempty"` + GoogleIdentity string `json:"google_identity"` + JigglerEnabled bool `json:"jiggler_enabled"` + JigglerConfig *JigglerConfig `json:"jiggler_config"` + AutoUpdateEnabled bool `json:"auto_update_enabled"` + IncludePreRelease bool `json:"include_pre_release"` + HashedPassword string `json:"hashed_password"` + LocalAuthToken string `json:"local_auth_token"` + CompanionPairing CompanionPairingConfig `json:"companion_pairing"` + LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration + LocalLoopbackOnly bool `json:"local_loopback_only"` + WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` + KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` + KeyboardLayout string `json:"keyboard_layout"` + EdidString string `json:"hdmi_edid_string"` + ActiveExtension string `json:"active_extension"` + DisplayRotation string `json:"display_rotation"` + TargetType string `json:"target_type"` + DisplayMaxBrightness int `json:"display_max_brightness"` + DisplayDimAfterSec int `json:"display_dim_after_sec"` + DisplayOffAfterSec int `json:"display_off_after_sec"` + TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", "" + UsbConfig *usbgadget.Config `json:"usb_config"` + UsbDevices *usbgadget.Devices `json:"usb_devices"` + NetworkConfig *types.NetworkConfig `json:"network_config"` + DefaultLogLevel string `json:"default_log_level"` + VideoSleepAfterSec int `json:"video_sleep_after_sec"` + VideoQualityFactor float64 `json:"video_quality_factor"` + VideoCodecPreference string `json:"video_codec_preference"` + HideDisplayWhenIdle bool `json:"host_display_disable_when_idle"` + NativeMaxRestart uint `json:"native_max_restart_attempts"` + MqttConfig *MQTTConfig `json:"mqtt_config"` + AudioEnabled bool `json:"audio_enabled"` } // GetUpdateAPIURL returns the update API URL @@ -176,13 +189,92 @@ var ( AbsoluteMouse: true, RelativeMouse: true, Keyboard: true, + Touchscreen: true, MassStorage: true, Audio: true, } ) +func applyUsbIdentityDefaults(c *Config) { + if c == nil || c.UsbConfig == nil { + return + } + + deviceID := strings.ToLower(strings.TrimSpace(GetDeviceID())) + if deviceID == "" || deviceID == "unknown_device_id" { + return + } + + if strings.TrimSpace(c.UsbConfig.SerialNumber) == "" { + c.UsbConfig.SerialNumber = deviceID + } + + shortID := deviceID + if len(shortID) > 8 { + shortID = shortID[:8] + } + + product := strings.TrimSpace(c.UsbConfig.Product) + if product == "" || product == defaultUsbConfig.Product || product == "JetKVM USB Emulation Device" { + c.UsbConfig.Product = fmt.Sprintf("%s %s", defaultUsbConfig.Product, shortID) + } +} + +func getDeviceDefaultEDID() string { + deviceID := strings.ToLower(strings.TrimSpace(GetDeviceID())) + if deviceID == "" || deviceID == "unknown_device_id" { + return native.DefaultEDID + } + + shortID := deviceID + if len(shortID) > 8 { + shortID = shortID[:8] + } + + edid, err := hex.DecodeString(native.DefaultEDID) + if err != nil || len(edid) < 128 { + return native.DefaultEDID + } + + if serial, err := strconv.ParseUint(shortID, 16, 32); err == nil && len(edid) >= 16 { + edid[12] = byte(serial) + edid[13] = byte(serial >> 8) + edid[14] = byte(serial >> 16) + edid[15] = byte(serial >> 24) + } + + const descriptorLength = 18 + name := "JKVM " + shortID + nameBytes := []byte(name) + if len(nameBytes) > 13 { + nameBytes = nameBytes[:13] + } + for i := 54; i+descriptorLength <= len(edid) && i < 126; i += descriptorLength { + if edid[i] == 0x00 && edid[i+1] == 0x00 && edid[i+2] == 0x00 && edid[i+3] == 0xfc && edid[i+4] == 0x00 { + for j := 0; j < 13; j++ { + edid[i+5+j] = 0x20 + } + copy(edid[i+5:i+18], nameBytes) + if len(nameBytes) < 13 { + edid[i+5+len(nameBytes)] = 0x0a + } + break + } + } + + for block := 0; block+127 < len(edid); block += 128 { + sum := 0 + for i := 0; i < 127; i++ { + sum += int(edid[block+i]) + } + edid[block+127] = byte((256 - (sum % 256)) % 256) + } + + return hex.EncodeToString(edid) +} + func getDefaultConfig() Config { - return Config{ + c := Config{ CloudURL: DefaultAPIURL, UpdateAPIURL: DefaultAPIURL, CloudAppURL: "https://app.jetkvm.com", @@ -190,6 +282,7 @@ func getDefaultConfig() Config { ActiveExtension: "", KeyboardMacros: []KeyboardMacro{}, DisplayRotation: "270", + TargetType: "generic", KeyboardLayout: "en-US", DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes @@ -217,6 +310,8 @@ func getDefaultConfig() Config { DebounceMs: 500, }, } + applyUsbIdentityDefaults(&c) + return c } var ( @@ -302,6 +397,7 @@ func LoadConfig() { if loadedConfig.MqttConfig == nil { loadedConfig.MqttConfig = getDefaultConfig().MqttConfig } + applyUsbIdentityDefaults(&loadedConfig) // fixup old keyboard layout value if loadedConfig.KeyboardLayout == "en_US" { @@ -311,7 +407,7 @@ func LoadConfig() { // Until rolling logs land, do not persist verbose levels across reboots. loadedConfig.DefaultLogLevel = "WARN" - // Migrate prior JetKVM defaults to the current native.DefaultEDID: + // Migrate prior JetKVM defaults to the current device-specific default EDID: // - Toshiba TSB chip default (pre-JetKVM-v1 EDID, no CEA extension) // - JetKVM v1 EDID without the 1280x720@120 DTD (the previous default that // advertised only 1080p60 + 720p60 in the base block) @@ -320,11 +416,14 @@ func LoadConfig() { const tsbDefaultEDID = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b" const jkvV1NoHighRefresh = "00ffffffffffff0028b4010001eeffc0302301038047287856ee91a3544c99260f5054000000d1c081c0318001010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fd00174c0f5111000a202020202020000000fc004a65744b564d2076310a202020011d020322d1431004012309070783010000e200cfe40d100401e305000065030c001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cf" const jkvV1CtaOnly120 = "00ffffffffffff0028b4010001eeffc0302301038047287856ee91a3544c99260f5054000000d1c081c0318001010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fd00174c0f5111000a202020202020000000fc004a65744b564d2076310a202020011d020322d1431004012309070783010000e200cfe40d100401e305000065030c001000773300a050d02b2030203500122c2100001a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c" + const jetKVM720pDefaultEDID = "00ffffffffffff0028b402000100000030230103801009780a0dc9a05747982712484c00010101010101010101010101010101010101011d007251d01e206e285500a05a0000001e000000fd00323c1e4b08000a202020202020000000fc004a65744b564d20373230700a20000000ff0044454255473732300a2020202000a9" if loadedConfig.EdidString == "" || strings.EqualFold(loadedConfig.EdidString, tsbDefaultEDID) || strings.EqualFold(loadedConfig.EdidString, jkvV1NoHighRefresh) || - strings.EqualFold(loadedConfig.EdidString, jkvV1CtaOnly120) { - loadedConfig.EdidString = native.DefaultEDID + strings.EqualFold(loadedConfig.EdidString, jkvV1CtaOnly120) || + strings.EqualFold(loadedConfig.EdidString, native.DefaultEDID) || + strings.EqualFold(loadedConfig.EdidString, jetKVM720pDefaultEDID) { + loadedConfig.EdidString = getDeviceDefaultEDID() } config = &loadedConfig diff --git a/dynamic_display_mode.go b/dynamic_display_mode.go new file mode 100644 index 000000000..83a2483c2 --- /dev/null +++ b/dynamic_display_mode.go @@ -0,0 +1,478 @@ +package kvm + +import ( + "encoding/hex" + "fmt" + "strings" + "sync" + + "github.com/jetkvm/kvm/internal/native" +) + +const dynamicDisplayRefreshHz = 60 +const fallbackCropDisplayWidth = 1920 +const fallbackCropDisplayHeight = 1080 + +const hdmiReconnectNotice = "Using 1920x1080 crop mode. Disconnect and reconnect HDMI to apply the new display size." + +type DisplayMode struct { + Width int `json:"width"` + Height int `json:"height"` + RefreshHz int `json:"refresh_hz"` + Source string `json:"source,omitempty"` +} + +type DisplayModeStatus struct { + CompanionTarget TargetMetadata `json:"companion_target"` + AdvertisedMode *DisplayMode `json:"advertised_edid_mode,omitempty"` + ActualInputMode native.VideoState `json:"actual_hdmi_input_mode"` + ActiveVideoStreamMode native.VideoState `json:"active_webrtc_video_stream"` + HDMIReconnectRequired bool `json:"hdmi_reconnect_required"` + FallbackDisplayMode *DisplayMode `json:"fallback_display_mode,omitempty"` + CompanionNotice string `json:"companion_notice,omitempty"` +} + +var dynamicDisplayModeState = struct { + sync.Mutex + mode *DisplayMode + edid string + hdmiReconnectRequired bool +}{} + +func applyDisplayModeForTarget(metadata TargetMetadata) { + if metadata.TargetType != "android" || !metadata.Fresh || metadata.DisplayWidth <= 0 || metadata.DisplayHeight <= 0 { + if metadata.TargetType == "android" && metadata.Source == "companion" { + applyDefaultEDIDFallback("companion target inactive", true) + } + return + } + + mode := selectCompanionDisplayMode(metadata) + edid, err := buildDynamicDisplayEDID(mode) + if err != nil { + logger.Warn(). + Err(err). + Int("display_width", metadata.DisplayWidth). + Int("display_height", metadata.DisplayHeight). + Msg("failed to build companion display EDID, restoring default EDID") + applyDefaultEDIDFallback("dynamic EDID build failed", true) + return + } + + dynamicDisplayModeState.Lock() + alreadyApplied := dynamicDisplayModeState.edid == edid + if alreadyApplied { + modeCopy := mode + dynamicDisplayModeState.mode = &modeCopy + dynamicDisplayModeState.hdmiReconnectRequired = !actualInputMatchesDisplayMode(mode) + } + dynamicDisplayModeState.Unlock() + if alreadyApplied { + return + } + + logger.Warn(). + Int("width", mode.Width). + Int("height", mode.Height). + Int("refresh_hz", mode.RefreshHz). + Str("source", mode.Source). + Int("target_width", metadata.DisplayWidth). + Int("target_height", metadata.DisplayHeight). + Float64("target_aspect", metadata.DisplayAspect). + Int("edid_bytes", len(edid)/2). + Msg("applying companion-derived EDID display mode") + + if err := nativeInstance.VideoSetEDID(edid); err != nil { + logger.Warn(). + Err(err). + Int("width", mode.Width). + Int("height", mode.Height). + Int("refresh_hz", mode.RefreshHz). + Msg("failed to apply companion-derived EDID display mode") + return + } + + reenumerationRequired := config.EdidString != edid + if reenumerationRequired { + config.EdidString = edid + if err := SaveConfig(); err != nil { + logger.Warn(). + Err(err). + Int("width", mode.Width). + Int("height", mode.Height). + Int("refresh_hz", mode.RefreshHz). + Msg("failed to persist companion-derived EDID display mode") + return + } + logger.Warn(). + Int("width", mode.Width). + Int("height", mode.Height). + Int("refresh_hz", mode.RefreshHz). + Msg("persisted companion-derived EDID display mode") + } + + dynamicDisplayModeState.Lock() + dynamicDisplayModeState.mode = &mode + dynamicDisplayModeState.edid = edid + dynamicDisplayModeState.hdmiReconnectRequired = !actualInputMatchesDisplayMode(mode) + dynamicDisplayModeState.Unlock() + + if reenumerationRequired { + logger.Warn().Msg("companion display EDID changed; physical HDMI reconnect required for Android to use it") + } +} + +func applyDefaultEDIDFallback(reason string, reenumerate bool) { + defaultEDID := getDeviceDefaultEDID() + + dynamicDisplayModeState.Lock() + hadDynamicMode := dynamicDisplayModeState.edid != "" + dynamicDisplayModeState.mode = nil + dynamicDisplayModeState.edid = "" + dynamicDisplayModeState.hdmiReconnectRequired = false + dynamicDisplayModeState.Unlock() + + logger.Warn(). + Bool("had_dynamic_mode", hadDynamicMode). + Str("reason", reason). + Msg("restoring default EDID fallback") + if err := nativeInstance.VideoSetEDID(defaultEDID); err != nil { + logger.Warn().Err(err).Msg("failed to restore default EDID fallback") + return + } + + reenumerationRequired := reenumerate && (hadDynamicMode || config.EdidString != defaultEDID) + if config.EdidString != defaultEDID { + config.EdidString = defaultEDID + if err := SaveConfig(); err != nil { + logger.Warn().Err(err).Msg("failed to persist default EDID fallback") + return + } + } + + if reenumerationRequired { + logger.Warn().Msg("default EDID restored; physical HDMI reconnect required for Android to use it") + } +} + +func selectCompanionDisplayMode(metadata TargetMetadata) DisplayMode { + width := metadata.DisplayWidth + height := metadata.DisplayHeight + source := "companion" + + const maxDynamicDisplayLongEdge = 1600 + longEdge := maxInt(width, height) + if longEdge > maxDynamicDisplayLongEdge { + width = roundToMultiple(maxInt(1, width*maxDynamicDisplayLongEdge/longEdge), 8) + height = roundToMultiple(maxInt(1, height*maxDynamicDisplayLongEdge/longEdge), 8) + source = "companion-aspect-scaled" + } + + return DisplayMode{ + Width: width, + Height: height, + RefreshHz: dynamicDisplayRefreshHz, + Source: source, + } +} + +func restoreDefaultEDIDIfAndroidModeIsUnleased(reason string, reenumerate bool) bool { + paired := len(companionAuthorizations()) > 0 + freshLease := hasFreshCompanionLease() + if paired && freshLease { + return false + } + clearCompanionTargetMetadata() + if !isCompanionGeneratedEDID(config.EdidString) { + return false + } + + applyDefaultEDIDFallback(reason, reenumerate) + return true +} + +func isCompanionGeneratedEDID(edidHex string) bool { + edid, err := hex.DecodeString(strings.TrimSpace(edidHex)) + if err != nil || len(edid) != 128 || edid[126] != 0 { + return false + } + for i := 35; i <= 37; i++ { + if edid[i] != 0 { + return false + } + } + for i := 38; i <= 53; i++ { + if edid[i] != 0x01 { + return false + } + } + return true +} + +func getDisplayModeStatus() DisplayModeStatus { + dynamicDisplayModeState.Lock() + var mode *DisplayMode + if dynamicDisplayModeState.mode != nil { + modeCopy := *dynamicDisplayModeState.mode + mode = &modeCopy + } + hdmiReconnectRequired := dynamicDisplayModeState.hdmiReconnectRequired + dynamicDisplayModeState.Unlock() + hdmiReconnectRequired = hdmiReconnectRequired && mode != nil && !actualInputMatchesDisplayMode(*mode) + + return DisplayModeStatus{ + CompanionTarget: getEffectiveTargetMetadata(), + AdvertisedMode: mode, + ActualInputMode: lastVideoState, + ActiveVideoStreamMode: lastVideoState, + HDMIReconnectRequired: hdmiReconnectRequired, + FallbackDisplayMode: fallbackDisplayMode(hdmiReconnectRequired), + CompanionNotice: companionNotice(hdmiReconnectRequired), + } +} + +func withDisplayReconnectStatus(metadata TargetMetadata) TargetMetadata { + status := getDisplayModeStatus() + metadata.HDMIReconnectRequired = status.HDMIReconnectRequired + metadata.FallbackDisplayMode = status.FallbackDisplayMode + metadata.CompanionNotice = status.CompanionNotice + return metadata +} + +func actualInputMatchesDisplayMode(mode DisplayMode) bool { + return lastVideoState.Width == mode.Width && lastVideoState.Height == mode.Height +} + +func fallbackDisplayMode(enabled bool) *DisplayMode { + if !enabled { + return nil + } + return &DisplayMode{ + Width: fallbackCropDisplayWidth, + Height: fallbackCropDisplayHeight, + RefreshHz: dynamicDisplayRefreshHz, + Source: "hdmi-reconnect-crop-fallback", + } +} + +func companionNotice(enabled bool) string { + if !enabled { + return "" + } + return hdmiReconnectNotice +} + +func buildDynamicDisplayEDID(mode DisplayMode) (string, error) { + if mode.Width <= 0 || mode.Height <= 0 { + return "", fmt.Errorf("invalid display mode dimensions %dx%d", mode.Width, mode.Height) + } + if mode.Width > 4095 || mode.Height > 4095 { + return "", fmt.Errorf("display mode exceeds EDID detailed timing limit: %dx%d", mode.Width, mode.Height) + } + refreshHz := mode.RefreshHz + if refreshHz <= 0 { + refreshHz = dynamicDisplayRefreshHz + } + + defaultEDID, err := hex.DecodeString(getDeviceDefaultEDID()) + if err != nil { + return "", fmt.Errorf("decode device default EDID: %w", err) + } + if len(defaultEDID) < 128 { + return "", fmt.Errorf("unexpected device default EDID length %d", len(defaultEDID)) + } + + dtd, err := buildDetailedTimingDescriptor(mode.Width, mode.Height, refreshHz) + if err != nil { + return "", err + } + + edid := buildDisplayEDID(defaultEDID[:128], dtd, mode.Width, mode.Height) + fixEDIDChecksums(edid) + return hex.EncodeToString(edid), nil +} + +func buildDisplayEDID(defaultEDID, preferredDTD []byte, width, height int) []byte { + edid := make([]byte, 128) + copy(edid, defaultEDID) + + hImageMM, vImageMM := imageSizeMM(width, height) + edid[21] = byte(maxInt(1, hImageMM/10)) + edid[22] = byte(maxInt(1, vImageMM/10)) + + // Do not leave stock 16:9 timings in the generated EDID. Android otherwise + // keeps the stock modes as physical modes and only synthesizes anisotropic + // logical modes from the changed physical size. + for i := 35; i <= 37; i++ { + edid[i] = 0 + } + for i := 38; i <= 53; i++ { + edid[i] = 0x01 + } + copy(edid[54:72], preferredDTD) + copy(edid[72:90], displayRangeDescriptor(preferredDTD)) + copy(edid[90:108], monitorNameDescriptor(findEDIDTextDescriptor(defaultEDID, 0xfc))) + copy(edid[108:126], monitorSerialDescriptor(findEDIDTextDescriptor(defaultEDID, 0xff))) + edid[126] = 0 + + return edid +} + +func buildDetailedTimingDescriptor(width, height, refreshHz int) ([]byte, error) { + hFrontPorch := maxInt(48, roundToMultiple(width/20, 8)) + hSyncWidth := maxInt(32, roundToMultiple(width/10, 8)) + hBlank := maxInt(320, roundToMultiple(width*3/10, 8)) + if hBlank < hFrontPorch+hSyncWidth+32 { + hBlank = hFrontPorch + hSyncWidth + 32 + } + + vFrontPorch := 3 + vSyncWidth := 5 + vBlank := maxInt(30, height/50) + if vBlank < vFrontPorch+vSyncWidth+8 { + vBlank = vFrontPorch + vSyncWidth + 8 + } + + pixelClock10KHz := ((width + hBlank) * (height + vBlank) * refreshHz) / 10000 + if pixelClock10KHz <= 0 || pixelClock10KHz > 0xffff { + return nil, fmt.Errorf("display mode pixel clock out of EDID range: %dx%d@%d", width, height, refreshHz) + } + + hImageMM, vImageMM := imageSizeMM(width, height) + dtd := make([]byte, 18) + dtd[0] = byte(pixelClock10KHz) + dtd[1] = byte(pixelClock10KHz >> 8) + dtd[2] = byte(width) + dtd[3] = byte(hBlank) + dtd[4] = byte(((width >> 8) & 0x0f) << 4) + dtd[4] |= byte((hBlank >> 8) & 0x0f) + dtd[5] = byte(height) + dtd[6] = byte(vBlank) + dtd[7] = byte(((height >> 8) & 0x0f) << 4) + dtd[7] |= byte((vBlank >> 8) & 0x0f) + dtd[8] = byte(hFrontPorch) + dtd[9] = byte(hSyncWidth) + dtd[10] = byte((vFrontPorch & 0x0f) << 4) + dtd[10] |= byte(vSyncWidth & 0x0f) + dtd[11] = byte(((hFrontPorch >> 8) & 0x03) << 6) + dtd[11] |= byte(((hSyncWidth >> 8) & 0x03) << 4) + dtd[11] |= byte(((vFrontPorch >> 4) & 0x03) << 2) + dtd[11] |= byte((vSyncWidth >> 4) & 0x03) + dtd[12] = byte(hImageMM) + dtd[13] = byte(vImageMM) + dtd[14] = byte(((hImageMM >> 8) & 0x0f) << 4) + dtd[14] |= byte((vImageMM >> 8) & 0x0f) + dtd[17] = 0x1e + return dtd, nil +} + +func displayRangeDescriptor(preferredDTD []byte) []byte { + pixelClockMHz := int(preferredDTD[0]) | int(preferredDTD[1])<<8 + pixelClockMHz = (pixelClockMHz + 99) / 100 + if pixelClockMHz < 10 { + pixelClockMHz = 10 + } + if pixelClockMHz > 255 { + pixelClockMHz = 255 + } + + return []byte{ + 0x00, 0x00, 0x00, 0xfd, 0x00, + 20, 75, + 15, 160, + byte(pixelClockMHz), + 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + } +} + +func monitorNameDescriptor(defaultDescriptor []byte) []byte { + if len(defaultDescriptor) == 18 && defaultDescriptor[0] == 0 && defaultDescriptor[1] == 0 && defaultDescriptor[3] == 0xfc { + out := make([]byte, 18) + copy(out, defaultDescriptor) + return out + } + return textDescriptor(0xfc, "JKVM Dynamic") +} + +func monitorSerialDescriptor(defaultDescriptor []byte) []byte { + if len(defaultDescriptor) == 18 && defaultDescriptor[0] == 0 && defaultDescriptor[1] == 0 && defaultDescriptor[3] == 0xff { + out := make([]byte, 18) + copy(out, defaultDescriptor) + return out + } + return textDescriptor(0xff, "JetKVM") +} + +func textDescriptor(tag byte, text string) []byte { + out := []byte{ + 0x00, 0x00, 0x00, tag, 0x00, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + } + for i := 0; i < len(text) && i < 13; i++ { + out[5+i] = text[i] + } + if len(text) < 13 { + out[5+len(text)] = 0x0a + } + return out +} + +func findEDIDTextDescriptor(edid []byte, tag byte) []byte { + const descriptorLength = 18 + for i := 54; i+descriptorLength <= len(edid) && i < 126; i += descriptorLength { + if edid[i] == 0x00 && edid[i+1] == 0x00 && edid[i+2] == 0x00 && edid[i+3] == tag && edid[i+4] == 0x00 { + out := make([]byte, descriptorLength) + copy(out, edid[i:i+descriptorLength]) + return out + } + } + return nil +} + +func imageSizeMM(width, height int) (int, int) { + const longEdgeMM = 160 + if width >= height { + return longEdgeMM, maxInt(1, height*longEdgeMM/width) + } + return maxInt(1, width*longEdgeMM/height), longEdgeMM +} + +func fixEDIDChecksums(edid []byte) { + for block := 0; block+127 < len(edid); block += 128 { + sum := 0 + for i := 0; i < 127; i++ { + sum += int(edid[block+i]) + } + edid[block+127] = byte((256 - (sum % 256)) % 256) + } +} + +func roundUp(value, multiple int) int { + if multiple <= 0 || value == 0 { + return value + } + remainder := value % multiple + if remainder == 0 { + return value + } + return value + multiple - remainder +} + +func roundToMultiple(value, multiple int) int { + if multiple <= 0 { + return value + } + rounded := ((value + multiple/2) / multiple) * multiple + if rounded < multiple { + return multiple + } + return rounded +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/dynamic_display_mode_test.go b/dynamic_display_mode_test.go new file mode 100644 index 000000000..05ab09317 --- /dev/null +++ b/dynamic_display_mode_test.go @@ -0,0 +1,73 @@ +package kvm + +import ( + "encoding/hex" + "testing" +) + +func TestBuildDynamicDisplayEDIDUsesCompanionMode(t *testing.T) { + edidHex, err := buildDynamicDisplayEDID(DisplayMode{ + Width: 1080, + Height: 2400, + RefreshHz: 30, + Source: "companion", + }) + if err != nil { + t.Fatalf("buildDynamicDisplayEDID returned error: %v", err) + } + + edid, err := hex.DecodeString(edidHex) + if err != nil { + t.Fatalf("decode EDID: %v", err) + } + if len(edid) != 128 { + t.Fatalf("EDID length = %d, want 128", len(edid)) + } + + width, height := detailedTimingActiveSize(edid[54:72]) + if width != 1080 || height != 2400 { + t.Fatalf("preferred DTD = %dx%d, want 1080x2400", width, height) + } + for block := 0; block+127 < len(edid); block += 128 { + sum := 0 + for i := 0; i < 128; i++ { + sum += int(edid[block+i]) + } + if sum%256 != 0 { + t.Fatalf("EDID block %d checksum invalid: sum %% 256 = %d", block/128, sum%256) + } + } +} + +func TestBuildDynamicDisplayEDIDRejectsOversizedMode(t *testing.T) { + _, err := buildDynamicDisplayEDID(DisplayMode{ + Width: 5000, + Height: 2400, + RefreshHz: 30, + }) + if err == nil { + t.Fatal("expected oversized mode to fail") + } +} + +func TestSelectCompanionDisplayModeScalesLongEdge(t *testing.T) { + mode := selectCompanionDisplayMode(TargetMetadata{ + TargetType: "android", + Fresh: true, + DisplayWidth: 1080, + DisplayHeight: 2400, + }) + + if mode.Width != 720 || mode.Height != 1600 || mode.RefreshHz != 60 { + t.Fatalf("mode = %dx%d@%d, want 720x1600@60", mode.Width, mode.Height, mode.RefreshHz) + } + if mode.Source != "companion-aspect-scaled" { + t.Fatalf("source = %q, want companion-aspect-scaled", mode.Source) + } +} + +func detailedTimingActiveSize(dtd []byte) (int, int) { + width := int(dtd[2]) | (int(dtd[4]&0xf0) << 4) + height := int(dtd[5]) | (int(dtd[7]&0xf0) << 4) + return width, height +} diff --git a/hidrpc.go b/hidrpc.go index 8c107626a..d8e45022e 100644 --- a/hidrpc.go +++ b/hidrpc.go @@ -47,6 +47,13 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) { return } rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button) + case hidrpc.TypeTouchscreenReport: + touchscreenReport, err := message.TouchscreenReport() + if err != nil { + logger.Warn().Err(err).Msg("failed to get touchscreen report") + return + } + rpcErr = rpcTouchscreenReport(touchscreenReport.X, touchscreenReport.Y, touchscreenReport.Touching) case hidrpc.TypeMouseReport: mouseReport, err := message.MouseReport() if err != nil { diff --git a/internal/hidrpc/hidrpc.go b/internal/hidrpc/hidrpc.go index 7313e3b53..6d9af6911 100644 --- a/internal/hidrpc/hidrpc.go +++ b/internal/hidrpc/hidrpc.go @@ -19,6 +19,7 @@ const ( TypeMouseReport MessageType = 0x06 TypeKeyboardMacroReport MessageType = 0x07 TypeCancelKeyboardMacroReport MessageType = 0x08 + TypeTouchscreenReport MessageType = 0x0a TypeKeyboardLedState MessageType = 0x32 TypeKeydownState MessageType = 0x33 TypeKeyboardMacroState MessageType = 0x34 @@ -35,7 +36,7 @@ func GetQueueIndex(messageType MessageType) int { return 0 case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardMacroReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroState: return 1 - case TypePointerReport, TypeMouseReport, TypeWheelReport: + case TypePointerReport, TypeMouseReport, TypeWheelReport, TypeTouchscreenReport: return 2 // we don't want to block the queue for this message case TypeCancelKeyboardMacroReport: diff --git a/internal/hidrpc/message.go b/internal/hidrpc/message.go index 3f3506f7f..c249c421d 100644 --- a/internal/hidrpc/message.go +++ b/internal/hidrpc/message.go @@ -148,6 +148,13 @@ type PointerReport struct { Button uint8 } +// TouchscreenReport is an absolute single-contact digitizer state. +type TouchscreenReport struct { + X int + Y int + Touching bool +} + func toInt(b []byte) int { return int(b[0])<<24 + int(b[1])<<16 + int(b[2])<<8 + int(b[3])<<0 } @@ -169,6 +176,23 @@ func (m *Message) PointerReport() (PointerReport, error) { }, nil } +// TouchscreenReport returns the touchscreen report from the message. +func (m *Message) TouchscreenReport() (TouchscreenReport, error) { + if m.t != TypeTouchscreenReport { + return TouchscreenReport{}, fmt.Errorf("invalid message type: %d", m.t) + } + + if len(m.d) != 9 { + return TouchscreenReport{}, fmt.Errorf("invalid message length: %d", len(m.d)) + } + + return TouchscreenReport{ + X: toInt(m.d[0:4]), + Y: toInt(m.d[4:8]), + Touching: m.d[8] != 0, + }, nil +} + // MouseReport .. type MouseReport struct { DX int8 diff --git a/internal/native/cgo_linux.go b/internal/native/cgo_linux.go index df30404a2..e91d9f5ee 100644 --- a/internal/native/cgo_linux.go +++ b/internal/native/cgo_linux.go @@ -422,9 +422,8 @@ func videoSetEDID(edid string) error { edidCStr := C.CString(edid) defer C.free(unsafe.Pointer(edidCStr)) - ret := C.jetkvm_video_set_edid(edidCStr) - if ret != 0 { - return fmt.Errorf("failed to set EDID: %d", ret) + if ret := C.jetkvm_video_set_edid(edidCStr); ret != 0 { + return fmt.Errorf("jetkvm_video_set_edid failed with code %d", int(ret)) } return nil } diff --git a/internal/native/proxy.go b/internal/native/proxy.go index 551625e70..d589f3900 100644 --- a/internal/native/proxy.go +++ b/internal/native/proxy.go @@ -24,7 +24,7 @@ import ( ) const ( - maxFrameSize = 1920 * 1080 / 2 + maxVideoFramePacketSize = 16 * 1024 * 1024 defaultMaxRestartAttempts uint = 5 ) @@ -265,7 +265,7 @@ func (p *NativeProxy) toProcessCommand() (*cmdWrapper, error) { func (p *NativeProxy) handleVideoFrame(conn net.Conn) { defer conn.Close() - inboundPacket := make([]byte, maxFrameSize) + inboundPacket := make([]byte, maxVideoFramePacketSize) var frameSizeBuffer [4]byte lastFrame := time.Now() @@ -280,8 +280,8 @@ func (p *NativeProxy) handleVideoFrame(conn net.Conn) { } frameSize := binary.LittleEndian.Uint32(frameSizeBuffer[:]) - if frameSize == 0 || frameSize > maxFrameSize { - p.logger.Error().Uint32("frameSize", frameSize).Uint32("maxFrameSize", maxFrameSize). + if frameSize == 0 || frameSize > maxVideoFramePacketSize { + p.logger.Error().Uint32("frameSize", frameSize).Uint32("maxFrameSize", maxVideoFramePacketSize). Msg("received invalid frame size") break } diff --git a/internal/usbgadget/config.go b/internal/usbgadget/config.go index 6af97b04e..10f3c620e 100644 --- a/internal/usbgadget/config.go +++ b/internal/usbgadget/config.go @@ -60,6 +60,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{ "relative_mouse": relativeMouseConfig, // USB audio sink "audio": audioConfig, + // touchscreen/digitizer HID + "touchscreen": touchscreenConfig, // mass storage "mass_storage_base": massStorageBaseConfig, "mass_storage_lun0": massStorageLun0Config, @@ -75,6 +77,10 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool { return u.enabledDevices.RelativeMouse case "keyboard": return u.enabledDevices.Keyboard + case "wake_hid": + return !u.enabledDevices.Touchscreen + case "touchscreen": + return u.enabledDevices.Touchscreen case "mass_storage_base": return u.enabledDevices.MassStorage case "mass_storage_lun0": diff --git a/internal/usbgadget/config_test.go b/internal/usbgadget/config_test.go index 9e1bff5db..b06b6382a 100644 --- a/internal/usbgadget/config_test.go +++ b/internal/usbgadget/config_test.go @@ -1,6 +1,11 @@ package usbgadget -import "testing" +import ( + "strings" + "testing" + + "github.com/rs/zerolog" +) func TestAudioGadgetConfigFollowsEnabledDevice(t *testing.T) { u := &UsbGadget{enabledDevices: Devices{Audio: false}} @@ -16,9 +21,113 @@ func TestAudioGadgetConfigFollowsEnabledDevice(t *testing.T) { func TestBaseGadgetConfigItemsAlwaysEnabled(t *testing.T) { u := &UsbGadget{} - for _, item := range []string{"base", "base_info", "wake_hid"} { + for _, item := range []string{"base", "base_info"} { if !u.isGadgetConfigItemEnabled(item) { t.Fatalf("%s should always be enabled", item) } } } + +func TestWakeHIDUsesTouchscreenSlotOnlyWhenTouchscreenDisabled(t *testing.T) { + u := &UsbGadget{enabledDevices: Devices{Touchscreen: false}} + if !u.isGadgetConfigItemEnabled("wake_hid") { + t.Fatal("wake_hid should be enabled when touchscreen is disabled") + } + + u.enabledDevices.Touchscreen = true + if u.isGadgetConfigItemEnabled("wake_hid") { + t.Fatal("wake_hid should be disabled when touchscreen uses the final HID slot") + } +} + +func TestEnabledGadgetConfigPathsAreUnique(t *testing.T) { + for name, devices := range map[string]Devices{ + "touchscreen": { + AbsoluteMouse: true, + RelativeMouse: true, + Keyboard: true, + Touchscreen: true, + MassStorage: true, + SerialConsole: true, + Audio: true, + }, + "wake_hid": { + AbsoluteMouse: true, + RelativeMouse: true, + Keyboard: true, + Touchscreen: false, + MassStorage: true, + SerialConsole: true, + Audio: true, + }, + } { + t.Run(name, func(t *testing.T) { + assertEnabledGadgetConfigPathsAreUnique(t, devices) + }) + } +} + +func assertEnabledGadgetConfigPathsAreUnique(t *testing.T, devices Devices) { + t.Helper() + + u := &UsbGadget{enabledDevices: devices} + + paths := map[string]string{} + configPaths := map[string]string{} + + for key, item := range defaultGadgetConfig { + if !u.isGadgetConfigItemEnabled(key) { + continue + } + + if len(item.path) > 0 { + pathKey := strings.Join(item.path, "/") + if previous, ok := paths[pathKey]; ok { + t.Fatalf("%s and %s share gadget path %s", previous, key, pathKey) + } + paths[pathKey] = key + } + + if len(item.configPath) > 0 { + configPathKey := strings.Join(item.configPath, "/") + if previous, ok := configPaths[configPathKey]; ok { + t.Fatalf("%s and %s share gadget config path %s", previous, key, configPathKey) + } + configPaths[configPathKey] = key + } + } +} + +func TestDisabledSharedConfigPathIsNotRemovedWhenEnabledItemUsesIt(t *testing.T) { + logger := zerolog.Nop() + tx := &UsbGadgetTransaction{ + c: &ChangeSet{}, + log: &logger, + kvmGadgetPath: "/sys/kernel/config/usb_gadget/test", + configC1Path: "/sys/kernel/config/usb_gadget/test/configs/c.1", + orderedConfigItems: orderedGadgetConfigItems{{"wake_hid", wakeHIDConfig}, {"touchscreen", touchscreenConfig}}, + isGadgetConfigItemEnabled: func(key string) bool { + return key == "touchscreen" + }, + } + + tx.WriteGadgetConfig() + + touchscreenConfigPath := "/sys/kernel/config/usb_gadget/test/configs/c.1/hid.usb3" + for _, change := range tx.c.Changes { + if change.Path == touchscreenConfigPath && change.ExpectedState == FileStateAbsent && change.When == "" { + t.Fatalf("disabled wake_hid should not request unconditional removal of enabled touchscreen config path: %s", change.String()) + } + } + + if tx.reorderSymlinkChanges == nil { + t.Fatal("expected touchscreen config path to be included in symlink reorder") + } + + for _, link := range tx.reorderSymlinkChanges.ParamSymlinks { + if link.Path == touchscreenConfigPath { + return + } + } + t.Fatalf("expected touchscreen config path %s in symlink reorder", touchscreenConfigPath) +} diff --git a/internal/usbgadget/config_tx.go b/internal/usbgadget/config_tx.go index f1e87a07f..d21a6a82e 100644 --- a/internal/usbgadget/config_tx.go +++ b/internal/usbgadget/config_tx.go @@ -152,12 +152,29 @@ func (tx *UsbGadgetTransaction) WriteGadgetConfig() { deps := make([]string, 0) deps = append(deps, tx.kvmGadgetPath) + enabledConfigPaths := map[string]struct{}{} + for _, val := range tx.orderedConfigItems { + if !tx.isGadgetConfigItemEnabled(val.key) { + continue + } + if val.item.configPath == nil || val.item.configAttrs != nil { + continue + } + enabledConfigPaths[joinPath(tx.configC1Path, val.item.configPath)] = struct{}{} + } + for _, val := range tx.orderedConfigItems { key := val.key item := val.item // check if the item is enabled in the config if !tx.isGadgetConfigItemEnabled(key) { + if item.configPath != nil && item.configAttrs == nil { + configPath := joinPath(tx.configC1Path, item.configPath) + if _, sharedWithEnabledItem := enabledConfigPaths[configPath]; sharedWithEnabledItem { + continue + } + } tx.DisableGadgetItemConfig(item) continue } diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index b1a1d711d..93bc5505c 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -529,6 +529,10 @@ func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) error { } func (u *UsbGadget) WakeReport(active bool) error { + if u.enabledDevices.Touchscreen { + return u.TouchscreenReport(0, 0, false) + } + var report byte if active { report = 1 diff --git a/internal/usbgadget/hid_mouse_relative.go b/internal/usbgadget/hid_mouse_relative.go index 846aef2f9..4c1548e2c 100644 --- a/internal/usbgadget/hid_mouse_relative.go +++ b/internal/usbgadget/hid_mouse_relative.go @@ -85,6 +85,30 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error { return nil } +func (u *UsbGadget) HasRelativeMouse() bool { + return u.enabledDevices.RelativeMouse +} + +func relativeMouseReportBytes(mx int8, my int8, buttons uint8) []byte { + return []byte{ + buttons, // Buttons + byte(mx), // X + byte(my), // Y + 0, // Wheel + 0, // AC Pan (Horizontal Scroll) + } +} + +func relativeMouseWheelReportBytes(wheelY int8, wheelX int8, buttons uint8) []byte { + return []byte{ + buttons, // Buttons + 0, // X + 0, // Y + byte(wheelY), // Wheel (signed) + byte(wheelX), // AC Pan (signed) + } +} + func (u *UsbGadget) RelMouseReport(mx int8, my int8, buttons uint8) error { if !u.enabledDevices.RelativeMouse { return nil @@ -93,17 +117,12 @@ func (u *UsbGadget) RelMouseReport(mx int8, my int8, buttons uint8) error { u.relMouseLock.Lock() defer u.relMouseLock.Unlock() - err := u.relMouseWriteHidFile([]byte{ - buttons, // Buttons - byte(mx), // X - byte(my), // Y - 0, // Wheel - 0, // AC Pan (Horizontal Scroll) - }) + err := u.relMouseWriteHidFile(relativeMouseReportBytes(mx, my, buttons)) if err != nil { return err } + u.relMouseButtons = buttons u.resetUserInputTime() return nil } @@ -120,13 +139,7 @@ func (u *UsbGadget) RelMouseWheelReport(wheelY int8, wheelX int8) error { return nil } - err := u.relMouseWriteHidFile([]byte{ - 0, // Buttons (none) - 0, // X - 0, // Y - byte(wheelY), // Wheel (signed) - byte(wheelX), // AC Pan (signed) - }) + err := u.relMouseWriteHidFile(relativeMouseWheelReportBytes(wheelY, wheelX, u.relMouseButtons)) u.resetUserInputTime() return err diff --git a/internal/usbgadget/hid_mouse_relative_test.go b/internal/usbgadget/hid_mouse_relative_test.go new file mode 100644 index 000000000..741200efb --- /dev/null +++ b/internal/usbgadget/hid_mouse_relative_test.go @@ -0,0 +1,22 @@ +package usbgadget + +import ( + "bytes" + "testing" +) + +func TestRelativeMouseReportBytes(t *testing.T) { + got := relativeMouseReportBytes(-2, 3, 0x05) + want := []byte{0x05, 0xFE, 0x03, 0x00, 0x00} + if !bytes.Equal(got, want) { + t.Fatalf("relativeMouseReportBytes() = %v, want %v", got, want) + } +} + +func TestRelativeMouseWheelReportBytesPreservesButtons(t *testing.T) { + got := relativeMouseWheelReportBytes(-127, 1, 0x02) + want := []byte{0x02, 0x00, 0x00, 0x81, 0x01} + if !bytes.Equal(got, want) { + t.Fatalf("relativeMouseWheelReportBytes() = %v, want %v", got, want) + } +} diff --git a/internal/usbgadget/hid_touchscreen.go b/internal/usbgadget/hid_touchscreen.go new file mode 100644 index 000000000..1d119df13 --- /dev/null +++ b/internal/usbgadget/hid_touchscreen.go @@ -0,0 +1,137 @@ +package usbgadget + +import ( + "fmt" + "os" +) + +var touchscreenConfig = gadgetConfigItem{ + order: 1003, + device: "hid.usb3", + path: []string{"functions", "hid.usb3"}, + configPath: []string{"hid.usb3"}, + attrs: gadgetAttributes{ + "protocol": "0", + "subclass": "0", + "report_length": "7", + "no_out_endpoint": "1", + "wakeup_on_write": "1", + }, + reportDesc: touchscreenReportDesc, +} + +// One-contact HID multitouch digitizer. +// +// Report layout, 7 bytes: +// +// byte 0: bit0 Tip Switch, bit1 In Range, bits2-7 padding +// byte 1: Contact Identifier +// byte 2-3: X, little-endian, 0..32767 +// byte 4-5: Y, little-endian, 0..32767 +// byte 6: Contact Count +var touchscreenReportDesc = []byte{ + 0x05, 0x0D, // Usage Page (Digitizers) + 0x09, 0x04, // Usage (Touch Screen) + 0xA1, 0x01, // Collection (Application) + + 0x09, 0x22, // Usage (Finger) + 0xA1, 0x02, // Collection (Logical) + + 0x09, 0x42, // Usage (Tip Switch) + 0x09, 0x32, // Usage (In Range) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x01, // Report Size (1) + 0x95, 0x02, // Report Count (2) + 0x81, 0x02, // Input (Data,Var,Abs) + + 0x75, 0x01, // Report Size (1) + 0x95, 0x06, // Report Count (6) + 0x81, 0x03, // Input (Const,Var,Abs) padding + + 0x09, 0x51, // Usage (Contact Identifier) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x0F, // Logical Maximum (15) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x81, 0x02, // Input (Data,Var,Abs) + + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x30, // Usage (X) + 0x09, 0x31, // Usage (Y) + 0x16, 0x00, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x7F, // Logical Maximum (32767) + 0x36, 0x00, 0x00, // Physical Minimum (0) + 0x46, 0xFF, 0x7F, // Physical Maximum (32767) + 0x75, 0x10, // Report Size (16) + 0x95, 0x02, // Report Count (2) + 0x81, 0x02, // Input (Data,Var,Abs) + + 0xC0, // End Collection + + 0x05, 0x0D, // Usage Page (Digitizers) + 0x09, 0x54, // Usage (Contact Count) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x81, 0x02, // Input (Data,Var,Abs) + + 0xC0, // End Collection +} + +func (u *UsbGadget) touchscreenWriteHidFile(data []byte) error { + if u.touchscreenHidFile == nil { + var err error + u.touchscreenHidFile, err = os.OpenFile("/dev/hidg3", os.O_RDWR, 0666) + if err != nil { + return fmt.Errorf("failed to open hidg3: %w", err) + } + } + + _, err := u.writeWithTimeout(u.touchscreenHidFile, data) + if err != nil { + u.touchscreenHidFile.Close() + u.touchscreenHidFile = nil + return err + } + return nil +} + +func (u *UsbGadget) TouchscreenReport(x int, y int, touching bool) error { + if !u.enabledDevices.Touchscreen { + return nil + } + + u.touchscreenHidLock.Lock() + defer u.touchscreenHidLock.Unlock() + + if x < 0 { + x = 0 + } else if x > 32767 { + x = 32767 + } + + if y < 0 { + y = 0 + } else if y > 32767 { + y = 32767 + } + + flags := byte(0) + contactCount := byte(0) + if touching { + flags = 0x03 + contactCount = 0x01 + } + + return u.touchscreenWriteHidFile([]byte{ + flags, + 0x00, + byte(x), + byte(x >> 8), + byte(y), + byte(y >> 8), + contactCount, + }) +} diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index bb4a9f98a..fc8248fc7 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -19,6 +19,7 @@ type Devices struct { AbsoluteMouse bool `json:"absolute_mouse"` RelativeMouse bool `json:"relative_mouse"` Keyboard bool `json:"keyboard"` + Touchscreen bool `json:"touchscreen"` MassStorage bool `json:"mass_storage"` SerialConsole bool `json:"serial_console"` Audio bool `json:"audio"` @@ -41,6 +42,7 @@ var defaultUsbGadgetDevices = Devices{ AbsoluteMouse: true, RelativeMouse: true, Keyboard: true, + Touchscreen: true, MassStorage: true, Audio: false, } @@ -62,14 +64,17 @@ type UsbGadget struct { configLock sync.Mutex - keyboardHidFile *os.File - keyboardLock sync.Mutex - wakeHidFile *os.File - wakeHidLock sync.Mutex - absMouseHidFile *os.File - absMouseLock sync.Mutex - relMouseHidFile *os.File - relMouseLock sync.Mutex + keyboardHidFile *os.File + keyboardLock sync.Mutex + wakeHidFile *os.File + wakeHidLock sync.Mutex + absMouseHidFile *os.File + absMouseLock sync.Mutex + relMouseHidFile *os.File + relMouseLock sync.Mutex + relMouseButtons uint8 + touchscreenHidFile *os.File + touchscreenHidLock sync.Mutex keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana) keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys) @@ -137,6 +142,7 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev wakeHidLock: sync.Mutex{}, absMouseLock: sync.Mutex{}, relMouseLock: sync.Mutex{}, + touchscreenHidLock: sync.Mutex{}, txLock: sync.Mutex{}, keyboardStateCtx: keyboardCtx, keyboardStateCancel: keyboardCancel, @@ -195,6 +201,10 @@ func (u *UsbGadget) Close() error { u.relMouseHidFile.Close() u.relMouseHidFile = nil } + if u.touchscreenHidFile != nil { + u.touchscreenHidFile.Close() + u.touchscreenHidFile = nil + } return nil } @@ -223,5 +233,13 @@ func (u *UsbGadget) ResetHIDFiles() { u.relMouseHidFile.Close() u.relMouseHidFile = nil } + u.relMouseButtons = 0 unlockWithLog(&u.relMouseLock, u.log, "relMouseHidFile reset") + + u.touchscreenHidLock.Lock() + if u.touchscreenHidFile != nil { + u.touchscreenHidFile.Close() + u.touchscreenHidFile = nil + } + unlockWithLog(&u.touchscreenHidLock, u.log, "touchscreenHidFile reset") } diff --git a/jetkvm-android/.gitignore b/jetkvm-android/.gitignore new file mode 100644 index 000000000..704499968 --- /dev/null +++ b/jetkvm-android/.gitignore @@ -0,0 +1,2 @@ +build/ +*.keystore diff --git a/jetkvm-android/AndroidManifest.xml b/jetkvm-android/AndroidManifest.xml new file mode 100644 index 000000000..598e3878d --- /dev/null +++ b/jetkvm-android/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + diff --git a/jetkvm-android/README.md b/jetkvm-android/README.md new file mode 100644 index 000000000..dd532a1a2 --- /dev/null +++ b/jetkvm-android/README.md @@ -0,0 +1,69 @@ +# JetKVM Android Controller + +Native Android wrapper for the JetKVM Android controller. + +The controller APK opens JetKVM in Android controller mode and owns the +Android-specific pieces that do not belong in the desktop browser UI: + +- Native login activity with Android Autofill support. +- Immersive phone controller view. +- Compact floating controls overlay. +- Android OSK bridge for text input. +- Display controls and logout from the compact overlay. +- Desktop virtual-keyboard fallback preserved for non-APK browser sessions. + +The app dispatches the Android controller URL internally: + +```text +http://jetkvm.local/?jetkvmAndroid=1 +``` + +The login screen asks for the JetKVM host/IP and password; users do not need to +type URL parameters manually. + +## Build + +```bash +cd /path/to/kvm +./jetkvm-android/build.sh +``` + +## Release Build + +```bash +cd /path/to/kvm +./jetkvm-android/build.sh release +``` + +## Install + +```bash +adb install -r jetkvm-android/build/JetKVM-debug.apk +``` + +Install the release APK: + +```bash +adb install -r jetkvm-android/build/JetKVM-release.apk +``` + +Latest release: + +```text +https://github.com/Batestinha/jetkvm-android-controller/releases/tag/v1.11 +``` + +Latest APK asset: + +```text +JetKVM-Android-Controller-1.11.apk +``` + +Obtainium source: + +```text +https://github.com/Batestinha/jetkvm-android-controller +``` + +This release-only repository is separate from the main JetKVM fork so Obtainium +can track the controller APK independently from the target companion APK. diff --git a/jetkvm-android/build.sh b/jetkvm-android/build.sh new file mode 100755 index 000000000..b2e8e977b --- /dev/null +++ b/jetkvm-android/build.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODE="${1:-debug}" +OUT="$ROOT/build" +GEN="$OUT/gen" +CLASSES="$OUT/classes" +DEX="$OUT/dex" +RES_ZIP="$OUT/resources.zip" +UNSIGNED="$OUT/JetKVM-unsigned.apk" +ALIGNED="$OUT/JetKVM-aligned.apk" + +case "$MODE" in + debug) + SIGNED="$OUT/JetKVM-debug.apk" + KEYSTORE="${KEYSTORE:-$ROOT/jetkvm-android-debug.keystore}" + KEY_ALIAS="${KEY_ALIAS:-jetkvm-android}" + KEY_DNAME="${KEY_DNAME:-CN=JetKVM Android Debug,O=JetKVM}" + ;; + release) + SIGNED="$OUT/JetKVM-release.apk" + KEYSTORE="${KEYSTORE:-$ROOT/jetkvm-android-release.keystore}" + KEY_ALIAS="${KEY_ALIAS:-jetkvm-android-release}" + KEY_DNAME="${KEY_DNAME:-CN=JetKVM Android Release,O=JetKVM}" + ;; + *) + echo "Usage: $0 [debug|release]" + exit 1 + ;; +esac + +STOREPASS="${STOREPASS:-android}" +KEYPASS="${KEYPASS:-$STOREPASS}" + +ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" +BUILD_TOOLS="${BUILD_TOOLS:-$ANDROID_HOME/build-tools/36.1.0}" +PLATFORM="${PLATFORM:-$ANDROID_HOME/platforms/android-36/android.jar}" + +AAPT2="$BUILD_TOOLS/aapt2" +APKSIGNER="$BUILD_TOOLS/apksigner" +D8="$BUILD_TOOLS/d8" +ZIPALIGN="$BUILD_TOOLS/zipalign" + +for tool in "$AAPT2" "$APKSIGNER" "$D8" "$ZIPALIGN" javac keytool; do + command -v "$tool" >/dev/null 2>&1 || { + echo "ERROR: missing tool: $tool" + exit 1 + } +done + +[ -f "$PLATFORM" ] || { + echo "ERROR: missing Android platform jar: $PLATFORM" + exit 1 +} + +rm -rf "$OUT" +mkdir -p "$GEN" "$CLASSES" "$DEX" + +"$AAPT2" compile --dir "$ROOT/res" -o "$RES_ZIP" +"$AAPT2" link \ + -I "$PLATFORM" \ + --manifest "$ROOT/AndroidManifest.xml" \ + --java "$GEN" \ + -o "$UNSIGNED" \ + "$RES_ZIP" + +javac -source 8 -target 8 \ + -bootclasspath "$PLATFORM" \ + -classpath "$GEN" \ + -d "$CLASSES" \ + $(find "$ROOT/src" "$GEN" -name '*.java' | sort) + +"$D8" --lib "$PLATFORM" --output "$DEX" $(find "$CLASSES" -name '*.class' | sort) +(cd "$DEX" && zip -qr "$UNSIGNED" classes.dex) + +"$ZIPALIGN" -f 4 "$UNSIGNED" "$ALIGNED" + +if [ ! -f "$KEYSTORE" ]; then + keytool -genkeypair \ + -keystore "$KEYSTORE" \ + -storepass "$STOREPASS" \ + -keypass "$KEYPASS" \ + -alias "$KEY_ALIAS" \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -dname "$KEY_DNAME" +fi + +"$APKSIGNER" sign \ + --ks "$KEYSTORE" \ + --ks-pass "pass:$STOREPASS" \ + --key-pass "pass:$KEYPASS" \ + --out "$SIGNED" \ + "$ALIGNED" + +"$APKSIGNER" verify "$SIGNED" + +echo "Built: $SIGNED" diff --git a/jetkvm-android/res/drawable/ic_launcher_background.xml b/jetkvm-android/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..5d5ef0719 --- /dev/null +++ b/jetkvm-android/res/drawable/ic_launcher_background.xml @@ -0,0 +1,3 @@ + + + diff --git a/jetkvm-android/res/drawable/ic_launcher_foreground.xml b/jetkvm-android/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..5eec83318 --- /dev/null +++ b/jetkvm-android/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/jetkvm-android/res/mipmap-anydpi-v26/ic_launcher.xml b/jetkvm-android/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..413f12c6a --- /dev/null +++ b/jetkvm-android/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jetkvm-android/res/mipmap-anydpi-v26/ic_launcher_round.xml b/jetkvm-android/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..413f12c6a --- /dev/null +++ b/jetkvm-android/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jetkvm-android/res/values/strings.xml b/jetkvm-android/res/values/strings.xml new file mode 100644 index 000000000..81ba0286e --- /dev/null +++ b/jetkvm-android/res/values/strings.xml @@ -0,0 +1,3 @@ + + JetKVM + diff --git a/jetkvm-android/res/values/styles.xml b/jetkvm-android/res/values/styles.xml new file mode 100644 index 000000000..d62777efd --- /dev/null +++ b/jetkvm-android/res/values/styles.xml @@ -0,0 +1,13 @@ + + + diff --git a/jetkvm-android/res/xml/network_security_config.xml b/jetkvm-android/res/xml/network_security_config.xml new file mode 100644 index 000000000..e538ea722 --- /dev/null +++ b/jetkvm-android/res/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + jetkvm.local + + diff --git a/jetkvm-android/src/com/jetkvm/controller/MainActivity.java b/jetkvm-android/src/com/jetkvm/controller/MainActivity.java new file mode 100644 index 000000000..b29c2119a --- /dev/null +++ b/jetkvm-android/src/com/jetkvm/controller/MainActivity.java @@ -0,0 +1,724 @@ +package com.jetkvm.controller; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.net.http.SslError; +import android.os.Build; +import android.os.Bundle; +import android.os.PowerManager; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.view.autofill.AutofillManager; +import android.view.autofill.AutofillValue; +import android.view.inputmethod.InputMethodManager; +import android.webkit.ConsoleMessage; +import android.webkit.CookieManager; +import android.webkit.JavascriptInterface; +import android.webkit.PermissionRequest; +import android.webkit.SslErrorHandler; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; +import java.util.Map; + +public class MainActivity extends Activity { + private static final String PREFS = "jetkvm_android"; + private static final String KEY_URL = "controllerUrl"; + private static final String KEY_HOST = "controllerHost"; + private static final String KEY_STAY_LOGGED_IN = "stayLoggedIn"; + private static final String DEFAULT_HOST = "jetkvm.local"; + private static final int JETKVM_BLUE_700 = Color.rgb(20, 71, 230); + private static final long WAKE_LOCK_TIMEOUT_MS = 10 * 60 * 1000L; + + private WebView webView; + private LinearLayout loginPanel; + private EditText imeInput; + private EditText hostInput; + private EditText passwordInput; + private CheckBox stayLoggedInInput; + private Button loginButton; + private TextView statusText; + private ProgressBar progressBar; + private SharedPreferences prefs; + private PowerManager.WakeLock wakeLock; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + + requestWindowFeature(Window.FEATURE_NO_TITLE); + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); + wakeLock = powerManager.newWakeLock(PowerManager.SCREEN_DIM_WAKE_LOCK, "JetKVM:Controller"); + + webView = new WebView(this); + webView.setBackgroundColor(Color.BLACK); + configureWebView(webView); + + FrameLayout root = new FrameLayout(this); + root.setBackgroundColor(Color.BLACK); + root.addView(webView, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + )); + + imeInput = createImeInput(); + root.addView(imeInput, new FrameLayout.LayoutParams(dp(1), dp(1))); + + loginPanel = createLoginPanel(); + root.addView(loginPanel, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + )); + setContentView(root); + + webView.setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + return true; + } + }); + webView.setHapticFeedbackEnabled(false); + + enterImmersiveMode(); + showLoginPanel(""); + } + + @Override + protected void onResume() { + super.onResume(); + enterImmersiveMode(); + if (wakeLock != null && !wakeLock.isHeld()) wakeLock.acquire(WAKE_LOCK_TIMEOUT_MS); + } + + @Override + protected void onPause() { + CookieManager.getInstance().flush(); + if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); + super.onPause(); + } + + @Override + protected void onDestroy() { + if (webView != null) { + webView.destroy(); + webView = null; + } + super.onDestroy(); + } + + @Override + public void onBackPressed() { + if (loginPanel != null && loginPanel.getVisibility() == View.VISIBLE) { + super.onBackPressed(); + return; + } + if (webView != null && webView.canGoBack()) { + webView.goBack(); + return; + } + showLoginPanel("Change JetKVM host or log in again."); + } + + private LinearLayout createLoginPanel() { + int padding = dp(24); + + LinearLayout outer = new LinearLayout(this); + outer.setOrientation(LinearLayout.VERTICAL); + outer.setGravity(Gravity.CENTER); + outer.setPadding(padding, padding, padding, padding); + outer.setBackgroundColor(Color.rgb(7, 12, 28)); + + LinearLayout form = new LinearLayout(this); + form.setOrientation(LinearLayout.VERTICAL); + form.setGravity(Gravity.CENTER_HORIZONTAL); + form.setPadding(0, 0, 0, 0); + outer.addView(form, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + ImageView logo = new ImageView(this); + logo.setImageResource(getApplicationInfo().icon); + logo.setAdjustViewBounds(true); + LinearLayout.LayoutParams logoParams = new LinearLayout.LayoutParams(dp(72), dp(72)); + logoParams.setMargins(0, 0, 0, dp(14)); + form.addView(logo, logoParams); + + TextView title = new TextView(this); + title.setText("JetKVM"); + title.setTextColor(Color.WHITE); + title.setTextSize(28); + title.setGravity(Gravity.CENTER); + form.addView(title, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + statusText = new TextView(this); + statusText.setTextColor(Color.rgb(148, 163, 184)); + statusText.setTextSize(14); + statusText.setGravity(Gravity.CENTER); + statusText.setPadding(0, dp(8), 0, dp(16)); + form.addView(statusText, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + hostInput = new EditText(this); + hostInput.setSingleLine(true); + hostInput.setText(getStoredHost()); + hostInput.setHint("JetKVM IP or host"); + hostInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + hostInput.setSelectAllOnFocus(true); + form.addView(hostInput, fieldLayoutParams()); + + passwordInput = new AutofillAwareEditText(this, new Runnable() { + @Override + public void run() { + collapseKeyboardAfterAutofill(); + } + }); + passwordInput.setSingleLine(true); + passwordInput.setHint("Password"); + passwordInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + enableAutofill(passwordInput, View.AUTOFILL_HINT_PASSWORD); + form.addView(passwordInput, fieldLayoutParams()); + + stayLoggedInInput = new CheckBox(this); + stayLoggedInInput.setText("Stay logged in"); + stayLoggedInInput.setTextColor(Color.WHITE); + stayLoggedInInput.setChecked(true); + form.addView(stayLoggedInInput, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + loginButton = new Button(this); + loginButton.setText("Log in"); + loginButton.setAllCaps(false); + applyLoginButtonStyle(false); + loginButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + submitNativeLogin( + hostInput.getText().toString(), + passwordInput.getText().toString(), + stayLoggedInInput.isChecked() + ); + } + }); + form.addView(loginButton, fieldLayoutParams()); + + progressBar = new ProgressBar(this); + progressBar.setVisibility(View.GONE); + form.addView(progressBar, new LinearLayout.LayoutParams(dp(40), dp(40))); + + return outer; + } + + private EditText createImeInput() { + EditText input = new EditText(this); + input.setAlpha(0.01f); + input.setSingleLine(false); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + input.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO); + input.addTextChangedListener(new TextWatcher() { + private boolean clearing; + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable editable) { + if (clearing) return; + + String text = editable.toString(); + if (text.isEmpty()) return; + + clearing = true; + editable.clear(); + clearing = false; + dispatchAndroidImeText(text); + } + }); + return input; + } + + private LinearLayout.LayoutParams fieldLayoutParams() { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.setMargins(0, 0, 0, dp(12)); + return params; + } + + private int dp(int value) { + return Math.round(value * getResources().getDisplayMetrics().density); + } + + private void enableAutofill(View view, String hint) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + view.setAutofillHints(hint); + view.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES); + } + } + + @SuppressLint("SetJavaScriptEnabled") + private void configureWebView(WebView view) { + WebView.setWebContentsDebuggingEnabled(false); + + WebSettings settings = view.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setDatabaseEnabled(true); + settings.setSaveFormData(false); + settings.setSavePassword(false); + settings.setMediaPlaybackRequiresUserGesture(false); + settings.setLoadWithOverviewMode(false); + settings.setUseWideViewPort(false); + settings.setSupportZoom(false); + settings.setBuiltInZoomControls(false); + settings.setDisplayZoomControls(false); + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + settings.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE); + settings.setUserAgentString(settings.getUserAgentString() + " JetKVMWebView/1"); + + CookieManager cookieManager = CookieManager.getInstance(); + cookieManager.setAcceptCookie(true); + cookieManager.setAcceptThirdPartyCookies(view, true); + + view.addJavascriptInterface(new JetKVMBridge(), "JetKVMAndroid"); + view.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS); + view.setOverScrollMode(View.OVER_SCROLL_NEVER); + view.setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY); + view.setVerticalScrollBarEnabled(false); + view.setHorizontalScrollBarEnabled(false); + + view.setWebChromeClient(new WebChromeClient() { + @Override + public void onPermissionRequest(final PermissionRequest request) { + runOnUiThread(new Runnable() { + @Override + public void run() { + request.grant(request.getResources()); + } + }); + } + + @Override + public boolean onConsoleMessage(ConsoleMessage consoleMessage) { + return true; + } + }); + + view.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + if (isNativeLoginUrl(request.getUrl().toString())) { + showLoginPanel("Session expired. Log in again."); + return true; + } + return false; + } + + @Override + public void onPageFinished(WebView view, String url) { + injectJetKVMHooks(view); + CookieManager.getInstance().flush(); + if (isNativeLoginUrl(url)) { + showLoginPanel("Session expired. Log in again."); + } + } + + @Override + public void onReceivedHttpError( + WebView view, + WebResourceRequest request, + WebResourceResponse errorResponse + ) { + if (request.isForMainFrame()) { + showLoginPanel("JetKVM returned an HTTP error."); + } + } + + @Override + public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { + if (request.isForMainFrame()) { + showLoginPanel("Unable to load JetKVM. Check the IP or host."); + } + } + + @Override + public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { + handler.cancel(); + showLoginPanel("JetKVM certificate is not trusted."); + } + }); + + view.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + if (event.getAction() == MotionEvent.ACTION_DOWN) enterImmersiveMode(); + return false; + } + }); + } + + private void showController() { + hideKeyboard(); + loginPanel.setVisibility(View.GONE); + webView.setVisibility(View.VISIBLE); + webView.loadUrl(buildControllerUrl(getStoredHost())); + enterImmersiveMode(); + } + + private void showLoginPanel(String message) { + if (loginPanel == null) return; + webView.setVisibility(View.GONE); + loginPanel.setVisibility(View.VISIBLE); + setBusy(false); + statusText.setText(message == null ? "" : message); + hostInput.setText(getStoredHost()); + passwordInput.setText(""); + passwordInput.requestFocus(); + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) imm.showSoftInput(passwordInput, InputMethodManager.SHOW_IMPLICIT); + requestPasswordAutofill(); + enterImmersiveMode(); + } + + private boolean isNativeLoginUrl(String url) { + if (url == null) return false; + try { + return "/login-local".equals(new URL(url).getPath()); + } catch (Exception ignored) { + return url.contains("/login-local"); + } + } + + private void requestPasswordAutofill() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || passwordInput == null) return; + + passwordInput.postDelayed(new Runnable() { + @Override + public void run() { + AutofillManager autofillManager = + (AutofillManager) getSystemService(AutofillManager.class); + if (autofillManager != null) { + autofillManager.cancel(); + autofillManager.requestAutofill(passwordInput); + } + } + }, 250); + } + + private void collapseKeyboardAfterAutofill() { + if (passwordInput == null || loginPanel == null || loginPanel.getVisibility() != View.VISIBLE) return; + + passwordInput.postDelayed(new Runnable() { + @Override + public void run() { + passwordInput.clearFocus(); + hideKeyboard(); + } + }, 150); + } + + private void commitAutofillSession() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + + AutofillManager autofillManager = + (AutofillManager) getSystemService(AutofillManager.class); + if (autofillManager != null) autofillManager.commit(); + } + + private void setBusy(boolean busy) { + if (loginButton == null) return; + loginButton.setEnabled(!busy); + applyLoginButtonStyle(busy); + progressBar.setVisibility(busy ? View.VISIBLE : View.GONE); + } + + private void applyLoginButtonStyle(boolean busy) { + if (loginButton == null) return; + loginButton.setTextColor(Color.WHITE); + loginButton.setBackgroundTintList(ColorStateList.valueOf(JETKVM_BLUE_700)); + loginButton.setAlpha(busy ? 0.5f : 1.0f); + } + + private String getStoredHost() { + String host = prefs.getString(KEY_HOST, null); + if (host == null || host.trim().isEmpty()) { + host = hostFromUrlOrHost(prefs.getString(KEY_URL, DEFAULT_HOST)); + } + if (host == null || host.trim().isEmpty()) return DEFAULT_HOST; + return host.trim(); + } + + private String hostFromUrlOrHost(String value) { + String host = value == null ? "" : value.trim(); + if (host.isEmpty()) return DEFAULT_HOST; + try { + String url = host.startsWith("http://") || host.startsWith("https://") ? host : "http://" + host; + URL parsed = new URL(url); + String parsedHost = parsed.getHost(); + if (parsedHost == null || parsedHost.isEmpty()) return DEFAULT_HOST; + if (parsed.getPort() != -1) parsedHost += ":" + parsed.getPort(); + return parsedHost; + } catch (Exception ignored) { + return host + .replaceFirst("^https?://", "") + .replaceAll("/.*$", ""); + } + } + + private String buildControllerUrl(String hostValue) { + String host = hostFromUrlOrHost(hostValue); + String url = host.startsWith("http://") || host.startsWith("https://") ? host : "http://" + host; + if (!url.contains("?")) { + url += "?jetkvmAndroid=1"; + } else if (!url.contains("jetkvmAndroid=1")) { + url += "&jetkvmAndroid=1"; + } + return url; + } + + private void submitNativeLogin(final String controllerHost, final String password, final boolean stayLoggedIn) { + final String host = hostFromUrlOrHost(controllerHost); + final String controllerUrl = buildControllerUrl(host); + prefs.edit() + .putString(KEY_HOST, host) + .putString(KEY_URL, controllerUrl) + .apply(); + setBusy(true); + statusText.setText("Logging in..."); + + new Thread(new Runnable() { + @Override + public void run() { + try { + final String origin = getOrigin(controllerUrl); + URL url = new URL(origin + "/auth/login-local"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json"); + + String body = "{\"password\":\"" + jsonEscape(password) + "\",\"stayLoggedIn\":" + + (stayLoggedIn ? "true" : "false") + "}"; + OutputStream out = conn.getOutputStream(); + out.write(body.getBytes("UTF-8")); + out.close(); + + int status = conn.getResponseCode(); + if (status < 200 || status >= 300) { + showLoginFailed("Invalid password or login failed."); + return; + } + + Map> headers = conn.getHeaderFields(); + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() == null || !"Set-Cookie".equalsIgnoreCase(entry.getKey())) continue; + for (String cookie : entry.getValue()) { + CookieManager.getInstance().setCookie(origin, cookie); + } + } + CookieManager.getInstance().flush(); + prefs.edit() + .putString(KEY_HOST, host) + .putString(KEY_URL, controllerUrl) + .putBoolean(KEY_STAY_LOGGED_IN, stayLoggedIn) + .apply(); + + runOnUiThread(new Runnable() { + @Override + public void run() { + commitAutofillSession(); + setBusy(false); + showController(); + } + }); + } catch (Exception e) { + showLoginFailed("Unable to reach JetKVM. Check the IP or host."); + } + } + }).start(); + } + + private void showLoginFailed(final String message) { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(MainActivity.this, message, Toast.LENGTH_SHORT).show(); + setBusy(false); + statusText.setText(message); + passwordInput.requestFocus(); + } + }); + } + + private String getOrigin(String value) throws Exception { + URL url = new URL(buildControllerUrl(value)); + String origin = url.getProtocol() + "://" + url.getHost(); + if (url.getPort() != -1) origin += ":" + url.getPort(); + return origin; + } + + private String jsonEscape(String value) { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private void hideKeyboard() { + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + View tokenView = loginPanel != null && loginPanel.getVisibility() == View.VISIBLE ? loginPanel : webView; + if (imm != null && tokenView != null) { + imm.hideSoftInputFromWindow(tokenView.getWindowToken(), 0); + } + if (imeInput != null) imeInput.clearFocus(); + } + + private void showAndroidIme() { + if (imeInput == null) return; + + imeInput.requestFocus(); + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) imm.showSoftInput(imeInput, InputMethodManager.SHOW_IMPLICIT); + enterImmersiveMode(); + } + + private void dispatchAndroidImeText(String text) { + if (webView == null || text == null || text.isEmpty()) return; + + String script = "window.dispatchEvent(new CustomEvent('jetkvm-android-ime-text'," + + "{detail:{text:\"" + jsonEscape(text) + "\"}}));"; + webView.evaluateJavascript(script, null); + } + + private void enterImmersiveMode() { + View decor = getWindow().getDecorView(); + decor.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE + ); + } + + private void injectJetKVMHooks(WebView view) { + view.evaluateJavascript( + "(function(){" + + "if(window.__jetkvmAndroidHooksInstalled)return;" + + "window.__jetkvmAndroidHooksInstalled=true;" + + "function isEditable(el){" + + "if(!el)return false;" + + "var tag=(el.tagName||'').toLowerCase();" + + "return tag==='input'||tag==='textarea'||tag==='select'||el.isContentEditable;" + + "}" + + "document.addEventListener('pointerdown',function(e){" + + "if(isEditable(e.target))return;" + + "if(isEditable(document.activeElement))document.activeElement.blur();" + + "window.JetKVMAndroid.hideKeyboard();" + + "},true);" + + "})();", + null + ); + } + + private final class JetKVMBridge { + @JavascriptInterface + public void setStayLoggedIn(boolean stayLoggedIn) { + prefs.edit().putBoolean(KEY_STAY_LOGGED_IN, stayLoggedIn).apply(); + } + + @JavascriptInterface + public void showNativeLogin(String url) { + runOnUiThread(new Runnable() { + @Override + public void run() { + showLoginPanel("Session expired. Log in again."); + } + }); + } + + @JavascriptInterface + public void hideKeyboard() { + runOnUiThread(new Runnable() { + @Override + public void run() { + MainActivity.this.hideKeyboard(); + } + }); + } + + @JavascriptInterface + public void showInputMethod() { + runOnUiThread(new Runnable() { + @Override + public void run() { + MainActivity.this.showAndroidIme(); + } + }); + } + } + + private static final class AutofillAwareEditText extends EditText { + private final Runnable onAutofilled; + + AutofillAwareEditText(Context context, Runnable onAutofilled) { + super(context); + this.onAutofilled = onAutofilled; + } + + @Override + public void autofill(AutofillValue value) { + super.autofill(value); + if (onAutofilled != null) onAutofilled.run(); + } + } +} diff --git a/jetkvm-companion/.gitignore b/jetkvm-companion/.gitignore new file mode 100644 index 000000000..704499968 --- /dev/null +++ b/jetkvm-companion/.gitignore @@ -0,0 +1,2 @@ +build/ +*.keystore diff --git a/jetkvm-companion/AndroidManifest.xml b/jetkvm-companion/AndroidManifest.xml new file mode 100644 index 000000000..d5a0e67b8 --- /dev/null +++ b/jetkvm-companion/AndroidManifest.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jetkvm-companion/README.md b/jetkvm-companion/README.md new file mode 100644 index 000000000..68ada3c15 --- /dev/null +++ b/jetkvm-companion/README.md @@ -0,0 +1,122 @@ +# JetKVM Companion APK + +Small target-side Android helper for JetKVM Android target setups. + +JetKVM controls Android targets through USB HID touchscreen and keyboard input. +On stock Android, that is enough once the phone is usable, but the lockscreen is +special: Android can keep a trusted soft keyguard on the external JetKVM display +and refuse to dismiss it from the external USB digitizer. This companion uses +only public Android APIs to bridge that gap. + +The companion exists because this is an Android multi-display keyguard policy +boundary, not a JetKVM HID bug. AOSP documents that secondary-display lockscreen +UI doesn't support unlocking from secondary screens, and its multi-display FAQ +says the default secondary-display lockscreen isn't interactive and doesn't +allow unlocking. + +References: + +- https://source.android.com/docs/core/display/multi_display/lock-screen +- https://source.android.com/docs/core/display/multi_display/faq + +The companion does not inject input, capture the screen, use ADB, require root, +use Accessibility, or depend on Shizuku. It runs a foreground service, listens +for JetKVM-identifiable Android input devices, and only attempts keyguard +dismissal while Android sees the JetKVM keyboard plus touchscreen or pointer. +When the target wakes, a transparent `showWhenLocked` Activity calls +`KeyguardManager.requestDismissKeyguard()`. Android decides whether the keyguard +can be dismissed. + +Opening the app shows a small settings UI. Use **Arm companion** after install, +grant **Background launch assist**, and enable **Launch on boot** if the helper +should arm itself after Android finishes booting. Android 13 and later may ask +for notification permission; that permission lets Android keep the companion +foreground service visible and reliable. + +Use **Grant unrestricted battery** if Android's battery policy would otherwise +stop the primary peripheral watchdog after boot or during idle. + +Automatic wake-unlock from the background requires Android's overlay permission. +The companion uses it for a tiny non-touchable launch-assist overlay. This gives +Android a visible non-app window for the foreground service, which allows the +transparent dismiss Activity to launch after display wake without leaving an +interactive overlay on top of the target phone. + +The companion intentionally does not use generic external-monitor presence as +its arming condition. It snapshots Android `InputDevice` metadata at startup and +after input-device add/remove/change events. The key condition is JetKVM-named +input devices, currently `JetKVM USB Emulation Device`, with keyboard and either +touchscreen or pointer sources present. The current Linux gadget VID/PID +`1d6b:0104` is logged as supporting metadata, but not required, so future +JetKVM-specific VID/PID changes do not break detection. + +## Integration Note + +After more real-world testing, cherry-pick companion commit `803988f` +(`Improve Android companion target support`) into the main JetKVM Android +support branch. That commit contains the multi-endpoint settings UI, target +metadata reporting updates, stable debug signing path, and the one-shot JetKVM +presentation pulse used to wake the external display without owning it. + +## Modes of Operation + +- **No lockscreen**: may work for some users, but some apps are hostile toward + disabled lockscreen or insecure-device configurations. +- **Keyguard on with Extend Unlock**: keep the normal Android keyguard enabled, + configure Extend Unlock or another trusted state, install JetKVM Companion on + the target phone, and open it once after boot to arm the foreground service. + Grant **Background launch assist** for automatic wake-unlock while the app is + in the background, and grant **Unrestricted battery** for watchdog + reliability. Enable **Launch on boot** to arm the service automatically after + future boots. +- **Keyguard on without Extend Unlock**: keep the normal Android keyguard + enabled and use JetKVM's display wake action to wake the target. The companion + can bring up Android's credential bouncer, and the user can enter the PIN or + password through JetKVM keyboard input or the Android controller's OSK bridge. + +## Build + +```bash +cd /path/to/kvm +./jetkvm-companion/build.sh +``` + +## Release Build + +```bash +cd /path/to/kvm +./jetkvm-companion/build.sh release +``` + +## Install + +```bash +adb install -r jetkvm-companion/build/JetKVM-Companion-debug.apk +``` + +Install the release APK: + +```bash +adb install -r jetkvm-companion/build/JetKVM-Companion-release.apk +``` + +Latest release: + +```text +https://github.com/Batestinha/jetkvm-companion/releases/tag/v1.7 +``` + +Latest APK asset: + +```text +JetKVM-Companion-1.7.apk +``` + +Obtainium source: + +```text +https://github.com/Batestinha/jetkvm-companion +``` + +This release-only repository is separate from the main JetKVM fork so Obtainium +can track the target companion APK independently from the controller APK. diff --git a/jetkvm-companion/build.sh b/jetkvm-companion/build.sh new file mode 100755 index 000000000..9d0a6c1ef --- /dev/null +++ b/jetkvm-companion/build.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODE="${1:-debug}" +OUT="$ROOT/build" +GEN="$OUT/gen" +CLASSES="$OUT/classes" +DEX="$OUT/dex" +RES_ZIP="$OUT/resources.zip" +UNSIGNED="$OUT/JetKVM-Companion-unsigned.apk" +ALIGNED="$OUT/JetKVM-Companion-aligned.apk" + +case "$MODE" in + debug) + SIGNED="$OUT/JetKVM-Companion-debug.apk" + KEYSTORE="${KEYSTORE:-${JETKVM_COMPANION_DEBUG_KEYSTORE:-$HOME/.local/share/jetkvm-companion/jetkvm-companion-debug.keystore}}" + KEY_ALIAS="${KEY_ALIAS:-jetkvm-companion}" + KEY_DNAME="${KEY_DNAME:-CN=JetKVM Companion Debug,O=JetKVM}" + ;; + release) + SIGNED="$OUT/JetKVM-Companion-release.apk" + KEYSTORE="${KEYSTORE:-$ROOT/jetkvm-companion-release.keystore}" + KEY_ALIAS="${KEY_ALIAS:-jetkvm-companion-release}" + KEY_DNAME="${KEY_DNAME:-CN=JetKVM Companion Release,O=JetKVM}" + ;; + *) + echo "Usage: $0 [debug|release]" + exit 1 + ;; +esac + +STOREPASS="${STOREPASS:-android}" +KEYPASS="${KEYPASS:-$STOREPASS}" + +ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" +BUILD_TOOLS="${BUILD_TOOLS:-$ANDROID_HOME/build-tools/36.1.0}" +PLATFORM="${PLATFORM:-$ANDROID_HOME/platforms/android-36/android.jar}" + +AAPT2="$BUILD_TOOLS/aapt2" +APKSIGNER="$BUILD_TOOLS/apksigner" +D8="$BUILD_TOOLS/d8" +ZIPALIGN="$BUILD_TOOLS/zipalign" + +for tool in "$AAPT2" "$APKSIGNER" "$D8" "$ZIPALIGN" javac keytool; do + command -v "$tool" >/dev/null 2>&1 || { + echo "ERROR: missing tool: $tool" + exit 1 + } +done + +[ -f "$PLATFORM" ] || { + echo "ERROR: missing Android platform jar: $PLATFORM" + exit 1 +} + +rm -rf "$OUT" +mkdir -p "$GEN" "$CLASSES" "$DEX" + +"$AAPT2" compile --dir "$ROOT/res" -o "$RES_ZIP" +"$AAPT2" link \ + -I "$PLATFORM" \ + --manifest "$ROOT/AndroidManifest.xml" \ + --java "$GEN" \ + -o "$UNSIGNED" \ + "$RES_ZIP" + +javac -source 8 -target 8 \ + -bootclasspath "$PLATFORM" \ + -classpath "$GEN" \ + -d "$CLASSES" \ + $(find "$ROOT/src" "$GEN" -name '*.java' | sort) + +"$D8" --lib "$PLATFORM" --output "$DEX" $(find "$CLASSES" -name '*.class' | sort) +(cd "$DEX" && zip -qr "$UNSIGNED" classes.dex) + +"$ZIPALIGN" -f 4 "$UNSIGNED" "$ALIGNED" + +mkdir -p "$(dirname "$KEYSTORE")" +if [ ! -f "$KEYSTORE" ]; then + keytool -genkeypair \ + -keystore "$KEYSTORE" \ + -storepass "$STOREPASS" \ + -keypass "$KEYPASS" \ + -alias "$KEY_ALIAS" \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -dname "$KEY_DNAME" +fi + +"$APKSIGNER" sign \ + --ks "$KEYSTORE" \ + --ks-pass "pass:$STOREPASS" \ + --key-pass "pass:$KEYPASS" \ + --out "$SIGNED" \ + "$ALIGNED" + +"$APKSIGNER" verify "$SIGNED" + +echo "Built: $SIGNED" diff --git a/jetkvm-companion/res/drawable/ic_launcher_background.xml b/jetkvm-companion/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..5d5ef0719 --- /dev/null +++ b/jetkvm-companion/res/drawable/ic_launcher_background.xml @@ -0,0 +1,3 @@ + + + diff --git a/jetkvm-companion/res/drawable/ic_launcher_foreground.xml b/jetkvm-companion/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..5eec83318 --- /dev/null +++ b/jetkvm-companion/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/jetkvm-companion/res/mipmap-anydpi-v26/ic_launcher.xml b/jetkvm-companion/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..413f12c6a --- /dev/null +++ b/jetkvm-companion/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jetkvm-companion/res/mipmap-anydpi-v26/ic_launcher_round.xml b/jetkvm-companion/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..413f12c6a --- /dev/null +++ b/jetkvm-companion/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jetkvm-companion/res/values/strings.xml b/jetkvm-companion/res/values/strings.xml new file mode 100644 index 000000000..72153a609 --- /dev/null +++ b/jetkvm-companion/res/values/strings.xml @@ -0,0 +1,6 @@ + + JetKVM Companion + Target-side Android helper for JetKVM metadata, keyguard, and display handling. + Maintains JetKVM target metadata, keyguard assistance, and external display handling. + Notification permission lets Android keep the foreground companion service visible and reliable. + diff --git a/jetkvm-companion/res/values/styles.xml b/jetkvm-companion/res/values/styles.xml new file mode 100644 index 000000000..b84ebdec5 --- /dev/null +++ b/jetkvm-companion/res/values/styles.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/jetkvm-companion/src/com/jetkvm/companion/BootReceiver.java b/jetkvm-companion/src/com/jetkvm/companion/BootReceiver.java new file mode 100644 index 000000000..e21327e28 --- /dev/null +++ b/jetkvm-companion/src/com/jetkvm/companion/BootReceiver.java @@ -0,0 +1,31 @@ +package com.jetkvm.companion; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.util.Log; + +public class BootReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (!Intent.ACTION_BOOT_COMPLETED.equals(action) + && !Intent.ACTION_LOCKED_BOOT_COMPLETED.equals(action)) { + return; + } + + Context storageContext = android.os.Build.VERSION.SDK_INT >= 24 + ? context.createDeviceProtectedStorageContext() + : context; + SharedPreferences prefs = storageContext.getSharedPreferences(CompanionService.PREFS, Context.MODE_PRIVATE); + if (!prefs.getBoolean(CompanionService.KEY_LAUNCH_ON_BOOT, false)) { + Log.i(CompanionService.TAG, action + "; launch on boot disabled"); + return; + } + + Log.i(CompanionService.TAG, action + "; starting companion service"); + Intent service = new Intent(context, CompanionService.class); + context.startForegroundService(service); + } +} diff --git a/jetkvm-companion/src/com/jetkvm/companion/CompanionService.java b/jetkvm-companion/src/com/jetkvm/companion/CompanionService.java new file mode 100644 index 000000000..bf4cbb7d2 --- /dev/null +++ b/jetkvm-companion/src/com/jetkvm/companion/CompanionService.java @@ -0,0 +1,1512 @@ +package com.jetkvm.companion; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Presentation; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.graphics.PixelFormat; +import android.graphics.drawable.ColorDrawable; +import android.hardware.display.DisplayManager; +import android.hardware.input.InputManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.PowerManager; +import android.provider.Settings; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Display; +import android.view.InputDevice; +import android.view.Gravity; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.UUID; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.security.cert.X509Certificate; + +public class CompanionService extends Service implements InputManager.InputDeviceListener { + static final String TAG = "JetKVMCompanion"; + static final String ACTION_SCREEN_ON = "com.jetkvm.companion.SCREEN_ON"; + static final String PREFS = "jetkvm_companion"; + static final String KEY_LAUNCH_ON_BOOT = "launch_on_boot"; + static final String KEY_JETKVM_URL = "jetkvm_url"; + static final String KEY_JETKVM_URLS = "jetkvm_urls"; + static final String KEY_JETKVM_PAIRINGS = "jetkvm_pairings"; + static final String KEY_PENDING_PAIR_URL = "pending_pair_url"; + static final String KEY_PENDING_PAIR_CREATED_AT = "pending_pair_created_at"; + static final String DEFAULT_JETKVM_URL = "https://jetkvm.local"; + static final String EXTRA_JETKVM_URL = "jetkvm_url"; + static final String ACTION_PAIR_REQUEST_UPDATED = "com.jetkvm.companion.PAIR_REQUEST_UPDATED"; + + private static final String CHANNEL_ID = "jetkvm-companion"; + private static final int NOTIFICATION_ID = 1001; + private static final int NOTIFICATION_RESPAWN_BASE_ID = 1100; + private static final int PAIRING_LISTEN_PORT = 8787; + private static final char[] PAIRING_TLS_KEYSTORE_PASSWORD = "jetkvm-pairing".toCharArray(); + private static final String PAIRING_TLS_PKCS12_BASE64 = + "MIIErQIBAzCCBGMGCSqGSIb3DQEHAaCCBFQEggRQMIIETDCCAsoGCSqGSIb3DQEHBqCCArswggK3AgEAMIICsAYJKoZIhvcNAQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBAwxVAx3n9iprRMGxOVTnRcAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQ5v0SWOS0Y9RgSUC9zVIDmICCAkD4oroylTCG9IGFbvvNQo8oL+ZktHuHmsK0ympLsKaiba0kqnyUDVHPIQCPkbqpzLvbVxK2v3XvqOW6uIDnO6WuGQ4SgJIHwtjKEfYE2nwRXzJAxG3K3W4rJcMojAOaH693FOFIJzAHGhfvBJJA91vzcoPbg54/8JQw2p9fxTUbUC3Oear/9uQV5zXO0gkA76YWakuLStdXE1V/DkHCq900J3OTla1d4FlIIc/6T30j4JmnLFBfsC42miNMYH9si6YiaqPk7kR4AAyzSGHLdT7nEUqLQYbFTodDGrpRON3uQdqoF2DV7jo/m7uoLkh/cqKyzLp6gBDP5PUemdvVV4NARUkVN+6k4y5nJdjERPUByu9sXvsVDXvlwtRpchcLTPA4Lu7csdusCJheyuXtU6AjkhkQ1bZodjhLwmYhUK9TWZy1Fc+0/xptp54aS0BT1zMZlNlN0h7QU1f7qfCK56IO8ElUh69yfx5n2we41/uZyJEMCgZAWhbSFhLe1LjPYjHjOipw2xkJpm6Q4jN6gANxacQQ86MK1TqP1bzArbBTMKKu/X2uLvd9GTVnZvPnqafMlzw8iEcnaKnRG2J3EmTL6VgEPIPNYwajbjV5ClVJd7OtoXx5NU2CyWRbwcc3wehQuJdLEfaHunPfv06zmCJ20p2bhOZmTRMFDJpnEM6SpVXxmpFObjJQ40jziuxFGR7O1LrNIYr+NdDUL0wL3eJeCiw26oi3+H3f3GfOGEkbyN1Uz31CPPxJpHT5eCfe2xkwggF6BgkqhkiG9w0BBwGgggFrBIIBZzCCAWMwggFfBgsqhkiG9w0BDAoBAqCB9zCB9DBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQnjQKS2/2mS7/VtWfmlHqHgICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEFyZrGeNQzflyuU01xMpV7gEgZCrgV4LouyEmAWy0YBYmPmyA+TqOXViN93lcBaJ/3VkWjhp6RIiaSx6PNCt7Gm2LXOjMBQ7U6atpRD7QBjSn+EfJv7CKxVXm6Q+kzk7yrx/cyUuP35vchCJM5x+2V1yUQ3kGJzUjNEwaUelHd4WTOwhrCwFU5nXvDyu28IniGD/rYuH5eTzcgrlX8hrke/seCcxVjAjBgkqhkiG9w0BCRUxFgQUnipi/wwZbTQzZhlNJ18M5wfnnz8wLwYJKoZIhvcNAQkUMSIeIABwAGEAaQByAGkAbgBnAC0AbABpAHMAdABlAG4AZQByMEEwMTANBglghkgBZQMEAgEFAAQgbIjNG8GU9ctDrstkzRq+YyFrBF/IZ3gjpVi75eIMoVgECKUudDl7lnsAAgIIAA=="; + private static final long SCREEN_ON_DISMISS_DELAY_MS = 600; + private static final long TARGET_REPORT_INTERVAL_MS = 15000; + private static final long TARGET_LEASE_MS = 120000; + private static final long TARGET_PRESENTATION_PULSE_MS = 750; + private static final String JETKVM_INPUT_NAME_TOKEN = "jetkvm"; + private static final String JETKVM_DISPLAY_NAME_TOKEN = "jetkvm"; + private static final String JETKVM_SHORT_DISPLAY_NAME_TOKEN = "jkvm"; + private static final int LINUX_GADGET_VENDOR_ID = 0x1d6b; + private static final int LINUX_GADGET_PRODUCT_ID = 0x0104; + + private WindowManager windowManager; + private DisplayManager displayManager; + private InputManager inputManager; + private View launchAssistOverlay; + private TargetPresentation targetPresentation; + private int targetPresentationDisplayId = -1; + private final Runnable dismissTargetPresentationRunnable = new Runnable() { + @Override + public void run() { + dismissTargetPresentation("pulseComplete"); + } + }; + private final Handler handler = new Handler(Looper.getMainLooper()); + private ServerSocket pairingServerSocket; + private Thread pairingServerThread; + private boolean jetkvmPeripheralsPresent; + private boolean hasPairedJetKvmEndpoints; + private boolean attemptedForCurrentScreen; + private boolean targetReportScheduled; + private String activeTargetIdentityToken = ""; + private volatile long targetDeclarationConfirmedUntilMs; + private volatile boolean targetDeclarationHDMIReconnectRequired; + private volatile String targetDeclarationCompanionNotice = ""; + private int activeNotificationId = NOTIFICATION_ID; + private int nextNotificationRespawnId = NOTIFICATION_RESPAWN_BASE_ID; + private String activeNotificationBody = ""; + private JetKvmPeripheralSnapshot currentSnapshot = new JetKvmPeripheralSnapshot(); + + private final Runnable targetReportRunnable = new Runnable() { + @Override + public void run() { + targetReportScheduled = false; + if (!jetkvmPeripheralsPresent) return; + reportTargetDeclarationAsync(); + scheduleTargetReport(); + } + }; + + private final BroadcastReceiver screenReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + Log.i(TAG, "screen receiver action=" + action); + if (Intent.ACTION_SCREEN_OFF.equals(action)) { + attemptedForCurrentScreen = false; + if (jetkvmPeripheralsPresent) { + scheduleTargetReport(); + } + return; + } + if (Intent.ACTION_SCREEN_ON.equals(action)) { + if (!jetkvmPeripheralsPresent) { + Log.i(TAG, "screen-on ignored; JetKVM peripherals not present"); + return; + } + scheduleTargetReport(); + if (attemptedForCurrentScreen) { + Log.i(TAG, "screen-on ignored; dismiss already attempted for this screen cycle"); + return; + } + attemptedForCurrentScreen = true; + launchDismissActivity(ACTION_SCREEN_ON); + handler.postDelayed(new Runnable() { + @Override + public void run() { + if (jetkvmPeripheralsPresent) { + pulseTargetPresentation("screen_on"); + } else { + Log.i(TAG, "pending presentation pulse cancelled; JetKVM peripherals removed"); + } + } + }, SCREEN_ON_DISMISS_DELAY_MS); + } + } + }; + + private final DisplayManager.DisplayListener displayListener = new DisplayManager.DisplayListener() { + @Override + public void onDisplayAdded(int displayId) { + updateJetKvmPeripheralState("displayAdded:" + displayId); + if (jetkvmPeripheralsPresent && isJetKvmExternalDisplay(displayManager.getDisplay(displayId))) { + pulseTargetPresentation("displayAdded:" + displayId); + } else { + Log.i(TAG, "display added ignored " + describeDisplay(displayManager.getDisplay(displayId))); + } + } + + @Override + public void onDisplayChanged(int displayId) { + updateJetKvmPeripheralState("displayChanged:" + displayId); + if (jetkvmPeripheralsPresent && isJetKvmExternalDisplay(displayManager.getDisplay(displayId))) { + Log.i(TAG, "display changed observed " + describeDisplay(displayManager.getDisplay(displayId))); + } else { + Log.i(TAG, "display changed ignored " + describeDisplay(displayManager.getDisplay(displayId))); + } + } + + @Override + public void onDisplayRemoved(int displayId) { + updateJetKvmPeripheralState("displayRemoved:" + displayId); + if (displayId == targetPresentationDisplayId) { + dismissTargetPresentation("displayRemoved:" + displayId); + } + } + }; + + @Override + public void onCreate() { + super.onCreate(); + createChannel(); + activeNotificationBody = buildNotificationBody(); + startForeground(activeNotificationId, buildNotification(activeNotificationBody)); + ensureLaunchAssistOverlay(); + displayManager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE); + if (displayManager != null) { + displayManager.registerDisplayListener(displayListener, handler); + } + inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); + if (inputManager != null) { + inputManager.registerInputDeviceListener(this, handler); + } + updateJetKvmPeripheralState("startup"); + + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_SCREEN_OFF); + filter.addAction(Intent.ACTION_SCREEN_ON); + registerReceiver(screenReceiver, filter); + startPairingRequestServer(); + Log.i(TAG, "service onCreate"); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + saveJetKvmUrlFromIntent(intent); + ensureLaunchAssistOverlay(); + updateJetKvmPeripheralState("startCommand"); + Log.i(TAG, "service onStartCommand"); + return START_STICKY; + } + + @Override + public void onDestroy() { + handler.removeCallbacksAndMessages(null); + if (inputManager != null) { + inputManager.unregisterInputDeviceListener(this); + } + if (displayManager != null) { + displayManager.unregisterDisplayListener(displayListener); + } + unregisterReceiver(screenReceiver); + dismissTargetPresentation("destroy"); + removeLaunchAssistOverlay(); + stopPairingRequestServer(); + Log.i(TAG, "service onDestroy"); + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onInputDeviceAdded(int deviceId) { + updateJetKvmPeripheralState("inputAdded:" + deviceId); + } + + @Override + public void onInputDeviceRemoved(int deviceId) { + updateJetKvmPeripheralState("inputRemoved:" + deviceId); + } + + @Override + public void onInputDeviceChanged(int deviceId) { + updateJetKvmPeripheralState("inputChanged:" + deviceId); + } + + static JetKvmPeripheralSnapshot getJetKvmPeripheralSnapshot() { + return getJetKvmPeripheralSnapshot(null); + } + + static JetKvmPeripheralSnapshot getJetKvmPeripheralSnapshot(DisplayManager displayManager) { + return getJetKvmPeripheralSnapshot(displayManager, null); + } + + static JetKvmPeripheralSnapshot getJetKvmPeripheralSnapshot(DisplayManager displayManager, String[] expectedIdentityTokens) { + JetKvmPeripheralSnapshot snapshot = new JetKvmPeripheralSnapshot(); + int[] ids = InputDevice.getDeviceIds(); + for (int id : ids) { + InputDevice device = InputDevice.getDevice(id); + if (device == null) { + continue; + } + + JetKvmInputIdentity identity = JetKvmInputIdentity.from(device, expectedIdentityTokens); + if (!identity.isJetKvm) continue; + if (!snapshot.acceptIdentityToken(identity.identityToken)) continue; + + snapshot.deviceCount++; + if (identity.usesLinuxGadgetIds) { + snapshot.linuxGadgetIdCount++; + } + + int sources = device.getSources(); + if ((sources & InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD) { + snapshot.keyboard = true; + } + if ((sources & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN) { + snapshot.touchscreen = true; + } + if ((sources & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE + || (sources & InputDevice.SOURCE_MOUSE_RELATIVE) == InputDevice.SOURCE_MOUSE_RELATIVE) { + snapshot.pointer = true; + } + } + if (displayManager != null) { + Display[] displays = displayManager.getDisplays(); + for (Display display : displays) { + String identityToken = getJetKvmExternalDisplayIdentityToken(display, expectedIdentityTokens); + if (identityToken != null && snapshot.acceptIdentityToken(identityToken)) { + snapshot.monitor = true; + snapshot.displayCount++; + } + } + } + snapshot.present = snapshot.keyboard || snapshot.touchscreen || snapshot.pointer || snapshot.monitor; + return snapshot; + } + + private void updateJetKvmPeripheralState(String reason) { + SharedPreferences prefs = getCompanionPreferences(this); + boolean hasPairedEndpoints = getPairedJetKvmUrls(prefs).length > 0; + boolean pairingStateChanged = hasPairedEndpoints != hasPairedJetKvmEndpoints; + hasPairedJetKvmEndpoints = hasPairedEndpoints; + JetKvmPeripheralSnapshot snapshot = getJetKvmPeripheralSnapshot( + displayManager, + getPairedJetKvmIdentityTokens(prefs) + ); + snapshot.present = snapshot.present && hasPairedEndpoints && snapshot.connectedIdentityToken.length() > 0; + String previousTargetIdentityToken = activeTargetIdentityToken; + boolean targetIdentityChanged = snapshot.present && + !snapshot.connectedIdentityToken.equals(previousTargetIdentityToken); + currentSnapshot = snapshot; + if (snapshot.present != jetkvmPeripheralsPresent || targetIdentityChanged) { + jetkvmPeripheralsPresent = snapshot.present; + attemptedForCurrentScreen = false; + targetDeclarationConfirmedUntilMs = 0; + if (!jetkvmPeripheralsPresent) { + targetReportScheduled = false; + handler.removeCallbacks(targetReportRunnable); + reportTargetDisconnectAsync(previousTargetIdentityToken); + activeTargetIdentityToken = ""; + dismissTargetPresentation(reason); + } else { + activeTargetIdentityToken = snapshot.connectedIdentityToken; + if (previousTargetIdentityToken.length() > 0 + && !previousTargetIdentityToken.equals(activeTargetIdentityToken)) { + reportTargetDisconnectAsync(previousTargetIdentityToken); + } + reportTargetDeclarationAsync(); + scheduleTargetReport(); + if (!isServiceLifecycleReason(reason)) { + pulseTargetPresentation(reason); + } + } + Log.i(TAG, "JetKVM peripheral state changed reason=" + reason + " " + snapshot); + updateNotification(); + } else if (pairingStateChanged) { + updateNotification(); + } else if ("startup".equals(reason) || "startCommand".equals(reason)) { + Log.i(TAG, "JetKVM peripheral snapshot reason=" + reason + " " + snapshot); + if (jetkvmPeripheralsPresent) { + reportTargetDeclarationAsync(); + scheduleTargetReport(); + } + } + } + + private boolean isServiceLifecycleReason(String reason) { + return "startup".equals(reason) || "startCommand".equals(reason); + } + + private void scheduleTargetReport() { + if (targetReportScheduled) return; + targetReportScheduled = true; + handler.postDelayed(targetReportRunnable, TARGET_REPORT_INTERVAL_MS); + } + + private void reportTargetDeclarationAsync() { + final JetKvmPeripheralSnapshot snapshot = currentSnapshot; + final String[] jetkvmUrls = getPairedJetKvmUrlsForIdentityToken( + getCompanionPreferences(this), + snapshot.connectedIdentityToken + ); + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + final int width = Math.min(metrics.widthPixels, metrics.heightPixels); + final int height = Math.max(metrics.widthPixels, metrics.heightPixels); + if (width <= 0 || height <= 0 || jetkvmUrls.length == 0) return; + + new Thread(new Runnable() { + @Override + public void run() { + for (String jetkvmUrl : jetkvmUrls) { + postTargetDeclaration(jetkvmUrl, true, width, height, snapshot); + } + } + }, "JetKVM-target-report").start(); + } + + private void reportTargetDisconnectAsync(String identityToken) { + final String[] jetkvmUrls = getPairedJetKvmUrlsForIdentityToken(getCompanionPreferences(this), identityToken); + if (jetkvmUrls.length == 0) return; + + new Thread(new Runnable() { + @Override + public void run() { + for (String jetkvmUrl : jetkvmUrls) { + postTargetDeclaration(jetkvmUrl, false, 0, 0, currentSnapshot); + } + } + }, "JetKVM-target-disconnect").start(); + } + + private void saveJetKvmUrlFromIntent(Intent intent) { + if (intent == null || !intent.hasExtra(EXTRA_JETKVM_URL)) return; + + String value = intent.getStringExtra(EXTRA_JETKVM_URL); + if (value == null) return; + + value = value.trim(); + if (value.length() == 0) value = DEFAULT_JETKVM_URL; + boolean saved = addJetKvmUrl(getCompanionPreferences(this), value); + Log.i(TAG, "JetKVM URL updated from intent saved=" + saved + " url=" + value); + } + + private void postTargetDeclaration(String baseUrl, boolean connected, int width, int height, JetKvmPeripheralSnapshot snapshot) { + HttpsURLConnection conn = null; + try { + String trimmedBaseUrl = normalizeJetKvmUrl(baseUrl); + if (trimmedBaseUrl.length() == 0) trimmedBaseUrl = DEFAULT_JETKVM_URL; + + URL url = new URL(trimmedBaseUrl + "/companion/target"); + SharedPreferences prefs = getCompanionPreferences(this); + CompanionPairing pairing = getPairing(prefs, trimmedBaseUrl); + if (pairing == null) { + Log.i(TAG, "target declaration skipped unpaired url=" + trimmedBaseUrl); + return; + } + String identityToken = pairing.identityToken; + if (identityToken.length() == 0) { + Log.i(TAG, "target declaration skipped missing identity url=" + trimmedBaseUrl); + return; + } + JSONObject payload = new JSONObject(); + payload.put("state", connected ? "connected" : "disconnected"); + payload.put("jetkvm_usb_identity", identityToken); + payload.put("target_type", "android"); + payload.put("notification_permission_granted", hasNotificationPermission()); + payload.put("display_over_apps_permission_granted", Settings.canDrawOverlays(this)); + payload.put("battery_unrestricted_granted", isIgnoringBatteryOptimizations()); + payload.put("paired_jetkvm_urls", new JSONArray(getPairedJetKvmUrls(prefs))); + payload.put("visible_ips", new JSONArray(getVisibleLocalIPs())); + if (connected) { + payload.put("preferred_mouse_mode", "digitizer"); + payload.put("display_width", width); + payload.put("display_height", height); + payload.put("display_aspect", (double) width / (double) height); + payload.put("lease_ms", TARGET_LEASE_MS); + payload.put("evidence", snapshot.toJsonEvidenceArray()); + } + byte[] bodyBytes = payload.toString().getBytes(StandardCharsets.UTF_8); + + conn = openTrustedConnection(url); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + applyCompanionSignatureHeaders(conn, "POST", "/companion/target", bodyBytes, pairing); + conn.setFixedLengthStreamingMode(bodyBytes.length); + + OutputStream out = conn.getOutputStream(); + out.write(bodyBytes); + out.close(); + + int status = conn.getResponseCode(); + boolean hdmiReconnectRequired = false; + String companionNotice = ""; + if (status >= 200 && status < 300) { + String responseBody = readResponseBody(conn); + if (responseBody.length() > 0) { + JSONObject response = new JSONObject(responseBody); + hdmiReconnectRequired = response.optBoolean("hdmi_reconnect_required", false); + companionNotice = response.optString("companion_notice", ""); + processRequestedActions(response.optJSONArray("requested_actions")); + } + } else if (status == 401 || status == 404) { + removeRejectedPairing(trimmedBaseUrl, status); + } + updateTargetDeclarationConfirmation(connected, status, hdmiReconnectRequired, companionNotice); + Log.i(TAG, "target declaration posted url=" + trimmedBaseUrl + + " status=" + status + " connected=" + connected + " width=" + width + " height=" + height + + " hdmiReconnectRequired=" + hdmiReconnectRequired); + } catch (Exception e) { + updateTargetDeclarationConfirmation(connected, 0, false, ""); + Log.i(TAG, "target declaration failed url=" + baseUrl + ": " + e.getClass().getSimpleName()); + } finally { + if (conn != null) conn.disconnect(); + } + } + + private void removeRejectedPairing(final String baseUrl, int status) { + SharedPreferences prefs = getCompanionPreferences(this); + if (!removePairing(prefs, baseUrl)) { + return; + } + Log.i(TAG, "removed local pairing rejected by JetKVM url=" + baseUrl + " status=" + status); + handler.post(new Runnable() { + @Override + public void run() { + updateJetKvmPeripheralState("pairingRejected:" + baseUrl); + updateNotification(); + } + }); + } + + private static String readResponseBody(HttpsURLConnection conn) throws Exception { + InputStream stream = conn.getInputStream(); + if (stream == null) return ""; + BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8)); + StringBuilder body = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + body.append(line); + } + reader.close(); + return body.toString(); + } + + private void updateTargetDeclarationConfirmation(boolean connected, int status, boolean hdmiReconnectRequired, String companionNotice) { + long now = System.currentTimeMillis(); + boolean confirmed = connected && status >= 200 && status < 300; + if (confirmed) { + targetDeclarationConfirmedUntilMs = now + TARGET_LEASE_MS; + targetDeclarationHDMIReconnectRequired = hdmiReconnectRequired; + targetDeclarationCompanionNotice = companionNotice == null ? "" : companionNotice; + } else if (!connected || now >= targetDeclarationConfirmedUntilMs) { + targetDeclarationConfirmedUntilMs = 0; + targetDeclarationHDMIReconnectRequired = false; + targetDeclarationCompanionNotice = ""; + } + handler.post(new Runnable() { + @Override + public void run() { + updateNotification(); + } + }); + } + + private boolean isTargetDeclarationConfirmed() { + return targetDeclarationConfirmedUntilMs > System.currentTimeMillis(); + } + + private void processRequestedActions(JSONArray actions) { + if (actions == null || actions.length() == 0) return; + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + intent.putExtra(MainActivity.EXTRA_PERMISSION_ACTIONS, actions.toString()); + try { + startActivity(intent); + } catch (RuntimeException e) { + Log.i(TAG, "permission action launch failed: " + e.getClass().getSimpleName()); + } + } + + static SharedPreferences getCompanionPreferences(Context context) { + if (Build.VERSION.SDK_INT < 24) { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + Context deviceContext = context.createDeviceProtectedStorageContext(); + deviceContext.moveSharedPreferencesFrom(context, PREFS); + return deviceContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + static String getConfiguredJetKvmUrlsText(SharedPreferences prefs) { + String[] urls = getConfiguredJetKvmUrls(prefs); + StringBuilder builder = new StringBuilder(); + for (String url : urls) { + if (builder.length() > 0) builder.append('\n'); + builder.append(url); + } + return builder.toString(); + } + + static boolean saveJetKvmUrlsText(SharedPreferences prefs, String rawText) { + String[] urls = parseJetKvmUrls(rawText); + StringBuilder builder = new StringBuilder(); + for (String url : urls) { + if (builder.length() > 0) builder.append('\n'); + builder.append(url); + } + return prefs.edit() + .putString(KEY_JETKVM_URLS, builder.toString()) + .putString(KEY_JETKVM_URL, urls.length == 0 ? DEFAULT_JETKVM_URL : urls[0]) + .commit(); + } + + static boolean addJetKvmUrl(SharedPreferences prefs, String rawUrl) { + LinkedHashSet urls = new LinkedHashSet(); + String[] existingUrls = getConfiguredJetKvmUrls(prefs); + for (String existingUrl : existingUrls) { + urls.add(existingUrl); + } + String normalized = normalizeJetKvmUrl(rawUrl); + if (normalized.length() > 0) { + urls.add(normalized); + } + return saveJetKvmUrlsText(prefs, joinUrls(urls)); + } + + static String[] getConfiguredJetKvmUrls(SharedPreferences prefs) { + String rawText = prefs.getString(KEY_JETKVM_URLS, null); + if (rawText == null || rawText.trim().length() == 0) { + rawText = prefs.getString(KEY_JETKVM_URL, DEFAULT_JETKVM_URL); + } + return parseJetKvmUrls(rawText); + } + + static String[] getPairedJetKvmUrls(SharedPreferences prefs) { + LinkedHashSet pairedUrls = new LinkedHashSet(); + CompanionPairing[] pairings = getSavedPairings(prefs); + for (CompanionPairing pairing : pairings) { + pairedUrls.add(pairing.url); + } + return pairedUrls.toArray(new String[pairedUrls.size()]); + } + + static String[] getPairedJetKvmUrlsForIdentityToken(SharedPreferences prefs, String identityToken) { + String normalizedIdentityToken = normalizeIdentityToken(identityToken); + LinkedHashSet pairedUrls = new LinkedHashSet(); + if (normalizedIdentityToken.length() == 0) { + return pairedUrls.toArray(new String[pairedUrls.size()]); + } + + CompanionPairing[] pairings = getSavedPairings(prefs); + for (CompanionPairing pairing : pairings) { + if (normalizedIdentityToken.equals(normalizeIdentityToken(pairing.identityToken))) { + pairedUrls.add(pairing.url); + } + } + return pairedUrls.toArray(new String[pairedUrls.size()]); + } + + static CompanionPairing[] getSavedPairings(SharedPreferences prefs) { + LinkedHashSet lines = new LinkedHashSet(); + String rawText = prefs.getString(KEY_JETKVM_PAIRINGS, ""); + String[] rawLines = rawText.split("\\n"); + for (String line : rawLines) { + CompanionPairing pairing = CompanionPairing.fromLine(line); + if (pairing != null) { + lines.add(pairing.toLine()); + } + } + + CompanionPairing[] pairings = new CompanionPairing[lines.size()]; + int i = 0; + for (String line : lines) { + pairings[i++] = CompanionPairing.fromLine(line); + } + return pairings; + } + + static String getPairingCompanionId(SharedPreferences prefs, String rawUrl) { + CompanionPairing pairing = getPairing(prefs, rawUrl); + return pairing == null ? "" : pairing.companionId; + } + + static String getPairingIdentityToken(SharedPreferences prefs, String rawUrl) { + CompanionPairing pairing = getPairing(prefs, rawUrl); + return pairing == null ? "" : pairing.identityToken; + } + + static String[] getPairedJetKvmIdentityTokens(SharedPreferences prefs) { + LinkedHashSet tokens = new LinkedHashSet(); + String rawText = prefs.getString(KEY_JETKVM_PAIRINGS, ""); + String[] lines = rawText.split("\\n"); + for (String line : lines) { + CompanionPairing pairing = CompanionPairing.fromLine(line); + if (pairing != null && pairing.identityToken.length() > 0) { + tokens.add(normalizeIdentityToken(pairing.identityToken)); + } + } + return tokens.toArray(new String[tokens.size()]); + } + + static CompanionPairing getPairing(SharedPreferences prefs, String rawUrl) { + String normalizedUrl = normalizeJetKvmUrl(rawUrl); + if (normalizedUrl.length() == 0) return null; + + String rawText = prefs.getString(KEY_JETKVM_PAIRINGS, ""); + String[] lines = rawText.split("\\n"); + for (String line : lines) { + CompanionPairing pairing = CompanionPairing.fromLine(line); + if (pairing != null && normalizedUrl.equals(pairing.url)) { + return pairing; + } + } + return null; + } + + static boolean savePairing(SharedPreferences prefs, String rawUrl, String companionId, String privateKey, String identityToken) { + String normalizedUrl = normalizeJetKvmUrl(rawUrl); + if (normalizedUrl.length() == 0 || companionId == null || companionId.trim().length() == 0 || + privateKey == null || privateKey.trim().length() == 0) { + return false; + } + + LinkedHashSet lines = new LinkedHashSet(); + String rawText = prefs.getString(KEY_JETKVM_PAIRINGS, ""); + String[] existingLines = rawText.split("\\n"); + for (String line : existingLines) { + CompanionPairing existing = CompanionPairing.fromLine(line); + if (existing != null && !normalizedUrl.equals(existing.url)) { + lines.add(existing.toLine()); + } + } + lines.add(new CompanionPairing( + normalizedUrl, + companionId.trim(), + privateKey.trim(), + normalizeIdentityToken(identityToken) + ).toLine()); + return prefs.edit().putString(KEY_JETKVM_PAIRINGS, joinUrls(lines)).commit(); + } + + static boolean removePairing(SharedPreferences prefs, String rawUrl) { + String normalizedUrl = normalizeJetKvmUrl(rawUrl); + if (normalizedUrl.length() == 0) return false; + + LinkedHashSet lines = new LinkedHashSet(); + String rawText = prefs.getString(KEY_JETKVM_PAIRINGS, ""); + String[] existingLines = rawText.split("\\n"); + for (String line : existingLines) { + CompanionPairing existing = CompanionPairing.fromLine(line); + if (existing != null && !normalizedUrl.equals(existing.url)) { + lines.add(existing.toLine()); + } + } + return prefs.edit().putString(KEY_JETKVM_PAIRINGS, joinUrls(lines)).commit(); + } + + private static String[] parseJetKvmUrls(String rawText) { + LinkedHashSet normalizedUrls = new LinkedHashSet(); + String text = rawText == null ? "" : rawText; + String[] parts = text.split("[,\\s]+"); + for (String part : parts) { + String normalized = normalizeJetKvmUrl(part); + if (normalized.length() > 0) { + normalizedUrls.add(normalized); + } + } + if (normalizedUrls.isEmpty()) { + normalizedUrls.add(DEFAULT_JETKVM_URL); + } + return normalizedUrls.toArray(new String[normalizedUrls.size()]); + } + + private static String normalizeJetKvmUrl(String rawUrl) { + String url = rawUrl == null ? "" : rawUrl.trim(); + if (url.length() == 0) return ""; + if (!url.contains("://")) { + url = "https://" + url; + } + if (!url.toLowerCase(Locale.US).startsWith("https://")) { + return ""; + } + while (url.endsWith("/") && url.length() > "https://".length()) { + url = url.substring(0, url.length() - 1); + } + return url; + } + + static String[] getVisibleLocalIPs() { + LinkedHashSet ips = new LinkedHashSet(); + try { + java.util.Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces != null && interfaces.hasMoreElements()) { + NetworkInterface iface = interfaces.nextElement(); + if (!iface.isUp() || iface.isLoopback()) continue; + java.util.Enumeration addresses = iface.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress address = addresses.nextElement(); + if (address.isLoopbackAddress() || address.isLinkLocalAddress()) continue; + String host = address.getHostAddress(); + if (host == null || host.length() == 0) continue; + int scope = host.indexOf('%'); + if (scope >= 0) host = host.substring(0, scope); + ips.add(host); + } + } + } catch (Exception e) { + Log.i(TAG, "visible IP enumeration failed: " + e.getClass().getSimpleName()); + } + return ips.toArray(new String[ips.size()]); + } + + static HttpsURLConnection openTrustedConnection(URL url) throws Exception { + if (!"https".equalsIgnoreCase(url.getProtocol())) { + throw new IllegalArgumentException("JetKVM communication requires HTTPS"); + } + HttpsURLConnection conn = (HttpsURLConnection) url.openConnection(); + conn.setSSLSocketFactory(trustAllSslContext().getSocketFactory()); + conn.setHostnameVerifier(new HostnameVerifier() { + @Override + public boolean verify(String hostname, SSLSession session) { + return true; + } + }); + return conn; + } + + private static SSLContext trustAllSslContext() throws Exception { + TrustManager[] trustManagers = new TrustManager[] { + new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } + }; + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, trustManagers, new SecureRandom()); + return context; + } + + private boolean hasNotificationPermission() { + return Build.VERSION.SDK_INT < 33 + || checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED; + } + + private boolean isIgnoringBatteryOptimizations() { + PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); + return powerManager == null || powerManager.isIgnoringBatteryOptimizations(getPackageName()); + } + + private static String normalizeIdentityToken(String identityToken) { + return identityToken == null ? "" : identityToken.trim().toLowerCase(Locale.US); + } + + private static String joinUrls(LinkedHashSet urls) { + StringBuilder builder = new StringBuilder(); + for (String url : urls) { + if (builder.length() > 0) builder.append('\n'); + builder.append(url); + } + return builder.toString(); + } + + static void applyCompanionSignatureHeaders(HttpsURLConnection conn, String method, String path, byte[] bodyBytes, CompanionPairing pairing) throws Exception { + String timestamp = java.time.Instant.now().toString(); + String nonce = UUID.randomUUID().toString() + "-" + Long.toHexString(new SecureRandom().nextLong()); + String bodyHash = hex(MessageDigest.getInstance("SHA-256").digest(bodyBytes)); + String canonical = method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash; + + byte[] privateKeyBytes = android.util.Base64.decode(pairing.privateKey, android.util.Base64.NO_WRAP); + PrivateKey privateKey = KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes)); + Signature signer = Signature.getInstance("SHA256withECDSA"); + signer.initSign(privateKey); + signer.update(canonical.getBytes(StandardCharsets.UTF_8)); + String signature = android.util.Base64.encodeToString(signer.sign(), android.util.Base64.NO_WRAP); + + conn.setRequestProperty("X-JetKVM-Companion-ID", pairing.companionId); + conn.setRequestProperty("X-JetKVM-Timestamp", timestamp); + conn.setRequestProperty("X-JetKVM-Nonce", nonce); + conn.setRequestProperty("X-JetKVM-Signature", signature); + } + + private static String hex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + char[] table = "0123456789abcdef".toCharArray(); + for (int i = 0; i < bytes.length; i++) { + int value = bytes[i] & 0xff; + out[i * 2] = table[value >>> 4]; + out[i * 2 + 1] = table[value & 0x0f]; + } + return new String(out); + } + + static final class CompanionPairing { + final String url; + final String companionId; + final String privateKey; + final String identityToken; + + CompanionPairing(String url, String companionId, String privateKey, String identityToken) { + this.url = url; + this.companionId = companionId; + this.privateKey = privateKey; + this.identityToken = identityToken; + } + + static CompanionPairing fromLine(String line) { + if (line == null) return null; + String[] parts = line.split("\\|", -1); + if (parts.length < 4) return null; + String url = normalizeJetKvmUrl(parts[0]); + String companionId = parts[1].trim(); + String privateKey = parts[2].trim(); + String identityToken = parts[3].trim().toLowerCase(Locale.US); + if (url.length() == 0 || companionId.length() == 0 || privateKey.length() == 0) return null; + return new CompanionPairing(url, companionId, privateKey, identityToken); + } + + String toLine() { + return url + "|" + companionId + "|" + privateKey + "|" + identityToken; + } + } + + private void pulseTargetPresentation(String reason) { + Display display = findJetKvmPresentationDisplay(reason); + if (display == null) { + dismissTargetPresentation("noJetKvmDisplay:" + reason); + return; + } + + int displayId = display.getDisplayId(); + dismissTargetPresentation("replace:" + reason); + try { + targetPresentation = new TargetPresentation(this, display); + targetPresentation.show(); + targetPresentationDisplayId = displayId; + handler.removeCallbacks(dismissTargetPresentationRunnable); + handler.postDelayed(dismissTargetPresentationRunnable, TARGET_PRESENTATION_PULSE_MS); + Log.i(TAG, "target presentation pulse shown reason=" + reason + + " durationMs=" + TARGET_PRESENTATION_PULSE_MS + " " + describeDisplay(display)); + } catch (WindowManager.InvalidDisplayException e) { + targetPresentation = null; + targetPresentationDisplayId = -1; + Log.i(TAG, "target presentation invalid display reason=" + reason); + } catch (RuntimeException e) { + targetPresentation = null; + targetPresentationDisplayId = -1; + Log.i(TAG, "target presentation failed reason=" + reason + ": " + e.getClass().getSimpleName()); + } + } + + private Display findJetKvmPresentationDisplay(String reason) { + if (displayManager == null) return null; + + Display[] presentationDisplays = displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION); + for (Display display : presentationDisplays) { + if (isJetKvmExternalDisplay(display)) { + return display; + } + } + + Display[] displays = displayManager.getDisplays(); + for (Display display : displays) { + if (isJetKvmExternalDisplay(display)) { + return display; + } + } + logExternalDisplays("no JetKVM display for presentation reason=" + reason); + return null; + } + + private boolean isJetKvmExternalDisplay(Display display) { + return isJetKvmExternalDisplayStatic( + display, + getPairedJetKvmIdentityTokens(getCompanionPreferences(this)) + ); + } + + private static boolean isJetKvmExternalDisplayStatic(Display display) { + return isJetKvmExternalDisplayStatic(display, null); + } + + private static boolean isJetKvmExternalDisplayStatic(Display display, String[] expectedIdentityTokens) { + return getJetKvmExternalDisplayIdentityToken(display, expectedIdentityTokens) != null; + } + + private static String getJetKvmExternalDisplayIdentityToken(Display display, String[] expectedIdentityTokens) { + if (display == null || display.getDisplayId() == Display.DEFAULT_DISPLAY) { + return null; + } + + String name = display.getName(); + if (name == null) return null; + String normalizedName = name.toLowerCase(Locale.US); + if (!normalizedName.contains(JETKVM_DISPLAY_NAME_TOKEN) + && !normalizedName.contains(JETKVM_SHORT_DISPLAY_NAME_TOKEN)) { + return null; + } + return matchingIdentityToken(normalizedName, expectedIdentityTokens); + } + + private void logExternalDisplays(String reason) { + if (displayManager == null) return; + Display[] displays = displayManager.getDisplays(); + StringBuilder builder = new StringBuilder(reason); + boolean foundExternal = false; + for (Display display : displays) { + if (display != null && display.getDisplayId() != Display.DEFAULT_DISPLAY) { + foundExternal = true; + builder.append(" candidate=").append(describeDisplay(display)); + } + } + if (!foundExternal) { + builder.append("; no external displays reported"); + } + Log.i(TAG, builder.toString()); + } + + private String describeDisplay(Display display) { + if (display == null) return "display=null"; + return "displayId=" + display.getDisplayId() + " name=\"" + display.getName() + "\""; + } + + private void dismissTargetPresentation(String reason) { + handler.removeCallbacks(dismissTargetPresentationRunnable); + if (targetPresentation == null) return; + try { + targetPresentation.dismiss(); + Log.i(TAG, "target presentation dismissed reason=" + reason); + } catch (RuntimeException e) { + Log.i(TAG, "target presentation dismiss failed reason=" + reason + + ": " + e.getClass().getSimpleName()); + } + targetPresentation = null; + targetPresentationDisplayId = -1; + } + + private void launchDismissActivity(String action) { + Intent activity = new Intent(this, DismissActivity.class); + activity.setAction(action); + activity.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_SINGLE_TOP + | Intent.FLAG_ACTIVITY_NO_HISTORY + | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS + ); + Log.i(TAG, "starting dismiss activity action=" + action); + try { + startActivity(activity); + } catch (RuntimeException e) { + Log.i(TAG, "starting dismiss activity failed: " + e.getClass().getSimpleName()); + } + } + + private void ensureLaunchAssistOverlay() { + if (!Settings.canDrawOverlays(this)) { + Log.i(TAG, "overlay permission not granted; background dismiss launch may be blocked"); + return; + } + if (launchAssistOverlay != null) return; + + windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE); + launchAssistOverlay = new View(this); + launchAssistOverlay.setAlpha(0.01f); + + WindowManager.LayoutParams params = new WindowManager.LayoutParams( + 1, + 1, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT + ); + params.gravity = Gravity.TOP | Gravity.START; + params.x = 0; + params.y = 0; + + try { + windowManager.addView(launchAssistOverlay, params); + Log.i(TAG, "launch assist overlay added"); + } catch (RuntimeException e) { + Log.i(TAG, "launch assist overlay failed: " + e.getClass().getSimpleName()); + launchAssistOverlay = null; + } + } + + private void removeLaunchAssistOverlay() { + if (windowManager == null || launchAssistOverlay == null) return; + try { + windowManager.removeView(launchAssistOverlay); + } catch (RuntimeException ignored) { + } + launchAssistOverlay = null; + } + + private void createChannel() { + if (android.os.Build.VERSION.SDK_INT < 26) return; + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "JetKVM Companion", + NotificationManager.IMPORTANCE_LOW + ); + NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + manager.createNotificationChannel(channel); + } + + private String buildNotificationBody() { + if (!hasPairedJetKvmEndpoints) { + return "Waiting for a device to be paired..."; + } + if (!jetkvmPeripheralsPresent) { + return "Waiting for peripherals..."; + } + if (!isTargetDeclarationConfirmed()) { + return "Waiting for backend confirmation..."; + } + if (targetDeclarationHDMIReconnectRequired) { + return targetDeclarationCompanionNotice.length() > 0 + ? targetDeclarationCompanionNotice + : "Using 1920x1080 crop mode. Disconnect and reconnect HDMI to apply the new display size."; + } + return "Monitoring display-on events"; + } + + private Notification buildNotification(String body) { + Intent intent = new Intent(this, MainActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + Notification.Builder builder = android.os.Build.VERSION.SDK_INT >= 26 + ? new Notification.Builder(this, CHANNEL_ID) + : new Notification.Builder(this); + builder + .setSmallIcon(getApplicationInfo().icon) + .setContentTitle("JetKVM Companion") + .setContentText(body) + .setStyle(new Notification.BigTextStyle().bigText(body)) + .setContentIntent(pendingIntent) + .setOnlyAlertOnce(false) + .setOngoing(true); + if (!hasPairedJetKvmEndpoints) { + builder.addAction(getApplicationInfo().icon, "Pair", pendingIntent); + } + return builder.build(); + } + + private void updateNotification() { + NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null) { + return; + } + + String body = buildNotificationBody(); + Notification notification = buildNotification(body); + if (!body.equals(activeNotificationBody)) { + int previousNotificationId = activeNotificationId; + activeNotificationId = nextNotificationRespawnId++; + activeNotificationBody = body; + startForeground(activeNotificationId, notification); + if (previousNotificationId != activeNotificationId) { + manager.cancel(previousNotificationId); + } + } else { + manager.notify(activeNotificationId, notification); + } + } + + private void startPairingRequestServer() { + if (pairingServerThread != null) return; + pairingServerThread = new Thread(new Runnable() { + @Override + public void run() { + try { + pairingServerSocket = createPairingTLSServerSocket(); + while (!Thread.currentThread().isInterrupted()) { + handlePairingRequestSocket(pairingServerSocket.accept()); + } + } catch (Exception e) { + Log.w(TAG, "pairing request listener stopped", e); + } + } + }, "JetKVM-pair-listener"); + pairingServerThread.start(); + } + + private void stopPairingRequestServer() { + if (pairingServerThread != null) { + pairingServerThread.interrupt(); + pairingServerThread = null; + } + if (pairingServerSocket != null) { + try { + pairingServerSocket.close(); + } catch (Exception ignored) { + } + pairingServerSocket = null; + } + } + + private ServerSocket createPairingTLSServerSocket() throws Exception { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + byte[] keyStoreBytes = android.util.Base64.decode(PAIRING_TLS_PKCS12_BASE64, android.util.Base64.DEFAULT); + keyStore.load(new ByteArrayInputStream(keyStoreBytes), PAIRING_TLS_KEYSTORE_PASSWORD); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm() + ); + keyManagerFactory.init(keyStore, PAIRING_TLS_KEYSTORE_PASSWORD); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom()); + SSLServerSocketFactory factory = context.getServerSocketFactory(); + SSLServerSocket socket = (SSLServerSocket) factory.createServerSocket(PAIRING_LISTEN_PORT); + socket.setUseClientMode(false); + socket.setNeedClientAuth(false); + socket.setEnabledProtocols(new String[] { "TLSv1.3", "TLSv1.2" }); + return socket; + } + + private void handlePairingRequestSocket(Socket socket) { + try { + if (socket instanceof SSLSocket) { + SSLSocket sslSocket = (SSLSocket) socket; + sslSocket.setUseClientMode(false); + sslSocket.startHandshake(); + } + BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + String requestLine = reader.readLine(); + int contentLength = 0; + String line; + while ((line = reader.readLine()) != null && line.length() > 0) { + String lower = line.toLowerCase(Locale.US); + if (lower.startsWith("content-length:")) { + contentLength = Integer.parseInt(line.substring(line.indexOf(':') + 1).trim()); + } + } + char[] chars = new char[Math.max(0, contentLength)]; + int read = 0; + while (read < chars.length) { + int count = reader.read(chars, read, chars.length - read); + if (count < 0) break; + read += count; + } + + String body = new String(chars, 0, read); + String jetkvmUrl = extractJsonString(body, "jetkvm_url"); + String normalizedJetKvmUrl = normalizeJetKvmUrl(jetkvmUrl); + String requestId = extractJsonString(body, "request_id"); + if (requestLine != null + && requestLine.startsWith("POST /pair/request ") + && normalizedJetKvmUrl.length() > 0 + && requestId.length() > 0) { + savePendingPairRequest(normalizedJetKvmUrl); + writePairingServerResponse(socket, 202, "{\"status\":\"pending\"}"); + } else if (requestLine != null && requestLine.startsWith("POST /pair/unpair ")) { + int removed = removePairingsFromAdminUnpair(body); + writePairingServerResponse(socket, 200, "{\"removed\":" + removed + "}"); + } else { + writePairingServerResponse(socket, 400, "{\"error\":\"invalid pairing request\"}"); + } + } catch (Exception e) { + Log.w(TAG, "pairing request failed", e); + } finally { + try { + socket.close(); + } catch (Exception ignored) { + } + } + } + + private int removePairingsFromAdminUnpair(String body) { + int removed = 0; + try { + SharedPreferences prefs = getCompanionPreferences(this); + JSONObject payload = new JSONObject(body == null ? "{}" : body); + JSONArray urls = payload.optJSONArray("jetkvm_urls"); + if (urls == null) { + urls = new JSONArray(); + String url = payload.optString("jetkvm_url", ""); + if (url.length() > 0) urls.put(url); + } + for (int i = 0; i < urls.length(); i++) { + String rawUrl = urls.optString(i, ""); + if (rawUrl.length() > 0 && removePairing(prefs, rawUrl)) { + removed++; + } + } + if (removed > 0) { + Log.i(TAG, "removed local pairings from backend admin unpair count=" + removed); + handler.post(new Runnable() { + @Override + public void run() { + updateJetKvmPeripheralState("backendAdminUnpair"); + updateNotification(); + } + }); + } + } catch (Exception e) { + Log.w(TAG, "admin unpair cleanup failed: " + e.getClass().getSimpleName()); + } + return removed; + } + + private void savePendingPairRequest(String jetkvmUrl) { + SharedPreferences prefs = getCompanionPreferences(this); + prefs.edit() + .putString(KEY_PENDING_PAIR_URL, jetkvmUrl) + .putLong(KEY_PENDING_PAIR_CREATED_AT, System.currentTimeMillis()) + .apply(); + Intent intent = new Intent(ACTION_PAIR_REQUEST_UPDATED); + intent.setPackage(getPackageName()); + sendBroadcast(intent); + } + + private void writePairingServerResponse(Socket socket, int status, String body) throws java.io.IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + String reason = status == 202 ? "Accepted" : status == 200 ? "OK" : "Bad Request"; + OutputStream out = socket.getOutputStream(); + out.write(("HTTP/1.1 " + status + " " + reason + "\r\nContent-Type: application/json\r\nContent-Length: " + bytes.length + "\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(bytes); + out.flush(); + } + + private static String extractJsonString(String json, String key) { + if (json == null || key == null) return ""; + String needle = "\"" + key + "\""; + int keyIndex = json.indexOf(needle); + if (keyIndex < 0) return ""; + int colonIndex = json.indexOf(':', keyIndex + needle.length()); + if (colonIndex < 0) return ""; + int startQuote = json.indexOf('"', colonIndex + 1); + if (startQuote < 0) return ""; + int endQuote = json.indexOf('"', startQuote + 1); + if (endQuote < 0) return ""; + return json.substring(startQuote + 1, endQuote); + } + + private static final class TargetPresentation extends Presentation { + TargetPresentation(Context context, Display display) { + super(context, display, R.style.TransparentPresentation); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + + View anchor = new View(getContext()); + anchor.setAlpha(0.01f); + anchor.setKeepScreenOn(true); + FrameLayout root = new FrameLayout(getContext()); + root.setBackgroundColor(Color.TRANSPARENT); + root.setKeepScreenOn(true); + root.addView(anchor, new FrameLayout.LayoutParams(1, 1)); + setContentView(root); + + Window window = getWindow(); + if (window != null) { + window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.dimAmount = 0f; + attrs.gravity = Gravity.TOP | Gravity.START; + attrs.x = 0; + attrs.y = 0; + window.setAttributes(attrs); + } + } + + @Override + protected void onStart() { + super.onStart(); + Window window = getWindow(); + if (window != null) { + window.setLayout(1, 1); + } + } + } + + private static final class JetKvmInputIdentity { + final boolean isJetKvm; + final boolean usesLinuxGadgetIds; + final String identityToken; + + private JetKvmInputIdentity(boolean isJetKvm, boolean usesLinuxGadgetIds, String identityToken) { + this.isJetKvm = isJetKvm; + this.usesLinuxGadgetIds = usesLinuxGadgetIds; + this.identityToken = identityToken; + } + + static JetKvmInputIdentity from(InputDevice device, String[] expectedIdentityTokens) { + String name = device.getName(); + String normalizedName = name == null ? "" : name.toLowerCase(java.util.Locale.US); + String identityToken = normalizedName.contains(JETKVM_INPUT_NAME_TOKEN) + ? matchingIdentityToken(normalizedName, expectedIdentityTokens) + : null; + boolean nameMatches = identityToken != null; + + int vendorId = 0; + int productId = 0; + if (Build.VERSION.SDK_INT >= 19) { + vendorId = device.getVendorId(); + productId = device.getProductId(); + } + + boolean linuxGadgetIds = vendorId == LINUX_GADGET_VENDOR_ID + && productId == LINUX_GADGET_PRODUCT_ID; + + if (!nameMatches) { + return new JetKvmInputIdentity(false, linuxGadgetIds, ""); + } + + if (vendorId != 0 && productId != 0 && !linuxGadgetIds) { + Log.i(TAG, "JetKVM-named input uses non-default vid/pid vendor=" + + vendorId + " product=" + productId); + } + + return new JetKvmInputIdentity(true, linuxGadgetIds, identityToken); + } + } + + private static String matchingIdentityToken(String text, String[] expectedIdentityTokens) { + if (expectedIdentityTokens == null || expectedIdentityTokens.length == 0) { + return ""; + } + if (text == null) return null; + String normalizedText = text.toLowerCase(Locale.US); + for (String token : expectedIdentityTokens) { + String normalizedToken = normalizeIdentityToken(token); + if (normalizedToken.length() > 0 && normalizedText.contains(normalizedToken)) { + return normalizedToken; + } + } + return null; + } + + static final class JetKvmPeripheralSnapshot { + boolean keyboard; + boolean touchscreen; + boolean pointer; + boolean monitor; + boolean present; + int deviceCount; + int linuxGadgetIdCount; + int displayCount; + String connectedIdentityToken = ""; + + boolean acceptIdentityToken(String identityToken) { + String normalizedIdentityToken = normalizeIdentityToken(identityToken); + if (normalizedIdentityToken.length() == 0) return connectedIdentityToken.length() == 0; + if (connectedIdentityToken.length() == 0) { + connectedIdentityToken = normalizedIdentityToken; + return true; + } + return connectedIdentityToken.equals(normalizedIdentityToken); + } + + String toJsonEvidence() { + StringBuilder builder = new StringBuilder(); + appendEvidence(builder, keyboard, "keyboard"); + appendEvidence(builder, touchscreen, "digitizer"); + appendEvidence(builder, pointer, "mouse"); + appendEvidence(builder, monitor, "monitor"); + return builder.toString(); + } + + JSONArray toJsonEvidenceArray() { + JSONArray evidence = new JSONArray(); + appendEvidence(evidence, keyboard, "keyboard"); + appendEvidence(evidence, touchscreen, "digitizer"); + appendEvidence(evidence, pointer, "mouse"); + appendEvidence(evidence, monitor, "monitor"); + return evidence; + } + + private static void appendEvidence(StringBuilder builder, boolean enabled, String value) { + if (!enabled) return; + if (builder.length() > 0) builder.append(','); + builder.append('"').append(value).append('"'); + } + + private static void appendEvidence(JSONArray evidence, boolean enabled, String value) { + if (enabled) evidence.put(value); + } + + @Override + public String toString() { + return "devices=" + deviceCount + + " linuxGadgetIds=" + linuxGadgetIdCount + + " displays=" + displayCount + + " keyboard=" + keyboard + + " touchscreen=" + touchscreen + + " pointer=" + pointer + + " monitor=" + monitor + + " identityToken=" + connectedIdentityToken + + " present=" + present; + } + } + +} diff --git a/jetkvm-companion/src/com/jetkvm/companion/DismissActivity.java b/jetkvm-companion/src/com/jetkvm/companion/DismissActivity.java new file mode 100644 index 000000000..1ffe1e1e9 --- /dev/null +++ b/jetkvm-companion/src/com/jetkvm/companion/DismissActivity.java @@ -0,0 +1,129 @@ +package com.jetkvm.companion; + +import android.app.Activity; +import android.app.KeyguardManager; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; + +public class DismissActivity extends Activity { + static final String ACTION_MANUAL = "com.jetkvm.companion.MANUAL_DISMISS"; + + private static final long DISMISS_DELAY_MS = 300; + private static final long FINISH_TIMEOUT_MS = 2500; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private KeyguardManager keyguardManager; + private boolean dismissInFlight; + private boolean dismissScheduled; + private final Runnable finishTimeout = new Runnable() { + @Override + public void run() { + logState("finish timeout"); + finishAndRemoveTask(); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + configureLockscreenWindow(); + + FrameLayout root = new FrameLayout(this); + root.setBackgroundColor(Color.TRANSPARENT); + setContentView(root); + + keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); + scheduleDismiss("onCreate"); + } + + @Override + protected void onResume() { + super.onResume(); + scheduleDismiss("onResume"); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + scheduleDismiss("onNewIntent:" + intent.getAction()); + } + + private void configureLockscreenWindow() { + if (android.os.Build.VERSION.SDK_INT >= 27) { + setShowWhenLocked(true); + setTurnScreenOn(false); + } else { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED); + } + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + } + + private void scheduleDismiss(final String reason) { + if (dismissScheduled) return; + dismissScheduled = true; + handler.postDelayed(new Runnable() { + @Override + public void run() { + requestDismiss(reason); + } + }, DISMISS_DELAY_MS); + } + + private void requestDismiss(String reason) { + if (keyguardManager == null || dismissInFlight) return; + handler.postDelayed(finishTimeout, FINISH_TIMEOUT_MS); + + boolean keyguardLocked = keyguardManager.isKeyguardLocked(); + boolean deviceLocked = keyguardManager.isDeviceLocked(); + Log.i(CompanionService.TAG, reason + " keyguardLocked=" + keyguardLocked + " deviceLocked=" + deviceLocked); + + if (!keyguardLocked) { + handler.removeCallbacks(finishTimeout); + finishAndRemoveTask(); + return; + } + + dismissInFlight = true; + keyguardManager.requestDismissKeyguard(this, new KeyguardManager.KeyguardDismissCallback() { + @Override + public void onDismissError() { + dismissInFlight = false; + handler.removeCallbacks(finishTimeout); + logState("callback onDismissError"); + finishAndRemoveTask(); + } + + @Override + public void onDismissSucceeded() { + dismissInFlight = false; + handler.removeCallbacks(finishTimeout); + logState("callback onDismissSucceeded"); + finishAndRemoveTask(); + } + + @Override + public void onDismissCancelled() { + dismissInFlight = false; + handler.removeCallbacks(finishTimeout); + logState("callback onDismissCancelled"); + finishAndRemoveTask(); + } + }); + } + + private void logState(String label) { + boolean keyguardLocked = keyguardManager != null && keyguardManager.isKeyguardLocked(); + boolean deviceLocked = keyguardManager != null && keyguardManager.isDeviceLocked(); + Log.i(CompanionService.TAG, label + " keyguardLocked=" + keyguardLocked + " deviceLocked=" + deviceLocked); + } +} diff --git a/jetkvm-companion/src/com/jetkvm/companion/MainActivity.java b/jetkvm-companion/src/com/jetkvm/companion/MainActivity.java new file mode 100644 index 000000000..8a3ad52f2 --- /dev/null +++ b/jetkvm-companion/src/com/jetkvm/companion/MainActivity.java @@ -0,0 +1,1511 @@ +package com.jetkvm.companion; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.hardware.display.DisplayManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.CountDownTimer; +import android.os.PowerManager; +import android.provider.Settings; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.view.Gravity; +import android.view.View; +import android.view.Window; +import android.view.WindowInsets; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.spec.ECGenParameterSpec; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import javax.net.ssl.HttpsURLConnection; +import org.json.JSONArray; + +public class MainActivity extends Activity { + private static final int REQUEST_POST_NOTIFICATIONS = 10; + private static final long PAIRING_OTP_TTL_MS = 120000; + private static final int JETKVM_BACKGROUND = Color.rgb(7, 12, 28); + private static final int JETKVM_BLUE_700 = Color.rgb(20, 71, 230); + static final String EXTRA_PERMISSION_ACTIONS = "permission_actions"; + private static final String EXTRA_JETKVM_URL = "jetkvm_url"; + private static final String KEY_PAIRINGS_COLLAPSED = "ui_pairings_collapsed"; + private static final String KEY_VISIBLE_IPS_COLLAPSED = "ui_visible_ips_collapsed"; + private static final SecureRandom PAIRING_RANDOM = new SecureRandom(); + + private SharedPreferences prefs; + private CheckBox launchOnBootInput; + private EditText jetkvmUrlsInput; + private Button pairJetkvmButton; + private TextView pairJetkvmState; + private LinearLayout pairingsList; + private LinearLayout visibleIpsList; + private Button pairingsToggleButton; + private Button visibleIpsToggleButton; + private Button refreshVisibleIpsButton; + private Button notificationButton; + private Button overlayButton; + private Button batteryButton; + private TextView statusText; + private LinearLayout pairingOtpPanel; + private TextView pairingOtpLabel; + private TextView pairingOtpCode; + private TextView pairingOtpCountdown; + private EditText jetkvmPairingCodeInput; + private Button jetkvmPairingCodeButton; + private String pendingPairUrl = ""; + private CountDownTimer pairingOtpTimer; + private final Map pairingReachability = new HashMap<>(); + private boolean requestedNotificationThisLaunch; + private boolean requestedOverlayThisLaunch; + private boolean requestedBatteryThisLaunch; + private boolean pairRequestReceiverRegistered; + private final BroadcastReceiver pairRequestReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + loadPendingPairRequest(); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + + prefs = getCompanionPreferences(); + saveJetKvmUrlFromIntent(getIntent()); + startCompanionServiceFromIntent(getIntent()); + setContentView(createSettingsView()); + registerPairRequestReceiver(); + loadPendingPairRequest(); + handlePermissionActionsFromIntent(getIntent()); + updateArmStatus(); + refreshPairingReachability(); + requestMissingPermissionsIfNeeded(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + saveJetKvmUrlFromIntent(intent); + if (jetkvmUrlsInput != null) { + jetkvmUrlsInput.setText(""); + } + handlePermissionActionsFromIntent(intent); + refreshPairingControls(); + startCompanionServiceFromIntent(intent); + } + + @Override + protected void onResume() { + super.onResume(); + loadPendingPairRequest(); + updateArmStatus(); + refreshPairingReachability(); + requestMissingPermissionsIfNeeded(); + } + + @Override + protected void onDestroy() { + if (pairingOtpTimer != null) { + pairingOtpTimer.cancel(); + pairingOtpTimer = null; + } + if (pairRequestReceiverRegistered) { + unregisterReceiver(pairRequestReceiver); + pairRequestReceiverRegistered = false; + } + super.onDestroy(); + } + + private android.view.View createSettingsView() { + int padding = dp(24); + final int horizontalPadding = padding; + final int topPadding = dp(44); + final int bottomPadding = padding; + + ScrollView scroller = new ScrollView(this); + scroller.setFillViewport(true); + scroller.setBackgroundColor(JETKVM_BACKGROUND); + scroller.setClipToPadding(false); + scroller.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); + + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setGravity(Gravity.CENTER_HORIZONTAL); + root.setPadding(horizontalPadding, topPadding, horizontalPadding, bottomPadding); + root.setBackgroundColor(JETKVM_BACKGROUND); + if (Build.VERSION.SDK_INT >= 20) { + root.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + v.setPadding( + horizontalPadding, + topPadding + insets.getSystemWindowInsetTop(), + horizontalPadding, + bottomPadding + insets.getSystemWindowInsetBottom() + ); + return insets; + } + }); + root.requestApplyInsets(); + } + scroller.addView(root, new ScrollView.LayoutParams( + ScrollView.LayoutParams.MATCH_PARENT, + ScrollView.LayoutParams.WRAP_CONTENT + )); + + TextView title = new TextView(this); + title.setText("JetKVM Companion"); + title.setTextColor(Color.WHITE); + title.setTextSize(26); + title.setGravity(Gravity.CENTER); + title.setTextIsSelectable(true); + root.addView(title, matchWrap()); + + pairingOtpPanel = new LinearLayout(this); + pairingOtpPanel.setOrientation(LinearLayout.VERTICAL); + pairingOtpPanel.setGravity(Gravity.CENTER_HORIZONTAL); + pairingOtpPanel.setPadding(0, 0, 0, dp(10)); + pairingOtpPanel.setVisibility(View.GONE); + + pairingOtpLabel = new TextView(this); + pairingOtpLabel.setText("Pairing code"); + pairingOtpLabel.setTextColor(Color.rgb(203, 213, 225)); + pairingOtpLabel.setTextSize(14); + pairingOtpLabel.setGravity(Gravity.CENTER); + pairingOtpLabel.setTextIsSelectable(true); + pairingOtpPanel.addView(pairingOtpLabel, tightWrap()); + + pairingOtpCode = new TextView(this); + pairingOtpCode.setTextColor(Color.WHITE); + pairingOtpCode.setTextSize(42); + pairingOtpCode.setGravity(Gravity.CENTER); + pairingOtpCode.setLetterSpacing(0.08f); + pairingOtpCode.setTextIsSelectable(true); + pairingOtpPanel.addView(pairingOtpCode, tightWrap()); + + pairingOtpCountdown = new TextView(this); + pairingOtpCountdown.setTextColor(Color.rgb(148, 163, 184)); + pairingOtpCountdown.setTextSize(15); + pairingOtpCountdown.setGravity(Gravity.CENTER); + pairingOtpCountdown.setTextIsSelectable(true); + pairingOtpPanel.addView(pairingOtpCountdown, tightWrap()); + + jetkvmPairingCodeInput = new EditText(this); + jetkvmPairingCodeInput.setSingleLine(true); + jetkvmPairingCodeInput.setInputType(InputType.TYPE_CLASS_NUMBER); + jetkvmPairingCodeInput.setText(""); + jetkvmPairingCodeInput.setHint("6 digit code from JetKVM web UI"); + jetkvmPairingCodeInput.setTextColor(Color.WHITE); + jetkvmPairingCodeInput.setHintTextColor(Color.rgb(148, 163, 184)); + jetkvmPairingCodeInput.setVisibility(View.GONE); + jetkvmPairingCodeInput.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + String cleaned = s.toString().replaceAll("\\D", ""); + if (cleaned.length() > 6) { + cleaned = cleaned.substring(0, 6); + } + if (!cleaned.equals(s.toString())) { + jetkvmPairingCodeInput.setText(cleaned); + jetkvmPairingCodeInput.setSelection(cleaned.length()); + } + } + }); + pairingOtpPanel.addView(jetkvmPairingCodeInput, matchWrap()); + + jetkvmPairingCodeButton = new Button(this); + jetkvmPairingCodeButton.setText("Pair"); + jetkvmPairingCodeButton.setAllCaps(false); + applyCompactActionButtonStyle(jetkvmPairingCodeButton); + jetkvmPairingCodeButton.setVisibility(View.GONE); + jetkvmPairingCodeButton.setOnClickListener(new android.view.View.OnClickListener() { + @Override + public void onClick(android.view.View v) { + String code = jetkvmPairingCodeInput == null ? "" : jetkvmPairingCodeInput.getText().toString(); + claimJetKvmGeneratedPairing(pendingPairUrl, code); + } + }); + pairingOtpPanel.addView(jetkvmPairingCodeButton, tightWrap()); + + root.addView(pairingOtpPanel, matchWrap()); + + TextView description = new TextView(this); + description.setText("Target-side helper for JetKVM Android metadata, keyguard, and display handling."); + description.setTextColor(Color.rgb(203, 213, 225)); + description.setTextSize(15); + description.setGravity(Gravity.CENTER); + description.setPadding(0, dp(8), 0, dp(18)); + description.setTextIsSelectable(true); + root.addView(description, matchWrap()); + + if (pendingPairUrl.length() > 0) { + showJetKvmPairingCodeEntry(pendingPairUrl); + } + + launchOnBootInput = new CheckBox(this); + launchOnBootInput.setText("Launch on boot"); + launchOnBootInput.setTextColor(Color.WHITE); + launchOnBootInput.setTextSize(16); + launchOnBootInput.setChecked(prefs.getBoolean(CompanionService.KEY_LAUNCH_ON_BOOT, false)); + launchOnBootInput.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + prefs.edit().putBoolean(CompanionService.KEY_LAUNCH_ON_BOOT, isChecked).apply(); + updateStatus(isChecked ? "Launch on boot enabled." : "Launch on boot disabled."); + } + }); + root.addView(launchOnBootInput, matchWrap()); + + TextView jetkvmUrlsLabel = new TextView(this); + jetkvmUrlsLabel.setText("JetKVM endpoint"); + jetkvmUrlsLabel.setTextColor(Color.WHITE); + jetkvmUrlsLabel.setTextSize(16); + jetkvmUrlsLabel.setTextIsSelectable(true); + root.addView(jetkvmUrlsLabel, tightWrap()); + + jetkvmUrlsInput = new EditText(this); + jetkvmUrlsInput.setSingleLine(true); + jetkvmUrlsInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + jetkvmUrlsInput.setText(""); + jetkvmUrlsInput.setHint("JetKVM IP or https://jetkvm.local"); + jetkvmUrlsInput.setTextColor(Color.WHITE); + jetkvmUrlsInput.setHintTextColor(Color.rgb(148, 163, 184)); + jetkvmUrlsInput.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + updatePairButtonState(); + } + + @Override + public void afterTextChanged(Editable s) { + } + }); + root.addView(jetkvmUrlsInput, matchWrap()); + + LinearLayout pairActionRow = new LinearLayout(this); + pairActionRow.setOrientation(LinearLayout.HORIZONTAL); + pairActionRow.setGravity(Gravity.CENTER_VERTICAL); + + pairJetkvmButton = new Button(this); + pairJetkvmButton.setText("Pair"); + pairJetkvmButton.setAllCaps(false); + applyButtonStyle(pairJetkvmButton); + pairJetkvmButton.setOnClickListener(new android.view.View.OnClickListener() { + @Override + public void onClick(android.view.View v) { + String value = jetkvmUrlsInput.getText().toString(); + if (value.trim().length() == 0) { + return; + } + pairJetKvmEndpoint(value); + } + }); + pairActionRow.addView(pairJetkvmButton, buttonWrap()); + + pairJetkvmState = new TextView(this); + pairJetkvmState.setText("Paired"); + pairJetkvmState.setTextColor(Color.rgb(34, 197, 94)); + pairJetkvmState.setTextSize(14); + pairJetkvmState.setPadding(dp(12), 0, 0, 0); + pairJetkvmState.setTextIsSelectable(true); + pairActionRow.addView(pairJetkvmState, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + root.addView(pairActionRow, matchWrap()); + + pairingsToggleButton = addCollapsibleHeader(root, "Paired JetKVM endpoints", KEY_PAIRINGS_COLLAPSED, new Runnable() { + @Override + public void run() { + updatePairingsVisibility(); + } + }); + + pairingsList = new LinearLayout(this); + pairingsList.setOrientation(LinearLayout.VERTICAL); + root.addView(pairingsList, matchWrap()); + refreshPairingControls(); + + addCollapsibleHeaderRow(root, "This device LAN/VPN IPs", KEY_VISIBLE_IPS_COLLAPSED, new Runnable() { + @Override + public void run() { + updateVisibleIpsVisibility(); + } + }); + + visibleIpsList = new LinearLayout(this); + visibleIpsList.setOrientation(LinearLayout.VERTICAL); + root.addView(visibleIpsList, matchWrap()); + refreshVisibleIps(); + updatePairButtonState(); + + notificationButton = new Button(this); + notificationButton.setText("Grant permission to post notifications"); + notificationButton.setAllCaps(false); + applyButtonStyle(notificationButton); + notificationButton.setOnClickListener(new android.view.View.OnClickListener() { + @Override + public void onClick(android.view.View v) { + requestNotificationPermission(); + } + }); + root.addView(notificationButton, matchWrap()); + + overlayButton = new Button(this); + overlayButton.setText("Grant permission to display over other apps"); + overlayButton.setAllCaps(false); + applyButtonStyle(overlayButton); + overlayButton.setOnClickListener(new android.view.View.OnClickListener() { + @Override + public void onClick(android.view.View v) { + requestOverlayPermission(); + } + }); + root.addView(overlayButton, matchWrap()); + + batteryButton = new Button(this); + batteryButton.setText("Grant unrestricted battery usage"); + batteryButton.setAllCaps(false); + applyButtonStyle(batteryButton); + batteryButton.setOnClickListener(new android.view.View.OnClickListener() { + @Override + public void onClick(android.view.View v) { + requestBatteryOptimizationExemption(); + } + }); + root.addView(batteryButton, matchWrap()); + + statusText = new TextView(this); + statusText.setTextColor(Color.rgb(148, 163, 184)); + statusText.setTextSize(14); + statusText.setGravity(Gravity.CENTER); + statusText.setPadding(0, dp(16), 0, 0); + statusText.setTextIsSelectable(true); + root.addView(statusText, matchWrap()); + + return scroller; + } + + private LinearLayout.LayoutParams matchWrap() { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.setMargins(0, 0, 0, dp(12)); + return params; + } + + private LinearLayout.LayoutParams tightWrap() { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.setMargins(0, 0, 0, dp(4)); + return params; + } + + private LinearLayout.LayoutParams buttonWrap() { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.setMargins(0, 0, 0, 0); + return params; + } + + private int dp(int value) { + return Math.round(value * getResources().getDisplayMetrics().density); + } + + private void applyButtonStyle(Button button) { + button.setTextColor(Color.WHITE); + button.setBackgroundTintList(ColorStateList.valueOf(JETKVM_BLUE_700)); + } + + private void applyDisabledButtonStyle(Button button) { + button.setTextColor(Color.rgb(203, 213, 225)); + button.setBackgroundTintList(ColorStateList.valueOf(Color.rgb(51, 65, 85))); + } + + private void applyDisclosureButtonStyle(Button button) { + button.setTextColor(Color.rgb(203, 213, 225)); + button.setBackgroundTintList(ColorStateList.valueOf(JETKVM_BACKGROUND)); + } + + private void applyCompactActionButtonStyle(Button button) { + applyButtonStyle(button); + button.setTextSize(12); + button.setMinHeight(dp(32)); + button.setMinimumHeight(dp(32)); + button.setMinWidth(dp(72)); + button.setMinimumWidth(dp(72)); + button.setPadding(dp(12), 0, dp(12), 0); + } + + private Button addCollapsibleHeader(LinearLayout root, String title, final String key, final Runnable onToggle) { + LinearLayout row = addCollapsibleHeaderRow(root, title, key, onToggle); + Object tag = row.getTag(); + return tag instanceof Button ? (Button) tag : null; + } + + private LinearLayout addCollapsibleHeaderRow(LinearLayout root, String title, final String key, final Runnable onToggle) { + LinearLayout header = new LinearLayout(this); + header.setOrientation(LinearLayout.HORIZONTAL); + header.setGravity(Gravity.CENTER_VERTICAL); + + TextView label = new TextView(this); + label.setText(title); + label.setTextColor(Color.WHITE); + label.setTextSize(16); + label.setTextIsSelectable(true); + header.addView(label, new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1 + )); + + final Button toggleButton = new Button(this); + toggleButton.setAllCaps(false); + toggleButton.setTextSize(28); + toggleButton.setMinHeight(0); + toggleButton.setMinimumHeight(0); + toggleButton.setMinWidth(0); + toggleButton.setMinimumWidth(0); + toggleButton.setPadding(dp(10), 0, dp(10), 0); + applyDisclosureButtonStyle(toggleButton); + updateCollapsibleButtonText(toggleButton, key); + toggleButton.setOnClickListener(new android.view.View.OnClickListener() { + @Override + public void onClick(android.view.View v) { + boolean nextCollapsed = !isSectionCollapsed(key); + prefs.edit().putBoolean(key, nextCollapsed).apply(); + updateCollapsibleButtonText(toggleButton, key); + if (onToggle != null) onToggle.run(); + } + }); + header.addView(toggleButton, buttonWrap()); + header.setTag(toggleButton); + if (KEY_VISIBLE_IPS_COLLAPSED.equals(key)) { + visibleIpsToggleButton = toggleButton; + } + root.addView(header, matchWrap()); + return header; + } + + private boolean isSectionCollapsed(String key) { + return prefs.getBoolean(key, true); + } + + private void updateCollapsibleButtonText(Button button, String key) { + if (button == null) return; + button.setText(isSectionCollapsed(key) ? "›" : "⌄"); + } + + private void updatePairingsVisibility() { + if (pairingsList != null) { + pairingsList.setVisibility(isSectionCollapsed(KEY_PAIRINGS_COLLAPSED) ? View.GONE : View.VISIBLE); + } + updateCollapsibleButtonText(pairingsToggleButton, KEY_PAIRINGS_COLLAPSED); + } + + private void updateVisibleIpsVisibility() { + if (visibleIpsList != null) { + visibleIpsList.setVisibility(isSectionCollapsed(KEY_VISIBLE_IPS_COLLAPSED) ? View.GONE : View.VISIBLE); + } + updateCollapsibleButtonText(visibleIpsToggleButton, KEY_VISIBLE_IPS_COLLAPSED); + } + + private void updateStatus(String message) { + if (statusText != null) statusText.setText(message); + } + + private void updatePairButtonState() { + if (pairJetkvmButton == null || pairJetkvmState == null || jetkvmUrlsInput == null) { + return; + } + String entered = jetkvmUrlsInput.getText().toString().trim(); + boolean hasValue = entered.length() > 0; + boolean paired = hasValue && CompanionService.getPairing(prefs, entered) != null; + pairJetkvmButton.setEnabled(hasValue && !paired); + if (paired) { + applyDisabledButtonStyle(pairJetkvmButton); + applyReachabilityStateText(pairJetkvmState, pairingReachability.get(normalizeJetKvmUrl(entered))); + pairJetkvmState.setVisibility(android.view.View.VISIBLE); + } else { + if (hasValue) { + applyButtonStyle(pairJetkvmButton); + } else { + applyDisabledButtonStyle(pairJetkvmButton); + } + pairJetkvmState.setVisibility(android.view.View.GONE); + } + } + + private void saveJetKvmUrlFromIntent(Intent intent) { + if (intent == null || !intent.hasExtra(EXTRA_JETKVM_URL)) return; + + String value = intent.getStringExtra(EXTRA_JETKVM_URL); + if (value == null) return; + + value = value.trim(); + if (value.length() == 0) value = CompanionService.DEFAULT_JETKVM_URL; + CompanionService.addJetKvmUrl(prefs, value); + } + + private void startCompanionServiceFromIntent(Intent source) { + Intent service = new Intent(this, CompanionService.class); + if (source != null && source.hasExtra(EXTRA_JETKVM_URL)) { + service.putExtra( + CompanionService.EXTRA_JETKVM_URL, + source.getStringExtra(EXTRA_JETKVM_URL) + ); + } + startForegroundService(service); + } + + private void registerPairRequestReceiver() { + if (pairRequestReceiverRegistered) return; + IntentFilter filter = new IntentFilter(CompanionService.ACTION_PAIR_REQUEST_UPDATED); + if (Build.VERSION.SDK_INT >= 33) { + registerReceiver(pairRequestReceiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(pairRequestReceiver, filter); + } + pairRequestReceiverRegistered = true; + } + + private void loadPendingPairRequest() { + String url = prefs.getString(CompanionService.KEY_PENDING_PAIR_URL, ""); + long createdAt = prefs.getLong(CompanionService.KEY_PENDING_PAIR_CREATED_AT, 0); + if (url == null || url.trim().length() == 0 || createdAt <= 0) { + return; + } + long age = System.currentTimeMillis() - createdAt; + if (age >= PAIRING_OTP_TTL_MS) { + prefs.edit() + .remove(CompanionService.KEY_PENDING_PAIR_URL) + .remove(CompanionService.KEY_PENDING_PAIR_CREATED_AT) + .apply(); + if (pendingPairUrl.length() > 0) { + pendingPairUrl = ""; + hidePairingOtp(); + } + return; + } + pendingPairUrl = normalizeJetKvmUrl(url); + if (pendingPairUrl.length() == 0) return; + showJetKvmPairingCodeEntry(pendingPairUrl, PAIRING_OTP_TTL_MS - age); + updateStatus("Pairing request from " + pendingPairUrl + "."); + } + + private void handlePermissionActionsFromIntent(Intent intent) { + if (intent == null || !intent.hasExtra(EXTRA_PERMISSION_ACTIONS)) return; + String rawActions = intent.getStringExtra(EXTRA_PERMISSION_ACTIONS); + if (rawActions == null || rawActions.length() == 0) return; + try { + JSONArray actions = new JSONArray(rawActions); + for (int i = 0; i < actions.length(); i++) { + String action = actions.optString(i, ""); + if ("request_notification_permission".equals(action)) { + requestNotificationPermission(); + } else if ("request_display_over_apps_permission".equals(action)) { + requestOverlayPermission(); + } else if ("request_unrestricted_battery_permission".equals(action)) { + requestBatteryOptimizationExemption(); + } + } + } catch (Exception e) { + updateStatus("Permission request ignored: " + e.getClass().getSimpleName()); + } + } + + private void updateArmStatus() { + DisplayManager displayManager = (DisplayManager) getSystemService(DISPLAY_SERVICE); + CompanionService.JetKvmPeripheralSnapshot snapshot = CompanionService.getJetKvmPeripheralSnapshot( + displayManager, + CompanionService.getPairedJetKvmIdentityTokens(prefs) + ); + boolean notificationGranted = hasNotificationPermission(); + boolean overlayGranted = Settings.canDrawOverlays(this); + boolean batteryGranted = isIgnoringBatteryOptimizations(); + + if (notificationButton != null) { + notificationButton.setText("Grant permission to post notifications"); + notificationButton.setVisibility(notificationGranted ? View.GONE : View.VISIBLE); + } + if (overlayButton != null) { + overlayButton.setText("Grant permission to display over other apps"); + overlayButton.setVisibility(overlayGranted ? View.GONE : View.VISIBLE); + } + if (batteryButton != null) { + batteryButton.setText("Grant unrestricted battery usage"); + batteryButton.setVisibility(batteryGranted ? View.GONE : View.VISIBLE); + } + + String notificationStatus = notificationGranted + ? "Notifications granted" + : "Notifications not granted"; + String overlayStatus = overlayGranted + ? "Display over other apps granted" + : "Display over other apps not granted"; + String batteryStatus = batteryGranted + ? "Unrestricted battery granted" + : "Unrestricted battery not granted"; + updateStatus(notificationStatus + "\n" + overlayStatus + "\n" + batteryStatus + + "\nJetKVM peripherals: " + snapshot); + } + + private void refreshPairingControls() { + if (pairingsList == null) return; + pairingsList.removeAllViews(); + + CompanionService.CompanionPairing[] pairings = CompanionService.getSavedPairings(prefs); + if (pairings.length == 0) { + TextView empty = new TextView(this); + empty.setText("No paired JetKVM endpoints."); + empty.setTextColor(Color.rgb(148, 163, 184)); + empty.setTextSize(13); + empty.setTextIsSelectable(true); + pairingsList.addView(empty, tightWrap()); + } + for (final CompanionService.CompanionPairing pairing : pairings) { + final String url = pairing.url; + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(0, dp(4), 0, dp(4)); + + Button unpairButton = new Button(this); + unpairButton.setText("Unpair"); + unpairButton.setAllCaps(false); + applyCompactActionButtonStyle(unpairButton); + unpairButton.setOnClickListener(new android.view.View.OnClickListener() { + @Override + public void onClick(android.view.View v) { + unpairJetKvmEndpoint(url); + } + }); + row.addView(unpairButton, buttonWrap()); + + TextView label = new TextView(this); + label.setText(url); + label.setTextColor(Color.WHITE); + label.setTextSize(14); + label.setSingleLine(false); + label.setMaxLines(2); + label.setPadding(dp(12), 0, dp(8), 0); + label.setTextIsSelectable(true); + row.addView(label, new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1 + )); + + TextView state = new TextView(this); + state.setTextSize(13); + state.setTextIsSelectable(true); + applyReachabilityStateText(state, pairingReachability.get(normalizeJetKvmUrl(url))); + row.addView(state, buttonWrap()); + + pairingsList.addView(row, matchWrap()); + } + updatePairButtonState(); + refreshVisibleIps(); + updatePairingsVisibility(); + } + + private void refreshVisibleIps() { + if (visibleIpsList == null) return; + visibleIpsList.removeAllViews(); + + refreshVisibleIpsButton = new Button(this); + refreshVisibleIpsButton.setText("Refresh"); + refreshVisibleIpsButton.setAllCaps(false); + applyButtonStyle(refreshVisibleIpsButton); + refreshVisibleIpsButton.setOnClickListener(new android.view.View.OnClickListener() { + @Override + public void onClick(android.view.View v) { + refreshVisibleIps(); + } + }); + LinearLayout refreshRow = new LinearLayout(this); + refreshRow.setOrientation(LinearLayout.HORIZONTAL); + refreshRow.setGravity(Gravity.LEFT); + refreshRow.addView(refreshVisibleIpsButton, buttonWrap()); + visibleIpsList.addView(refreshRow, tightWrap()); + + String[] ips = CompanionService.getVisibleLocalIPs(); + int visibleCount = 0; + for (final String ip : ips) { + visibleCount++; + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(0, dp(2), 0, dp(2)); + + Button pairButton = new Button(this); + pairButton.setText("Pair"); + pairButton.setAllCaps(false); + applyCompactActionButtonStyle(pairButton); + pairButton.setOnClickListener(new android.view.View.OnClickListener() { + @Override + public void onClick(android.view.View v) { + String endpoint = normalizeJetKvmUrl(ip); + jetkvmUrlsInput.setText(endpoint); + pairJetKvmEndpoint(endpoint); + } + }); + row.addView(pairButton, buttonWrap()); + + TextView label = new TextView(this); + label.setText(ip); + label.setTextColor(Color.rgb(203, 213, 225)); + label.setTextSize(14); + label.setTextIsSelectable(true); + label.setPadding(dp(12), 0, 0, 0); + row.addView(label, new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1 + )); + visibleIpsList.addView(row, tightWrap()); + } + + if (visibleCount == 0) { + TextView empty = new TextView(this); + empty.setText("No LAN/VPN IPs visible."); + empty.setTextColor(Color.rgb(148, 163, 184)); + empty.setTextSize(13); + empty.setTextIsSelectable(true); + visibleIpsList.addView(empty, tightWrap()); + } + updateVisibleIpsVisibility(); + } + + private static String hostFromUrl(String rawUrl) { + try { + URL url = new URL(normalizeJetKvmUrl(rawUrl)); + return url.getHost(); + } catch (Exception e) { + return ""; + } + } + + private void pairJetKvmEndpoint(final String baseUrl) { + final String normalizedBaseUrl = normalizeJetKvmUrl(baseUrl); + updateStatus("Checking " + normalizedBaseUrl + " for pending JetKVM pairing."); + new Thread(new Runnable() { + @Override + public void run() { + final boolean jetkvmPending = hasPendingJetKvmGeneratedPairing(normalizedBaseUrl); + runOnUiThread(new Runnable() { + @Override + public void run() { + if (jetkvmPending) { + pendingPairUrl = normalizedBaseUrl; + showJetKvmPairingCodeEntry(normalizedBaseUrl); + updateStatus("Enter the code shown in the JetKVM web UI."); + } else { + startCompanionInitiatedPairing(normalizedBaseUrl); + } + } + }); + } + }, "JetKVM-pair-check").start(); + } + + private void startCompanionInitiatedPairing(final String baseUrl) { + final String otp = generatePairingOtp(); + showPairingOtp(otp); + updateStatus("Pairing " + baseUrl + ". Type " + otp + " in the JetKVM web UI."); + new Thread(new Runnable() { + @Override + public void run() { + final PairResult result = requestPairing(baseUrl, otp); + runOnUiThread(new Runnable() { + @Override + public void run() { + if (result.success) { + CompanionService.savePairing(prefs, baseUrl, result.companionId, result.privateKey, result.identityToken); + hidePairingOtp(); + refreshPairingControls(); + refreshPairingReachability(); + startForegroundService(new Intent(MainActivity.this, CompanionService.class)); + updateStatus("Paired " + baseUrl + "."); + } else { + hidePairingOtp(); + updateStatus("Pairing failed for " + baseUrl + ": " + result.message); + } + } + }); + } + }, "JetKVM-pair").start(); + } + + private boolean hasPendingJetKvmGeneratedPairing(String baseUrl) { + HttpsURLConnection conn = null; + try { + URL url = new URL(normalizeJetKvmUrl(baseUrl) + "/companion/pair/jetkvm-pending"); + conn = CompanionService.openTrustedConnection(url); + conn.setConnectTimeout(2000); + conn.setReadTimeout(2000); + conn.setRequestMethod("GET"); + int status = conn.getResponseCode(); + if (status != 200) { + return false; + } + String response = readAll(conn.getInputStream()); + return extractJsonBoolean(response, "pending"); + } catch (Exception e) { + return false; + } finally { + if (conn != null) conn.disconnect(); + } + } + + private void claimJetKvmGeneratedPairing(final String baseUrl, final String otp) { + if (otp == null || !otp.trim().matches("\\d{6}")) { + updateStatus("Enter the 6 digit code from the JetKVM web UI."); + return; + } + updateStatus("Pairing " + baseUrl + " with JetKVM-generated code."); + new Thread(new Runnable() { + @Override + public void run() { + final PairResult result = claimPairingWithJetKvmGeneratedCode(baseUrl, otp); + runOnUiThread(new Runnable() { + @Override + public void run() { + if (result.success) { + CompanionService.savePairing(prefs, baseUrl, result.companionId, result.privateKey, result.identityToken); + pendingPairUrl = ""; + prefs.edit() + .remove(CompanionService.KEY_PENDING_PAIR_URL) + .remove(CompanionService.KEY_PENDING_PAIR_CREATED_AT) + .apply(); + hidePairingOtp(); + refreshPairingControls(); + refreshPairingReachability(); + startForegroundService(new Intent(MainActivity.this, CompanionService.class)); + updateStatus("Paired " + baseUrl + "."); + } else { + updateStatus("Pairing failed for " + baseUrl + ": " + result.message); + } + } + }); + } + }, "JetKVM-pair-code").start(); + } + + private PairResult requestPairing(String baseUrl, String otp) { + HttpsURLConnection conn = null; + try { + PairingKeys keys = generatePairingKeys(); + String trimmedBaseUrl = normalizeJetKvmUrl(baseUrl); + URL url = new URL(trimmedBaseUrl + "/companion/pair"); + conn = CompanionService.openTrustedConnection(url); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json"); + byte[] requestBody = ("{\"otp\":\"" + otp + "\",\"companion_public_key\":\"" + keys.publicKey + "\"}").getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(requestBody.length); + conn.getOutputStream().write(requestBody); + + int status = conn.getResponseCode(); + String response = readAll(status >= 400 ? conn.getErrorStream() : conn.getInputStream()); + if (status == 200) { + return parsePairingResponse(response, keys.privateKey); + } + if (status != 202) { + return PairResult.error("status " + status); + } + + String requestId = extractJsonString(response, "request_id"); + if (requestId.length() == 0) { + return PairResult.error("missing request id"); + } + return pollPairingStatus(trimmedBaseUrl, requestId, keys.privateKey); + } catch (Exception e) { + return PairResult.error(describeNetworkFailure(e)); + } finally { + if (conn != null) conn.disconnect(); + } + } + + private PairResult claimPairingWithJetKvmGeneratedCode(String baseUrl, String otp) { + HttpsURLConnection conn = null; + try { + PairingKeys keys = generatePairingKeys(); + String trimmedBaseUrl = normalizeJetKvmUrl(baseUrl); + URL url = new URL(trimmedBaseUrl + "/companion/pair"); + conn = CompanionService.openTrustedConnection(url); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json"); + byte[] requestBody = ( + "{\"otp\":\"" + otp.trim() + + "\",\"claim_jetkvm\":true,\"companion_public_key\":\"" + + keys.publicKey + "\"}" + ).getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(requestBody.length); + conn.getOutputStream().write(requestBody); + + int status = conn.getResponseCode(); + String response = readAll(status >= 400 ? conn.getErrorStream() : conn.getInputStream()); + if (status == 200) { + return parsePairingResponse(response, keys.privateKey); + } + String error = extractJsonString(response, "error"); + if (error.length() > 0) { + return PairResult.error(error); + } + return PairResult.error("status " + status); + } catch (Exception e) { + return PairResult.error(describeNetworkFailure(e)); + } finally { + if (conn != null) conn.disconnect(); + } + } + + private void unpairJetKvmEndpoint(final String baseUrl) { + updateStatus("Unpairing " + baseUrl + "."); + new Thread(new Runnable() { + @Override + public void run() { + final UnpairResult result = requestUnpair(baseUrl); + runOnUiThread(new Runnable() { + @Override + public void run() { + CompanionService.removePairing(prefs, baseUrl); + pairingReachability.remove(normalizeJetKvmUrl(baseUrl)); + refreshPairingControls(); + startForegroundService(new Intent(MainActivity.this, CompanionService.class)); + if (result.backendUpdated) { + updateStatus("Unpaired " + baseUrl + "."); + } else { + updateStatus("Removed local pairing for " + baseUrl + ". JetKVM cleanup skipped: " + result.message + "."); + } + } + }); + } + }, "JetKVM-unpair").start(); + } + + private UnpairResult requestUnpair(String baseUrl) { + HttpsURLConnection conn = null; + try { + String trimmedBaseUrl = normalizeJetKvmUrl(baseUrl); + CompanionService.CompanionPairing pairing = CompanionService.getPairing(prefs, trimmedBaseUrl); + if (pairing == null) return UnpairResult.backendUpdated(); + byte[] requestBody = new byte[0]; + URL url = new URL(trimmedBaseUrl + "/companion/unpair"); + conn = CompanionService.openTrustedConnection(url); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + CompanionService.applyCompanionSignatureHeaders(conn, "POST", "/companion/unpair", requestBody, pairing); + conn.setFixedLengthStreamingMode(requestBody.length); + int status = conn.getResponseCode(); + if (status == 200 || status == 401 || status == 404) { + return UnpairResult.backendUpdated(); + } + return UnpairResult.localOnly("status " + status); + } catch (Exception e) { + return UnpairResult.localOnly(describeNetworkFailure(e)); + } finally { + if (conn != null) conn.disconnect(); + } + } + + private static String describeNetworkFailure(Exception e) { + if (e instanceof SocketTimeoutException) { + return "endpoint timed out"; + } + if (e instanceof ConnectException || e instanceof UnknownHostException) { + return "endpoint unreachable"; + } + String message = e.getMessage(); + if (message != null && message.trim().length() > 0) { + return e.getClass().getSimpleName() + ": " + message; + } + return e.getClass().getSimpleName(); + } + + private PairResult pollPairingStatus(String baseUrl, String requestId, String privateKey) { + for (int i = 0; i < 60; i++) { + HttpsURLConnection conn = null; + try { + Thread.sleep(2000); + URL url = new URL(baseUrl + "/companion/pair/" + requestId); + conn = CompanionService.openTrustedConnection(url); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + conn.setRequestMethod("GET"); + int status = conn.getResponseCode(); + String response = readAll(status >= 400 ? conn.getErrorStream() : conn.getInputStream()); + if (status == 200 && "paired".equals(extractJsonString(response, "status"))) { + return parsePairingResponse(response, privateKey); + } + if (status == 200 && "rejected".equals(extractJsonString(response, "status"))) { + return PairResult.error("rejected on JetKVM"); + } + } catch (Exception e) { + return PairResult.error(describeNetworkFailure(e)); + } finally { + if (conn != null) conn.disconnect(); + } + } + return PairResult.error("approval timed out"); + } + + private void refreshPairingReachability() { + final CompanionService.CompanionPairing[] pairings = CompanionService.getSavedPairings(prefs); + if (pairings.length == 0) { + pairingReachability.clear(); + refreshPairingControls(); + return; + } + + for (CompanionService.CompanionPairing pairing : pairings) { + final String url = normalizeJetKvmUrl(pairing.url); + pairingReachability.put(url, null); + new Thread(new Runnable() { + @Override + public void run() { + final boolean reachable = isJetKvmEndpointReachable(url); + runOnUiThread(new Runnable() { + @Override + public void run() { + pairingReachability.put(url, reachable); + refreshPairingControls(); + } + }); + } + }, "JetKVM-reachability").start(); + } + refreshPairingControls(); + } + + private static boolean isJetKvmEndpointReachable(String baseUrl) { + HttpsURLConnection conn = null; + try { + URL url = new URL(normalizeJetKvmUrl(baseUrl) + "/companion/pair/requests"); + conn = CompanionService.openTrustedConnection(url); + conn.setConnectTimeout(2000); + conn.setReadTimeout(2000); + conn.setRequestMethod("GET"); + conn.getResponseCode(); + return true; + } catch (Exception e) { + return false; + } finally { + if (conn != null) conn.disconnect(); + } + } + + private void applyReachabilityStateText(TextView state, Boolean reachable) { + if (reachable == null) { + state.setTextColor(Color.rgb(148, 163, 184)); + state.setText("Checking"); + } else if (reachable) { + state.setTextColor(Color.rgb(34, 197, 94)); + state.setText("Paired"); + } else { + state.setTextColor(Color.rgb(239, 68, 68)); + state.setText("Unreachable"); + } + } + + private void showPairingOtp(final String otp) { + if (pairingOtpTimer != null) { + pairingOtpTimer.cancel(); + pairingOtpTimer = null; + } + if (pairingOtpPanel == null || pairingOtpLabel == null || pairingOtpCode == null || pairingOtpCountdown == null) { + return; + } + pairingOtpLabel.setText("Pairing code"); + pairingOtpCode.setText(otp); + pairingOtpCode.setVisibility(View.VISIBLE); + if (jetkvmPairingCodeInput != null) { + jetkvmPairingCodeInput.setVisibility(View.GONE); + } + if (jetkvmPairingCodeButton != null) { + jetkvmPairingCodeButton.setVisibility(View.GONE); + } + pairingOtpPanel.setVisibility(View.VISIBLE); + updatePairingOtpCountdown(PAIRING_OTP_TTL_MS); + pairingOtpTimer = new CountDownTimer(PAIRING_OTP_TTL_MS, 1000) { + @Override + public void onTick(long millisUntilFinished) { + updatePairingOtpCountdown(millisUntilFinished); + } + + @Override + public void onFinish() { + hidePairingOtp(); + updateStatus("Pairing code expired."); + } + }; + pairingOtpTimer.start(); + } + + private void showJetKvmPairingCodeEntry(String baseUrl) { + showJetKvmPairingCodeEntry(baseUrl, PAIRING_OTP_TTL_MS); + } + + private void showJetKvmPairingCodeEntry(String baseUrl, long remainingMs) { + if (pairingOtpTimer != null) { + pairingOtpTimer.cancel(); + pairingOtpTimer = null; + } + if (pairingOtpPanel == null || pairingOtpLabel == null || pairingOtpCode == null || pairingOtpCountdown == null) { + return; + } + pairingOtpLabel.setText("Code from JetKVM web UI"); + pairingOtpCode.setText(""); + pairingOtpCode.setVisibility(View.GONE); + updateJetKvmPairingEntryCountdown(baseUrl, remainingMs); + if (jetkvmPairingCodeInput != null) { + jetkvmPairingCodeInput.setText(""); + jetkvmPairingCodeInput.setVisibility(View.VISIBLE); + jetkvmPairingCodeInput.requestFocus(); + } + if (jetkvmPairingCodeButton != null) { + jetkvmPairingCodeButton.setVisibility(View.VISIBLE); + } + pairingOtpPanel.setVisibility(View.VISIBLE); + pairingOtpTimer = new CountDownTimer(Math.max(1, remainingMs), 1000) { + @Override + public void onTick(long millisUntilFinished) { + updateJetKvmPairingEntryCountdown(baseUrl, millisUntilFinished); + } + + @Override + public void onFinish() { + pendingPairUrl = ""; + prefs.edit() + .remove(CompanionService.KEY_PENDING_PAIR_URL) + .remove(CompanionService.KEY_PENDING_PAIR_CREATED_AT) + .apply(); + hidePairingOtp(); + updateStatus("Pairing request expired."); + } + }; + pairingOtpTimer.start(); + } + + private void updateJetKvmPairingEntryCountdown(String baseUrl, long millisRemaining) { + if (pairingOtpCountdown == null) { + return; + } + long secondsRemaining = Math.max(0, (millisRemaining + 999) / 1000); + pairingOtpCountdown.setText( + "Pairing request from " + normalizeJetKvmUrl(baseUrl) + "\nExpires in " + secondsRemaining + " seconds" + ); + } + + private void updatePairingOtpCountdown(long millisRemaining) { + if (pairingOtpCountdown == null) { + return; + } + long secondsRemaining = Math.max(0, (millisRemaining + 999) / 1000); + pairingOtpCountdown.setText("Expires in " + secondsRemaining + " seconds"); + } + + private void hidePairingOtp() { + if (pairingOtpTimer != null) { + pairingOtpTimer.cancel(); + pairingOtpTimer = null; + } + if (pairingOtpPanel != null) { + pairingOtpPanel.setVisibility(View.GONE); + } + if (pairingOtpCode != null) { + pairingOtpCode.setText(""); + } + if (jetkvmPairingCodeInput != null) { + jetkvmPairingCodeInput.setText(""); + jetkvmPairingCodeInput.setVisibility(View.GONE); + } + if (jetkvmPairingCodeButton != null) { + jetkvmPairingCodeButton.setVisibility(View.GONE); + } + } + + private PairResult parsePairingResponse(String response, String privateKey) { + String companionId = extractJsonString(response, "companion_id"); + String identityToken = extractJsonString(response, "jetkvm_identity_token"); + if (companionId.length() == 0 || privateKey.length() == 0 || identityToken.length() == 0) { + return PairResult.error("missing companion id, key, or identity"); + } + return PairResult.success(companionId, privateKey, identityToken); + } + + private static String generatePairingOtp() { + return String.format("%06d", PAIRING_RANDOM.nextInt(1000000)); + } + + private static String normalizeJetKvmUrl(String rawUrl) { + String url = rawUrl == null ? "" : rawUrl.trim(); + if (url.length() == 0) return CompanionService.DEFAULT_JETKVM_URL; + if (!url.contains("://")) { + url = "https://" + url; + } + if (!url.toLowerCase(java.util.Locale.US).startsWith("https://")) { + return ""; + } + while (url.endsWith("/") && url.length() > "https://".length()) { + url = url.substring(0, url.length() - 1); + } + return url; + } + + private static String readAll(InputStream input) throws java.io.IOException { + if (input == null) return ""; + BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + reader.close(); + return builder.toString(); + } + + private static String extractJsonString(String json, String key) { + if (json == null || key == null) return ""; + String needle = "\"" + key + "\""; + int keyIndex = json.indexOf(needle); + if (keyIndex < 0) return ""; + int colonIndex = json.indexOf(':', keyIndex + needle.length()); + if (colonIndex < 0) return ""; + int startQuote = json.indexOf('"', colonIndex + 1); + if (startQuote < 0) return ""; + int endQuote = json.indexOf('"', startQuote + 1); + if (endQuote < 0) return ""; + return json.substring(startQuote + 1, endQuote); + } + + private static boolean extractJsonBoolean(String json, String key) { + if (json == null || key == null) return false; + String needle = "\"" + key + "\""; + int keyIndex = json.indexOf(needle); + if (keyIndex < 0) return false; + int colonIndex = json.indexOf(':', keyIndex + needle.length()); + if (colonIndex < 0) return false; + int valueIndex = colonIndex + 1; + while (valueIndex < json.length() && Character.isWhitespace(json.charAt(valueIndex))) { + valueIndex++; + } + return json.startsWith("true", valueIndex); + } + + private static PairingKeys generatePairingKeys() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1"), new SecureRandom()); + KeyPair keyPair = generator.generateKeyPair(); + String publicKey = android.util.Base64.encodeToString(keyPair.getPublic().getEncoded(), android.util.Base64.NO_WRAP); + String privateKey = android.util.Base64.encodeToString(keyPair.getPrivate().getEncoded(), android.util.Base64.NO_WRAP); + return new PairingKeys(publicKey, privateKey); + } + + private static final class PairingKeys { + final String publicKey; + final String privateKey; + + PairingKeys(String publicKey, String privateKey) { + this.publicKey = publicKey; + this.privateKey = privateKey; + } + } + + private static final class PairResult { + final boolean success; + final String companionId; + final String privateKey; + final String identityToken; + final String message; + + private PairResult(boolean success, String companionId, String privateKey, String identityToken, String message) { + this.success = success; + this.companionId = companionId; + this.privateKey = privateKey; + this.identityToken = identityToken; + this.message = message; + } + + static PairResult success(String companionId, String privateKey, String identityToken) { + return new PairResult(true, companionId, privateKey, identityToken, ""); + } + + static PairResult error(String message) { + return new PairResult(false, "", "", "", message); + } + } + + private static final class UnpairResult { + final boolean backendUpdated; + final String message; + + private UnpairResult(boolean backendUpdated, String message) { + this.backendUpdated = backendUpdated; + this.message = message; + } + + static UnpairResult backendUpdated() { + return new UnpairResult(true, ""); + } + + static UnpairResult localOnly(String message) { + return new UnpairResult(false, message); + } + } + + private void requestMissingPermissionsIfNeeded() { + if (!requestedNotificationThisLaunch && !hasNotificationPermission()) { + requestedNotificationThisLaunch = true; + requestNotificationPermission(); + return; + } + if (!requestedOverlayThisLaunch && !Settings.canDrawOverlays(this)) { + requestedOverlayThisLaunch = true; + requestOverlayPermission(); + return; + } + if (!requestedBatteryThisLaunch && !isIgnoringBatteryOptimizations()) { + requestedBatteryThisLaunch = true; + requestBatteryOptimizationExemption(); + } + } + + private boolean hasNotificationPermission() { + return Build.VERSION.SDK_INT < 33 + || checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED; + } + + private void requestNotificationPermission() { + if (Build.VERSION.SDK_INT < 33) { + updateArmStatus(); + return; + } + if (hasNotificationPermission()) { + updateArmStatus(); + return; + } + requestPermissions(new String[] { android.Manifest.permission.POST_NOTIFICATIONS }, REQUEST_POST_NOTIFICATIONS); + } + + private void requestOverlayPermission() { + Intent intent = new Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + getPackageName()) + ); + startActivity(intent); + } + + private void requestBatteryOptimizationExemption() { + Intent detailIntent = new Intent("android.settings.VIEW_ADVANCED_POWER_USAGE_DETAIL"); + detailIntent.setData(Uri.parse("package:" + getPackageName())); + detailIntent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName()); + if (startSettingsActivity(detailIntent)) { + updateStatus("Open Battery usage and set JetKVM Companion to Unrestricted."); + return; + } + + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(Uri.parse("package:" + getPackageName())); + if (startSettingsActivity(intent)) { + updateStatus("Allow JetKVM Companion to run unrestricted in the background."); + return; + } + + openAppSettings(); + updateStatus("Open Battery, then select Unrestricted for JetKVM Companion."); + } + + private boolean startSettingsActivity(Intent intent) { + try { + startActivity(intent); + return true; + } catch (ActivityNotFoundException e) { + return false; + } catch (SecurityException e) { + return false; + } + } + + private void openAppSettings() { + Intent intent = new Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:" + getPackageName()) + ); + startActivity(intent); + } + + private SharedPreferences getCompanionPreferences() { + return CompanionService.getCompanionPreferences(this); + } + + private boolean isIgnoringBatteryOptimizations() { + PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); + return powerManager == null || powerManager.isIgnoringBatteryOptimizations(getPackageName()); + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == REQUEST_POST_NOTIFICATIONS) { + updateArmStatus(); + requestMissingPermissionsIfNeeded(); + } + } +} diff --git a/jsonrpc.go b/jsonrpc.go index 7a60656e1..56a60c506 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -56,6 +56,21 @@ type AudioConfig struct { Enabled bool `json:"enabled"` } +type TargetTypeSettings struct { + TargetType string `json:"target_type"` + PreferredMouseMode string `json:"preferred_mouse_mode,omitempty"` + DisplayWidth int `json:"display_width,omitempty"` + DisplayHeight int `json:"display_height,omitempty"` + DisplayAspect float64 `json:"display_aspect,omitempty"` + Evidence []string `json:"evidence,omitempty"` + Source string `json:"source,omitempty"` + LastSeenUnixMilli int64 `json:"last_seen_unix_milli,omitempty"` + HDMIReconnectRequired bool `json:"hdmi_reconnect_required,omitempty"` + FallbackDisplayMode *DisplayMode `json:"fallback_display_mode,omitempty"` + CompanionNotice string `json:"companion_notice,omitempty"` + Fresh bool `json:"fresh"` +} + func writeJSONRPCResponse(response JSONRPCResponse, session *Session) { responseBytes, err := json.Marshal(response) if err != nil { @@ -239,24 +254,35 @@ func rpcGetEDID() (string, error) { return resp, nil } +func rpcGetDisplayModeStatus() (DisplayModeStatus, error) { + return getDisplayModeStatus(), nil +} + func rpcSetEDID(edid string) error { if isInternalDisabledEDID(edid) { return fmt.Errorf("invalid EDID") } + edidToApply := edid if edid == "" { logger.Info().Msg("Restoring EDID to default") + edidToApply = getDeviceDefaultEDID() } else { logger.Info().Str("edid", edid).Msg("Setting EDID") } previousEDID := config.EdidString - config.EdidString = edid + config.EdidString = edidToApply if err := reapplyHostDisplayAdvertisement("set_edid"); err != nil { config.EdidString = previousEDID return err } + dynamicDisplayModeState.Lock() + dynamicDisplayModeState.mode = nil + dynamicDisplayModeState.edid = "" + dynamicDisplayModeState.hdmiReconnectRequired = false + dynamicDisplayModeState.Unlock() // Save EDID to config, allowing it to be restored on reboot. if err := SaveConfig(); err != nil { @@ -401,6 +427,37 @@ func rpcSetAudioConfig(params AudioConfig) error { return nil } +func rpcGetTargetType() (*TargetTypeSettings, error) { + metadata := withDisplayReconnectStatus(getEffectiveTargetMetadata()) + return &TargetTypeSettings{ + TargetType: metadata.TargetType, + PreferredMouseMode: metadata.PreferredMouseMode, + DisplayWidth: metadata.DisplayWidth, + DisplayHeight: metadata.DisplayHeight, + DisplayAspect: metadata.DisplayAspect, + Evidence: metadata.Evidence, + Source: metadata.Source, + LastSeenUnixMilli: metadata.LastSeenUnixMilli, + HDMIReconnectRequired: metadata.HDMIReconnectRequired, + FallbackDisplayMode: metadata.FallbackDisplayMode, + CompanionNotice: metadata.CompanionNotice, + Fresh: metadata.Fresh, + }, nil +} + +func rpcSetTargetType(settings TargetTypeSettings) error { + switch settings.TargetType { + case "", "generic": + config.TargetType = "generic" + case "android": + config.TargetType = "android" + default: + return fmt.Errorf("invalid target type: %s", settings.TargetType) + } + + return SaveConfig() +} + const ( devModeFile = "/userdata/jetkvm/devmode.enable" sshKeyDir = "/userdata/dropbear/.ssh" @@ -1012,6 +1069,8 @@ func rpcSetUsbDeviceState(device string, enabled bool) error { config.UsbDevices.RelativeMouse = enabled case "keyboard": config.UsbDevices.Keyboard = enabled + case "touchscreen": + config.UsbDevices.Touchscreen = enabled case "massStorage": config.UsbDevices.MassStorage = enabled case "serialConsole": @@ -1344,6 +1403,7 @@ var rpcHandlers = map[string]RPCHandler{ "keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, + "touchscreenReport": {Func: rpcTouchscreenReport, Params: []string{"x", "y", "touching"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY", "wheelX"}}, "wakeHost": {Func: rpcWakeHost}, "getVideoState": {Func: rpcGetVideoState}, @@ -1366,6 +1426,7 @@ var rpcHandlers = map[string]RPCHandler{ "setEDID": {Func: rpcSetEDID, Params: []string{"edid"}}, "getHostDisplayIdleMode": {Func: rpcGetHostDisplayIdleMode}, "setHostDisplayIdleMode": {Func: rpcSetHostDisplayIdleMode, Params: []string{"enabled"}}, + "getDisplayModeStatus": {Func: rpcGetDisplayModeStatus}, "getVideoLogStatus": {Func: rpcGetVideoLogStatus}, "getVideoSleepMode": {Func: rpcGetVideoSleepMode}, "setVideoSleepMode": {Func: rpcSetVideoSleepMode, Params: []string{"duration"}}, @@ -1407,6 +1468,8 @@ var rpcHandlers = map[string]RPCHandler{ "getBacklightSettings": {Func: rpcGetBacklightSettings}, "setAudioConfig": {Func: rpcSetAudioConfig, Params: []string{"params"}}, "getAudioConfig": {Func: rpcGetAudioConfig}, + "getTargetType": {Func: rpcGetTargetType}, + "setTargetType": {Func: rpcSetTargetType, Params: []string{"settings"}}, "getDCPowerState": {Func: rpcGetDCPowerState}, "setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}}, "setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}}, diff --git a/native.go b/native.go index 529ab4a57..6889f87c1 100644 --- a/native.go +++ b/native.go @@ -35,6 +35,7 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) { OnNativeRestart: func() { configureDisplayOnNativeRestart() _ = reapplyHostDisplayAdvertisement("native_restarted") + go applyDisplayModeForTarget(getEffectiveTargetMetadata()) }, OnVideoStateChange: func(state native.VideoState) { lastVideoState = state @@ -99,7 +100,10 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) { nativeLogger.Fatal().Err(err).Msg("failed to start native proxy") } go func() { - _ = reapplyHostDisplayAdvertisement("native_started") + if !restoreDefaultEDIDIfAndroidModeIsUnleased("android display mode unleased at startup", true) { + _ = reapplyHostDisplayAdvertisement("native_started") + } + applyDisplayModeForTarget(getEffectiveTargetMetadata()) }() if os.Getenv("JETKVM_CRASH_TESTING") == "1" { diff --git a/target_metadata.go b/target_metadata.go new file mode 100644 index 000000000..1d0e9735d --- /dev/null +++ b/target_metadata.go @@ -0,0 +1,170 @@ +package kvm + +import ( + "sync" + "time" +) + +const companionTargetTTL = 2 * time.Minute + +type TargetMetadata struct { + TargetType string `json:"target_type"` + PreferredMouseMode string `json:"preferred_mouse_mode,omitempty"` + DisplayWidth int `json:"display_width,omitempty"` + DisplayHeight int `json:"display_height,omitempty"` + DisplayAspect float64 `json:"display_aspect,omitempty"` + Evidence []string `json:"evidence,omitempty"` + Source string `json:"source,omitempty"` + LastSeenUnixMilli int64 `json:"last_seen_unix_milli,omitempty"` + LeaseExpiresUnixMilli int64 `json:"lease_expires_unix_milli,omitempty"` + HDMIReconnectRequired bool `json:"hdmi_reconnect_required,omitempty"` + FallbackDisplayMode *DisplayMode `json:"fallback_display_mode,omitempty"` + CompanionNotice string `json:"companion_notice,omitempty"` + Fresh bool `json:"fresh"` +} + +type CompanionTargetDeclaration struct { + State string `json:"state"` + JetKVMUSBIdentity string `json:"jetkvm_usb_identity"` + TargetType string `json:"target_type"` + PreferredMouseMode string `json:"preferred_mouse_mode"` + DisplayWidth int `json:"display_width"` + DisplayHeight int `json:"display_height"` + DisplayAspect float64 `json:"display_aspect"` + Evidence []string `json:"evidence"` + LeaseMs int64 `json:"lease_ms"` + NotificationPermissionGranted bool `json:"notification_permission_granted"` + DisplayOverAppsPermissionGranted bool `json:"display_over_apps_permission_granted"` + BatteryUnrestrictedGranted bool `json:"battery_unrestricted_granted"` + PairedJetKVMURLs []string `json:"paired_jetkvm_urls"` + VisibleIPs []string `json:"visible_ips"` +} + +var ( + targetMetadataLock sync.Mutex + companionTarget TargetMetadata +) + +func setCompanionTargetMetadata(declaration CompanionTargetDeclaration) TargetMetadata { + now := time.Now() + if declaration.State == "disconnected" { + targetMetadataLock.Lock() + defer targetMetadataLock.Unlock() + + companionTarget = TargetMetadata{ + TargetType: "android", + Source: "companion", + LastSeenUnixMilli: now.UnixMilli(), + Fresh: false, + } + return companionTarget + } + + aspect := declaration.DisplayAspect + if aspect <= 0 && declaration.DisplayWidth > 0 && declaration.DisplayHeight > 0 { + aspect = float64(declaration.DisplayWidth) / float64(declaration.DisplayHeight) + } + lease := time.Duration(declaration.LeaseMs) * time.Millisecond + if lease <= 0 { + lease = companionTargetTTL + } + if lease > companionTargetTTL { + lease = companionTargetTTL + } + + metadata := TargetMetadata{ + TargetType: declaration.TargetType, + PreferredMouseMode: declaration.PreferredMouseMode, + DisplayWidth: declaration.DisplayWidth, + DisplayHeight: declaration.DisplayHeight, + DisplayAspect: aspect, + Evidence: append([]string(nil), declaration.Evidence...), + Source: "companion", + LastSeenUnixMilli: now.UnixMilli(), + LeaseExpiresUnixMilli: now.Add(lease).UnixMilli(), + Fresh: true, + } + + targetMetadataLock.Lock() + companionTarget = metadata + targetMetadataLock.Unlock() + + scheduleCompanionTargetExpiryCheck(metadata.LeaseExpiresUnixMilli) + return metadata +} + +func getEffectiveTargetMetadata() TargetMetadata { + targetMetadataLock.Lock() + companion := companionTarget + targetMetadataLock.Unlock() + + if companion.TargetType != "" && companion.LeaseExpiresUnixMilli > time.Now().UnixMilli() { + companion.Fresh = true + return companion + } + + targetType := config.TargetType + if targetType == "" { + targetType = "generic" + } + return TargetMetadata{ + TargetType: targetType, + Source: "config", + Fresh: true, + } +} + +func hasFreshCompanionLease() bool { + targetMetadataLock.Lock() + companion := companionTarget + targetMetadataLock.Unlock() + + return companion.TargetType == "android" && + companion.Source == "companion" && + companion.LeaseExpiresUnixMilli > time.Now().UnixMilli() +} + +func clearCompanionTargetMetadata() { + targetMetadataLock.Lock() + defer targetMetadataLock.Unlock() + companionTarget = TargetMetadata{} +} + +func scheduleCompanionTargetExpiryCheck(leaseExpiresUnixMilli int64) { + if leaseExpiresUnixMilli <= 0 { + return + } + + delay := time.Until(time.UnixMilli(leaseExpiresUnixMilli)) + if delay < 0 { + delay = 0 + } + + go func() { + time.Sleep(delay + time.Second) + + targetMetadataLock.Lock() + expired := companionTarget.LeaseExpiresUnixMilli == leaseExpiresUnixMilli && + leaseExpiresUnixMilli <= time.Now().UnixMilli() + targetMetadataLock.Unlock() + + if expired { + logger.Warn(). + Int64("lease_expires_unix_milli", leaseExpiresUnixMilli). + Msg("companion target lease expired") + expireCompanionTargetMetadata(leaseExpiresUnixMilli) + applyDefaultEDIDFallback("companion target lease expired", true) + } + }() +} + +func expireCompanionTargetMetadata(leaseExpiresUnixMilli int64) { + targetMetadataLock.Lock() + defer targetMetadataLock.Unlock() + + if companionTarget.LeaseExpiresUnixMilli != leaseExpiresUnixMilli { + return + } + companionTarget.Fresh = false + companionTarget.LeaseExpiresUnixMilli = 0 +} diff --git a/ui/localization/messages/cy.json b/ui/localization/messages/cy.json index 45e61d69e..2e77c68e7 100644 --- a/ui/localization/messages/cy.json +++ b/ui/localization/messages/cy.json @@ -639,6 +639,8 @@ "mouse_jiggler_title": "Jiggler", "mouse_mode_absolute": "Absoliwt", "mouse_mode_absolute_description": "Mwyaf cyfleus", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "Perthynol", "mouse_mode_relative_description": "Mwyaf cydnaws", "mouse_modes_description": "Dewiswch y modd mewnbwn llygoden", diff --git a/ui/localization/messages/da.json b/ui/localization/messages/da.json index 79db56fe4..4627a3dd2 100644 --- a/ui/localization/messages/da.json +++ b/ui/localization/messages/da.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "Jiggler", "mouse_mode_absolute": "Absolut", "mouse_mode_absolute_description": "Mest bekvemme", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "Relativ", "mouse_mode_relative_description": "Mest kompatible", "mouse_modes_description": "Vælg musens inputtilstand", diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json index 99fb1a10b..8c26d9279 100644 --- a/ui/localization/messages/de.json +++ b/ui/localization/messages/de.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "Jiggler", "mouse_mode_absolute": "Absolute", "mouse_mode_absolute_description": "Am bequemsten", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "Relativ", "mouse_mode_relative_description": "Am kompatibelsten", "mouse_modes_description": "Wählen Sie den Mauseingabemodus", diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index bec0bcdd3..8ffd6ae00 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "Jiggler", "mouse_mode_absolute": "Absolute", "mouse_mode_absolute_description": "Most convenient", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "Relative", "mouse_mode_relative_description": "Most compatible", "mouse_modes_description": "Choose the mouse input mode", diff --git a/ui/localization/messages/es.json b/ui/localization/messages/es.json index f5339120d..b246146fc 100644 --- a/ui/localization/messages/es.json +++ b/ui/localization/messages/es.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "Jiggler", "mouse_mode_absolute": "Absoluto", "mouse_mode_absolute_description": "Más preciso", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "Relativo", "mouse_mode_relative_description": "Más compatible con sistemas antiguos", "mouse_modes_description": "Elija el modo de entrada del mouse", diff --git a/ui/localization/messages/fr.json b/ui/localization/messages/fr.json index eeafb3d60..da89f9620 100644 --- a/ui/localization/messages/fr.json +++ b/ui/localization/messages/fr.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "Jiggler", "mouse_mode_absolute": "Absolu", "mouse_mode_absolute_description": "Le plus pratique", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "Relatif", "mouse_mode_relative_description": "Le plus compatible", "mouse_modes_description": "Choisissez le mode de saisie de la souris", diff --git a/ui/localization/messages/it.json b/ui/localization/messages/it.json index f9ffa5405..cdf5aaddb 100644 --- a/ui/localization/messages/it.json +++ b/ui/localization/messages/it.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "Jiggler", "mouse_mode_absolute": "Assoluto", "mouse_mode_absolute_description": "Il più conveniente", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "Relativo", "mouse_mode_relative_description": "Più compatibile", "mouse_modes_description": "Scegli la modalità di input del mouse", diff --git a/ui/localization/messages/ja.json b/ui/localization/messages/ja.json index ca305d04a..d7db67a55 100644 --- a/ui/localization/messages/ja.json +++ b/ui/localization/messages/ja.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "マウスジグラー", "mouse_mode_absolute": "絶対座標", "mouse_mode_absolute_description": "最も便利", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "相対座標", "mouse_mode_relative_description": "最も互換性が高い", "mouse_modes_description": "マウス入力モードを選択してください", diff --git a/ui/localization/messages/nb.json b/ui/localization/messages/nb.json index 205a44309..28bd54038 100644 --- a/ui/localization/messages/nb.json +++ b/ui/localization/messages/nb.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "Jiggler", "mouse_mode_absolute": "Absolutt", "mouse_mode_absolute_description": "Mest praktisk", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "Relativ", "mouse_mode_relative_description": "Mest kompatibel", "mouse_modes_description": "Velg museinndatamodus", diff --git a/ui/localization/messages/pt.json b/ui/localization/messages/pt.json index 9f1874a54..15b09405e 100644 --- a/ui/localization/messages/pt.json +++ b/ui/localization/messages/pt.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "Jiggler", "mouse_mode_absolute": "Absoluto", "mouse_mode_absolute_description": "Mais conveniente", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "Relativo", "mouse_mode_relative_description": "Mais compatível", "mouse_modes_description": "Escolha o modo de entrada do mouse", diff --git a/ui/localization/messages/ru.json b/ui/localization/messages/ru.json index c1571cc8b..fac12d1e6 100644 --- a/ui/localization/messages/ru.json +++ b/ui/localization/messages/ru.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "Jiggler", "mouse_mode_absolute": "Абсолютный", "mouse_mode_absolute_description": "Наиболее удобный", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "Относительный", "mouse_mode_relative_description": "Наиболее совместимый", "mouse_modes_description": "Выберите режим ввода мыши", diff --git a/ui/localization/messages/sv.json b/ui/localization/messages/sv.json index e9c311a48..e442308fc 100644 --- a/ui/localization/messages/sv.json +++ b/ui/localization/messages/sv.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "Jiggler", "mouse_mode_absolute": "Absolut", "mouse_mode_absolute_description": "Mest bekvämt", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "Relativ", "mouse_mode_relative_description": "Mest kompatibelt", "mouse_modes_description": "Välj musinmatningsläge", diff --git a/ui/localization/messages/zh-tw.json b/ui/localization/messages/zh-tw.json index 85cd7ae24..102e569ae 100644 --- a/ui/localization/messages/zh-tw.json +++ b/ui/localization/messages/zh-tw.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "防休眠", "mouse_mode_absolute": "絕對", "mouse_mode_absolute_description": "最方便", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "相對", "mouse_mode_relative_description": "最相容", "mouse_modes_description": "選擇滑鼠輸入模式", diff --git a/ui/localization/messages/zh.json b/ui/localization/messages/zh.json index 867d88802..82706f373 100644 --- a/ui/localization/messages/zh.json +++ b/ui/localization/messages/zh.json @@ -644,6 +644,8 @@ "mouse_jiggler_title": "鼠标防休眠", "mouse_mode_absolute": "绝对模式", "mouse_mode_absolute_description": "最方便,推荐使用", + "mouse_mode_digitizer": "Digitizer", + "mouse_mode_digitizer_description": "Android friendly", "mouse_mode_relative": "相对模式", "mouse_mode_relative_description": "兼容性最好", "mouse_modes_description": "选择鼠标的输入模式。", diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 7b5e2fdc6..a38095d1d 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useEffect, useRef } from "react"; +import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { MdOutlineContentPasteGo } from "react-icons/md"; import { LuCable, @@ -6,6 +6,7 @@ import { LuHardDrive, LuMaximize, LuScanText, + LuMonitorUp, LuSettings, LuSignal, LuTerminal, @@ -33,8 +34,14 @@ import WakeOnLanModal from "@components/popovers/WakeOnLan/Index"; import MountPopopover from "@components/popovers/MountPopover"; import ExtensionPopover from "@components/popovers/ExtensionPopover"; import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; +import useKeyboard from "@hooks/useKeyboard"; import { m } from "@localizations/messages.js"; +type UsbDevices = { + serial_console?: boolean; + touchscreen?: boolean; +}; + export default function Actionbar({ requestFullscreen, }: { @@ -57,15 +64,22 @@ export default function Actionbar({ const { width: videoWidth, height: videoHeight } = useVideoStore(); const { developerMode } = useSettingsStore(); const { send } = useJsonRpc(); + const { executeMacro } = useKeyboard(); + const [androidTargetControlsEnabled, setAndroidTargetControlsEnabled] = useState(false); useEffect(() => { send("getUsbDevices", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; - const devices = resp.result as { serial_console?: boolean }; + const devices = resp.result as UsbDevices; setUsbSerialConsoleEnabled(devices.serial_console === true); + setAndroidTargetControlsEnabled(devices.touchscreen === true); }); }, [send, setUsbSerialConsoleEnabled]); + const toggleAndroidDisplay = useCallback(() => { + void executeMacro([{ keys: ["Power"], modifiers: null, delay: 80 }]); + }, [executeMacro]); + // This is the only way to get a reliable state change for the popover // at time of writing this there is no mount, or unmount event for the popover const isOpen = useRef(false); @@ -321,6 +335,17 @@ export default function Actionbar({ }} />
+ {androidTargetControlsEnabled && ( +
+
+ )} {!isEmbedMode && (
+ ); +} + +export default function AndroidCompactControls() { + const { navigateTo } = useDeviceUiNavigation(); + const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); + const { remoteVirtualMediaState } = useMountMediaStore(); + const { executeMacro } = useKeyboard(); + const { selectedKeyboard } = useKeyboardLayout(); + const setUser = useUserStore(state => state.setUser); + const { + isOcrMode, + setDisableVideoFocusTrap, + setOcrMode, + setTerminalType, + terminalType, + toggleSidebarView, + } = useUiStore(); + const { developerMode } = useSettingsStore(); + + const [position, setPosition] = useState(() => getStoredPosition()); + const [open, setOpen] = useState(false); + const [requestCenterOpen, setRequestCenterOpen] = useState(false); + const [panel, setPanel] = useState("root"); + const buttonRef = useRef(null); + const panelRef = useRef(null); + const dragRef = useRef<{ + moved: boolean; + offsetX: number; + offsetY: number; + pointerId: number; + startX: number; + startY: number; + } | null>(null); + + const persistPosition = useCallback((next: Position) => { + const clamped = clampPosition(next); + setPosition(clamped); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(clamped)); + }, []); + + const closePanel = useCallback(() => { + setOpen(false); + setPanel("root"); + window.setTimeout(() => setDisableVideoFocusTrap(false), 0); + }, [setDisableVideoFocusTrap]); + + const openRootPanel = useCallback(() => { + setPanel("root"); + setOpen(true); + setDisableVideoFocusTrap(true); + }, [setDisableVideoFocusTrap]); + + const openChildPanel = useCallback( + (nextPanel: Exclude) => { + setPanel(nextPanel); + setOpen(true); + setDisableVideoFocusTrap(true); + }, + [setDisableVideoFocusTrap], + ); + + const executeTextInput = useCallback( + async (text: string) => { + const macroSteps: MacroStep[] = []; + + for (const char of text) { + const normalizedChar = char.normalize("NFC"); + const keyprops = selectedKeyboard.chars[normalizedChar]; + if (!keyprops?.key) continue; + + if (keyprops.accentKey) { + const accentModifiers: string[] = []; + if (keyprops.accentKey.shift) accentModifiers.push("ShiftLeft"); + if (keyprops.accentKey.altRight) accentModifiers.push("AltRight"); + + macroSteps.push({ + keys: [String(keyprops.accentKey.key)], + modifiers: accentModifiers.length > 0 ? accentModifiers : null, + delay: 20, + }); + } + + const modifiers: string[] = []; + if (keyprops.shift) modifiers.push("ShiftLeft"); + if (keyprops.altRight) modifiers.push("AltRight"); + + macroSteps.push({ + keys: [String(keyprops.key)], + modifiers: modifiers.length > 0 ? modifiers : null, + delay: 20, + }); + + if (keyprops.deadKey) { + macroSteps.push({ keys: ["Space"], modifiers: null, delay: 20 }); + } + } + + if (macroSteps.length > 0) await executeMacro(macroSteps); + }, + [executeMacro, selectedKeyboard], + ); + + useEffect(() => { + const onAndroidImeText = (event: Event) => { + const customEvent = event as CustomEvent<{ text?: string }>; + const text = customEvent.detail?.text; + if (!text) return; + + void executeTextInput(text); + }; + + window.addEventListener("jetkvm-android-ime-text", onAndroidImeText); + return () => window.removeEventListener("jetkvm-android-ime-text", onAndroidImeText); + }, [executeTextInput]); + + useEffect(() => { + const onResize = () => persistPosition(position); + window.addEventListener("orientationchange", onResize); + window.addEventListener("resize", onResize); + return () => { + window.removeEventListener("orientationchange", onResize); + window.removeEventListener("resize", onResize); + }; + }, [persistPosition, position]); + + useEffect(() => { + if (!open) return; + + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (buttonRef.current?.contains(target)) return; + if (panelRef.current?.contains(target)) return; + + event.preventDefault(); + event.stopPropagation(); + closePanel(); + }; + + document.addEventListener("pointerdown", onPointerDown, true); + return () => document.removeEventListener("pointerdown", onPointerDown, true); + }, [closePanel, open]); + + const panelStyle = useMemo(() => { + if (typeof window === "undefined") + return { left: position.x, top: position.y, width: PANEL_WIDTH }; + + const left = clamp( + position.x + BUTTON_SIZE - PANEL_WIDTH, + EDGE_PADDING, + window.innerWidth - PANEL_WIDTH - EDGE_PADDING, + ); + const top = clamp( + position.y + BUTTON_SIZE + 8, + EDGE_PADDING, + window.innerHeight - PANEL_MAX_HEIGHT_MARGIN - EDGE_PADDING, + ); + + return { left, top, width: PANEL_WIDTH }; + }, [position]); + + const startDrag = (event: ReactPointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { + moved: false, + offsetX: event.clientX - position.x, + offsetY: event.clientY - position.y, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + }; + }; + + const moveDrag = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + const moved = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 4; + drag.moved = drag.moved || moved; + persistPosition({ + x: event.clientX - drag.offsetX, + y: event.clientY - drag.offsetY, + }); + }; + + const finishDrag = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + dragRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + if (drag.moved) return; + + if (open) { + closePanel(); + } else { + openRootPanel(); + } + }; + + const toggleDisplay = useCallback(() => { + void executeMacro([{ keys: ["Power"], modifiers: null, delay: 80 }]); + closePanel(); + }, [closePanel, executeMacro]); + + const logout = useCallback(async () => { + const res = await api.POST(`${DEVICE_API}/auth/logout`); + if (!res.ok) return; + + setUser(null); + window.location.assign("/"); + }, [setUser]); + + return ( + <> + + + {open && ( +
event.stopPropagation()} + > +
+ {panel === "root" ? ( + Controls + ) : ( + + )} + +
+ + {panel === "root" ? ( +
+ {developerMode && ( + setTerminalType(terminalType === "kvm" ? "none" : "kvm")} + /> + )} + openChildPanel("paste")} + /> + { + setOcrMode(!isOcrMode); + closePanel(); + }} + /> + + openChildPanel("media")} + /> + openChildPanel("wol")} + /> + openChildPanel("extension")} + /> + { + if (window.JetKVMAndroid?.showInputMethod) { + window.JetKVMAndroid.showInputMethod(); + closePanel(); + return; + } + + setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled); + }} + /> + { + toggleSidebarView("connection-stats"); + closePanel(); + }} + /> + { + closePanel(); + navigateTo("/settings"); + }} + /> + { + closePanel(); + setRequestCenterOpen(true); + }} + /> + void logout()} /> +
+ ) : panel === "paste" ? ( + + ) : panel === "media" ? ( + + ) : panel === "wol" ? ( + + ) : ( + + )} +
+ )} + setRequestCenterOpen(false)} + /> + + ); +} diff --git a/ui/src/components/CompanionRequestCenter.tsx b/ui/src/components/CompanionRequestCenter.tsx new file mode 100644 index 000000000..cacbbbb06 --- /dev/null +++ b/ui/src/components/CompanionRequestCenter.tsx @@ -0,0 +1,785 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { LuBell, LuChevronDown, LuChevronRight, LuRefreshCw, LuX } from "react-icons/lu"; + +import api from "@/api"; +import { DEVICE_API } from "@/ui.config"; +import notifications from "@/notifications"; +import { cx } from "@/cva.config"; +import Card from "@components/Card"; +import { useUiStore } from "@hooks/stores"; + +type CompanionPairRequest = { + request_id: string; + remote_addr: string; + direction?: "companion" | "jetkvm"; + status?: string; + otp?: string; + error?: string; + user_agent?: string; + created_at?: number; +}; + +type CompanionStatus = { + companion_id: string; + remote_addr?: string; + remote_hostname?: string; + has_report?: boolean; + last_seen_unix_milli?: number; + notification_permission_granted?: boolean; + display_over_apps_permission_granted?: boolean; + battery_unrestricted_granted?: boolean; + paired_jetkvm_urls?: string[]; + visible_ips?: string[]; + visible_ip_entries?: VisibleIP[]; + jetkvm_usb_identity?: string; + target_type?: string; + preferred_mouse_mode?: string; + display_width?: number; + display_height?: number; + evidence?: string[]; + peripherals?: Record; + pending_actions?: string[]; +}; + +type VisibleIP = { + ip: string; + hostname?: string; + source?: string; + interface?: string; +}; + +type PairRequestsResponse = { + requests?: CompanionPairRequest[]; +}; + +type CompanionStatusResponse = { + companions?: CompanionStatus[]; + visible_ips?: VisibleIP[]; +}; + +const permissionLabels: Record = { + notifications: "Notifications", + overlay: "Display over apps", + battery: "Unrestricted battery", +}; + +const permissionDescriptors = [ + { + key: "notifications", + granted: (companion: CompanionStatus) => !!companion.notification_permission_granted, + }, + { + key: "overlay", + granted: (companion: CompanionStatus) => !!companion.display_over_apps_permission_granted, + }, + { + key: "battery", + granted: (companion: CompanionStatus) => !!companion.battery_unrestricted_granted, + }, +] as const; + +const PAIRED_SECTION_STORAGE_KEY = "jetkvm.companion.pairedCollapsed"; +const VISIBLE_IPS_SECTION_STORAGE_KEY = "jetkvm.companion.visibleIpsCollapsed"; +const PAIRING_CODE_TTL_MS = 120_000; + +export default function CompanionRequestCenter({ + compact = false, + forceOpen, + hideTrigger = false, + onOpen, + onClose, +}: { + compact?: boolean; + forceOpen?: boolean; + hideTrigger?: boolean; + onOpen?: () => void; + onClose?: () => void; +}) { + const panelRef = useRef(null); + const [open, setOpen] = useState(false); + const [requests, setRequests] = useState([]); + const [companions, setCompanions] = useState([]); + const [visibleIps, setVisibleIps] = useState([]); + const [pairedCollapsed, setPairedCollapsed] = usePersistentCollapsed(PAIRED_SECTION_STORAGE_KEY); + const [visibleIpsCollapsed, setVisibleIpsCollapsed] = usePersistentCollapsed( + VISIBLE_IPS_SECTION_STORAGE_KEY, + ); + const [otpById, setOtpById] = useState>({}); + const [companionUrl, setCompanionUrl] = useState(""); + const [initiatedOtp, setInitiatedOtp] = useState(""); + const [initiatedAt, setInitiatedAt] = useState(0); + const [nowMs, setNowMs] = useState(() => Date.now()); + const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); + + const refresh = useCallback(async () => { + try { + const [requestsResp, statusResp] = await Promise.all([ + api.GET(`${DEVICE_API}/companion/pair/requests`), + api.GET(`${DEVICE_API}/companion/status`), + ]); + if (requestsResp.ok) { + const body = (await requestsResp.json()) as PairRequestsResponse; + setRequests(body.requests || []); + } + if (statusResp.ok) { + const body = (await statusResp.json()) as CompanionStatusResponse; + setCompanions(body.companions || []); + setVisibleIps(body.visible_ips || []); + } + } catch { + // Transient failures are expected while the backend is rebooting. + } + }, []); + + useEffect(() => { + void refresh(); + const id = window.setInterval(() => void refresh(), 3000); + return () => window.clearInterval(id); + }, [refresh]); + + useEffect(() => { + const id = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + + useEffect(() => { + if (!(forceOpen ?? open)) return; + const onPointerDown = (event: PointerEvent) => { + if (!panelRef.current?.contains(event.target as Node)) { + onClose?.(); + setOpen(false); + } + }; + window.addEventListener("pointerdown", onPointerDown); + return () => window.removeEventListener("pointerdown", onPointerDown); + }, [forceOpen, onClose, open]); + + const approve = useCallback( + async (request: CompanionPairRequest) => { + const otp = (otpById[request.request_id] || "").trim(); + if (!/^\d{6}$/.test(otp)) { + notifications.error("Enter the 6 digit companion code."); + return; + } + const resp = await api.POST(`${DEVICE_API}/companion/pair/${request.request_id}/approve`, { + otp, + }); + if (!resp.ok) { + notifications.error("Companion pairing failed."); + return; + } + notifications.success("Companion paired."); + setOtpById(current => { + const next = { ...current }; + delete next[request.request_id]; + return next; + }); + void refresh(); + }, + [otpById, refresh], + ); + + const reject = useCallback( + async (request: CompanionPairRequest) => { + await api.POST(`${DEVICE_API}/companion/pair/${request.request_id}/reject`, {}); + notifications.success("Companion pairing rejected."); + if (request.direction === "jetkvm") { + setInitiatedOtp(""); + setInitiatedAt(0); + } + void refresh(); + }, + [refresh], + ); + + const initiate = useCallback( + async (rawUrl?: string) => { + const url = (rawUrl ?? companionUrl).trim(); + if (!url) { + notifications.error("Enter the companion address."); + return; + } + const resp = await api.POST(`${DEVICE_API}/companion/pair/initiate`, { + companion_url: url, + }); + if (!resp.ok) { + let message = "Failed to start companion pairing."; + try { + const body = (await resp.json()) as { error?: string }; + if (body.error) message = body.error; + } catch { + // Keep the generic error. + } + notifications.error(message); + return; + } + const body = (await resp.json()) as { otp?: string }; + setInitiatedOtp(body.otp || ""); + setInitiatedAt(Date.now()); + notifications.success("Pairing code generated."); + void refresh(); + }, + [companionUrl, refresh], + ); + + const unpair = useCallback(async () => { + if (!window.confirm("Unpair all Android companions from this JetKVM?")) { + return; + } + const resp = await api.POST(`${DEVICE_API}/companion/unpair-admin`, {}); + if (!resp.ok) { + notifications.error("Failed to unpair companion."); + return; + } + notifications.success("Companion unpaired."); + setInitiatedOtp(""); + setInitiatedAt(0); + void refresh(); + }, [refresh]); + + const unpairCompanion = useCallback( + async (companionID: string) => { + const resp = await api.POST(`${DEVICE_API}/companion/${companionID}/unpair-admin`, {}); + if (!resp.ok) { + notifications.error("Failed to unpair companion."); + return; + } + notifications.success("Companion unpaired."); + void refresh(); + }, + [refresh], + ); + + const requestPermission = useCallback( + async (companionID: string, permission: string) => { + const resp = await api.POST(`${DEVICE_API}/companion/${companionID}/request-permission`, { + permission, + }); + if (!resp.ok) { + notifications.error("Failed to queue permission request."); + return; + } + notifications.success(`${permissionLabels[permission]} request queued.`); + void refresh(); + }, + [refresh], + ); + + const pairedHosts = useMemo(() => { + const hosts = new Set(); + for (const companion of companions) { + const remoteHost = companion.remote_addr?.split(":")[0]; + if (remoteHost) hosts.add(remoteHost); + for (const url of companion.paired_jetkvm_urls || []) { + try { + hosts.add(new URL(url).hostname); + } catch { + // Ignore malformed companion-reported URLs. + } + } + } + return hosts; + }, [companions]); + + const candidateIps = useMemo(() => { + const seen = new Set(); + const candidates: VisibleIP[] = []; + for (const entry of visibleIps) { + if (entry.source === "backend") continue; + if (!entry.ip || seen.has(entry.ip) || pairedHosts.has(entry.ip)) continue; + seen.add(entry.ip); + candidates.push(entry); + } + return candidates.sort((a, b) => candidateIPSortKey(a).localeCompare(candidateIPSortKey(b))); + }, [pairedHosts, visibleIps]); + + const companionRequests = useMemo( + () => requests.filter(request => request.direction !== "jetkvm"), + [requests], + ); + const jetkvmRequests = useMemo( + () => requests.filter(request => request.direction === "jetkvm"), + [requests], + ); + const activeJetkvmRequests = useMemo( + () => jetkvmRequests.filter(request => pairRequestRemainingMs(request, nowMs) > 0), + [jetkvmRequests, nowMs], + ); + const initiatedRemainingMs = initiatedAt + ? Math.max(0, initiatedAt + PAIRING_CODE_TTL_MS - nowMs) + : 0; + const showInitiatedFallback = + activeJetkvmRequests.length === 0 && !!initiatedOtp && initiatedRemainingMs > 0; + const count = companionRequests.length; + const isOpen = forceOpen ?? open; + + useEffect(() => { + if (!isOpen) return; + setDisableVideoFocusTrap(true); + return () => setDisableVideoFocusTrap(false); + }, [isOpen, setDisableVideoFocusTrap]); + + const openPanel = useCallback(() => { + onOpen?.(); + setOpen(true); + }, [onOpen]); + const closePanel = useCallback(() => { + onClose?.(); + setOpen(false); + }, [onClose]); + + return ( +
+ {!hideTrigger && ( + + )} + + {isOpen && ( +
+ +
+
+ Android companion +
+
+ + +
+
+ +
setPairedCollapsed(!pairedCollapsed)} + > + {companions.length === 0 ? ( + No paired companions. + ) : ( +
+ {companions.map(companion => ( +
+
+
+
+ {companion.remote_hostname || + companion.remote_addr || + companion.companion_id} +
+ {companion.remote_hostname && companion.remote_addr && ( +
+ {companion.remote_addr} +
+ )} + {!companion.has_report && ( +
+ Waiting for signed status report +
+ )} +
+ +
+
+ {companion.has_report ? ( + <> + + + + + ) : ( + + )} + + + companion.peripherals?.[key]) + .join(", ") || "None" + } + /> + +
+ {companion.has_report && ( +
+ {permissionDescriptors + .filter(permission => !permission.granted(companion)) + .map(permission => ( + + ))} +
+ )} +
+ ))} +
+ )} +
+ +
setVisibleIpsCollapsed(!visibleIpsCollapsed)} + > +
+ +
+ {candidateIps.length === 0 ? ( + No unpaired companion IPs visible. + ) : ( +
+ {candidateIps.map(entry => ( +
+
+
+ {entry.hostname || entry.ip} +
+ {entry.hostname && ( +
+ {entry.ip} +
+ )} +
+ {[sourceLabel(entry.source), entry.interface] + .filter(Boolean) + .join(" / ") || "visible"} +
+
+ +
+ ))} +
+ )} +
+ +
+ {activeJetkvmRequests.length === 0 && !showInitiatedFallback ? ( + No code generated. + ) : ( +
+ {activeJetkvmRequests.map(request => ( +
+
+ Enter this code on the Android companion. +
+
+ {request.otp || initiatedOtp} +
+
+ Open the companion app at {request.remote_addr}, enter this code, then tap + Pair. +
+
+ Expires in {formatSeconds(pairRequestRemainingMs(request, nowMs))}. +
+
+ +
+
+ ))} + {showInitiatedFallback && ( +
+
+ Enter this code on the Android companion. +
+
+ {initiatedOtp} +
+
+ Expires in {formatSeconds(initiatedRemainingMs)}. +
+
+ +
+
+ )} +
+ )} +
+ +
+ {companionRequests.length === 0 ? ( + No companion-generated requests. + ) : ( +
+ {companionRequests.map(request => ( +
+
+ Companion from {request.remote_addr} +
+ + setOtpById(current => ({ + ...current, + [request.request_id]: event.target.value.replace(/\D/g, "").slice(0, 6), + })) + } + /> +
+ + +
+
+ ))} +
+ )} +
+ +
+ setCompanionUrl(event.target.value)} + /> +
+ + +
+
+
+
+ )} +
+ ); +} + +function Section({ + title, + children, + collapsed, + onToggle, +}: { + title: string; + children: ReactNode; + collapsed?: boolean; + onToggle?: () => void; +}) { + const collapsible = collapsed !== undefined && !!onToggle; + return ( +
+ {collapsible ? ( + + ) : ( +
{title}
+ )} + {!collapsed && children} +
+ ); +} + +function Muted({ children }: { children: ReactNode }) { + return
{children}
; +} + +function StatusRow({ label, ok }: { label: string; ok: boolean }) { + return ( +
+ {label} + + {ok ? "Granted" : "Missing"} + +
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function candidateIPSortKey(entry: VisibleIP) { + return `${entry.hostname || ""} ${entry.ip || ""} ${entry.source || ""} ${entry.interface || ""}`.toLowerCase(); +} + +function sourceLabel(source?: string) { + if (source === "backend") return "JetKVM"; + if (source === "paired_device") return "Visible from paired device"; + return source || ""; +} + +function pairRequestRemainingMs(request: CompanionPairRequest, nowMs: number) { + if (!request.created_at) return PAIRING_CODE_TTL_MS; + return Math.max(0, request.created_at + PAIRING_CODE_TTL_MS - nowMs); +} + +function formatSeconds(ms: number) { + const seconds = Math.max(0, Math.ceil(ms / 1000)); + return `${seconds} second${seconds === 1 ? "" : "s"}`; +} + +function usePersistentCollapsed(key: string) { + const [collapsed, setCollapsedState] = useState(() => { + if (typeof window === "undefined") return true; + return window.localStorage.getItem(key) !== "expanded"; + }); + + const setCollapsed = useCallback( + (next: boolean) => { + setCollapsedState(next); + if (typeof window !== "undefined") { + window.localStorage.setItem(key, next ? "collapsed" : "expanded"); + } + }, + [key], + ); + + return [collapsed, setCollapsed] as const; +} diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index 7eaf18cdf..36f96b53c 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -12,6 +12,7 @@ import Container from "@components/Container"; import { LinkButton } from "@components/Button"; import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard"; import USBStateStatus from "@components/USBStateStatus"; +import CompanionRequestCenter from "@components/CompanionRequestCenter"; import { CLOUD_API, DEVICE_API } from "@/ui.config"; import api from "@/api"; import { isOnDevice } from "@/main"; @@ -100,6 +101,7 @@ export default function DashboardNavbar({ )} {isLoggedIn ? ( <> + {isOnDevice && }
diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index 12e45a9d9..f23a99d1a 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -133,7 +133,7 @@ export default function InfoBar() {
) : null} - {debugMode && mouseMode == "absolute" ? ( + {debugMode && (mouseMode == "absolute" || mouseMode == "digitizer") ? (
{m.info_pointer()} diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 36a4be61f..515a96d41 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -5,10 +5,13 @@ import { cx } from "@/cva.config"; import { isWindows } from "@/utils"; import useKeyboard from "@hooks/useKeyboard"; import useMouse from "@hooks/useMouse"; +import { useJsonRpc } from "@hooks/useJsonRpc"; +import type { JsonRpcResponse } from "@hooks/useJsonRpc"; import { useRTCStore, useSettingsStore, useUiStore, useVideoStore } from "@hooks/stores"; -import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; import VirtualKeyboard from "@components/VirtualKeyboard"; import Actionbar from "@components/ActionBar"; +import AndroidCompactControls from "@components/AndroidCompactControls"; + import MacroBar from "@components/MacroBar"; import InfoBar from "@components/InfoBar"; import { @@ -27,9 +30,11 @@ const initialHdmiErrorGraceMs = 2500; export default function WebRTCVideo({ hasConnectionIssues, hideStatusBar, + compactControllerMode, }: { hasConnectionIssues: boolean; hideStatusBar?: boolean; + compactControllerMode?: boolean; }) { // Video and stream related refs and states const videoElm = useRef(null); @@ -41,6 +46,12 @@ export default function WebRTCVideo({ const [audioEnabled, setAudioEnabled] = useState(false); const [isPointerLockActive, setIsPointerLockActive] = useState(false); const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false); + const [targetType, setTargetType] = useState<"generic" | "android">("generic"); + const [targetDisplayAspect, setTargetDisplayAspect] = useState(null); + const [hdmiReconnectRequired, setHdmiReconnectRequired] = useState(false); + const androidPreferredMouseModeApplied = useRef(false); + const isAndroidTarget = targetType === "android"; + const androidCropFallbackMode = isAndroidTarget && hdmiReconnectRequired; const { send: sendRpc } = useJsonRpc(); @@ -49,10 +60,26 @@ export default function WebRTCVideo({ // Store hooks const settings = useSettingsStore(); + const setMouseMode = settings.setMouseMode; const { handleKeyPress, resetKeyboardState } = useKeyboard(); + const { send } = useJsonRpc(); + + const sendPicphoneKeyTap = useCallback( + (key: number) => { + void handleKeyPress(key, true); + window.setTimeout(() => void handleKeyPress(key, false), 80); + }, + [handleKeyPress], + ); + + const sendPicphoneAndroidBack = useCallback(() => { + sendPicphoneKeyTap(keys.Escape); + }, [sendPicphoneKeyTap]); + const { getRelMouseMoveHandler, getAbsMouseMoveHandler, + getDigitizerMoveHandler, getMouseWheelHandler, resetMousePosition, } = useMouse(); @@ -83,6 +110,44 @@ export default function WebRTCVideo({ const [isInitialHdmiErrorGraceActive, setIsInitialHdmiErrorGraceActive] = useState(false); const hdmiError = rawHdmiError && !isInitialHdmiErrorGraceActive; + useEffect(() => { + const refreshTargetType = () => { + send("getTargetType", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + const result = resp.result as { + target_type?: string; + preferred_mouse_mode?: string; + display_aspect?: number; + hdmi_reconnect_required?: boolean; + fresh?: boolean; + }; + const isFreshAndroid = result.target_type === "android" && result.fresh !== false; + setTargetType(isFreshAndroid ? "android" : "generic"); + setTargetDisplayAspect( + isFreshAndroid && typeof result.display_aspect === "number" && result.display_aspect > 0 + ? result.display_aspect + : null, + ); + setHdmiReconnectRequired(isFreshAndroid && result.hdmi_reconnect_required === true); + if ( + isFreshAndroid && + result.preferred_mouse_mode === "digitizer" && + !androidPreferredMouseModeApplied.current + ) { + setMouseMode("digitizer"); + androidPreferredMouseModeApplied.current = true; + } else if (!isFreshAndroid) { + androidPreferredMouseModeApplied.current = false; + setHdmiReconnectRequired(false); + } + }); + }; + + refreshTargetType(); + const interval = window.setInterval(refreshTargetType, 5000); + return () => window.clearInterval(interval); + }, [send, setMouseMode]); + // Video-related const handleResize = useCallback( ({ width, height }: { width: number | undefined; height: number | undefined }) => { @@ -249,7 +314,9 @@ export default function WebRTCVideo({ const abortController = new AbortController(); const signal = abortController.signal; - document.addEventListener("pointerlockchange", handlePointerLockChange, { signal }); + document.addEventListener("pointerlockchange", handlePointerLockChange, { + signal, + }); return () => { abortController.abort(); @@ -297,6 +364,17 @@ export default function WebRTCVideo({ const relMouseMoveHandler = useMemo(() => getRelMouseMoveHandler(), [getRelMouseMoveHandler]); + const digitizerMoveHandler = useMemo( + () => + getDigitizerMoveHandler({ + videoClientWidth, + videoClientHeight, + videoWidth, + videoHeight, + }), + [getDigitizerMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight], + ); + const mouseWheelHandler = useMemo(() => getMouseWheelHandler(), [getMouseWheelHandler]); function getAdjustedKeyCode(e: KeyboardEvent) { @@ -325,9 +403,16 @@ export default function WebRTCVideo({ return code; } + function isEditableEventTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + return !!target.closest("input, textarea, select, [contenteditable='true']"); + } + const keyDownHandler = useCallback( (e: KeyboardEvent) => { if (isOcrMode) return; // Let OCR overlay handle keys + if (isEditableEventTarget(e.target)) return; e.preventDefault(); const code = getAdjustedKeyCode(e); const hidKey = keys[code]; @@ -402,6 +487,7 @@ export default function WebRTCVideo({ const keyUpHandler = useCallback( async (e: KeyboardEvent) => { if (isOcrMode) return; // Let OCR overlay handle keys + if (isEditableEventTarget(e.target)) return; e.preventDefault(); const code = getAdjustedKeyCode(e); const hidKey = keys[code]; @@ -537,7 +623,9 @@ export default function WebRTCVideo({ document.addEventListener("keyup", keyUpHandler, { signal }); window.addEventListener("blur", resetKeyboardState, { signal }); - document.addEventListener("visibilitychange", resetKeyboardState, { signal }); + document.addEventListener("visibilitychange", resetKeyboardState, { + signal, + }); return () => { abortController.abort(); @@ -556,7 +644,9 @@ export default function WebRTCVideo({ const signal = abortController.signal; // To prevent the video from being paused when the user presses a space in fullscreen mode - videoElmRefValue.addEventListener("keydown", videoKeyDownHandler, { signal }); + videoElmRefValue.addEventListener("keydown", videoKeyDownHandler, { + signal, + }); videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); // We need to know when the video is playing to update state and video size @@ -576,17 +666,68 @@ export default function WebRTCVideo({ if (!videoElmRefValue) return; const isRelativeMouseMode = settings.mouseMode === "relative"; - const mouseHandler = isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler; + const isDigitizerMouseMode = settings.mouseMode === "digitizer"; + const mouseHandler = isDigitizerMouseMode + ? digitizerMoveHandler + : isRelativeMouseMode + ? relMouseMoveHandler + : absMouseMoveHandler; const abortController = new AbortController(); const signal = abortController.signal; - videoElmRefValue.addEventListener("mousemove", mouseHandler, { signal }); - videoElmRefValue.addEventListener("pointerdown", mouseHandler, { signal }); - videoElmRefValue.addEventListener("pointerup", mouseHandler, { signal }); + if (isDigitizerMouseMode) { + videoElmRefValue.style.touchAction = "none"; + videoElmRefValue.style.userSelect = "none"; + videoElmRefValue.draggable = false; + + const pointerHandler = (e: PointerEvent) => { + e.preventDefault(); + + if (e.type === "pointerdown" && e.button === 2) { + e.stopPropagation(); + sendPicphoneAndroidBack(); + return; + } + + if (e.type === "pointerdown") { + try { + videoElmRefValue.setPointerCapture(e.pointerId); + } catch (err) { + console.debug("Unable to capture pointer", err); + } + } + + mouseHandler(e); + + if (e.type === "pointerup" || e.type === "pointercancel") { + try { + if (videoElmRefValue.hasPointerCapture(e.pointerId)) { + videoElmRefValue.releasePointerCapture(e.pointerId); + } + } catch (err) { + console.debug("Unable to release pointer capture", err); + } + } + }; + + videoElmRefValue.addEventListener("pointerdown", pointerHandler, { signal }); + videoElmRefValue.addEventListener("pointermove", pointerHandler, { signal }); + videoElmRefValue.addEventListener("pointerup", pointerHandler, { signal }); + videoElmRefValue.addEventListener("pointercancel", pointerHandler, { signal }); + } else { + videoElmRefValue.style.touchAction = ""; + videoElmRefValue.style.userSelect = ""; + videoElmRefValue.draggable = true; + videoElmRefValue.addEventListener("mousemove", mouseHandler, { signal }); + videoElmRefValue.addEventListener("pointerdown", mouseHandler, { + signal, + }); + videoElmRefValue.addEventListener("pointerup", mouseHandler, { signal }); + } videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal, - passive: true, + passive: !isDigitizerMouseMode, }); if (isRelativeMouseMode) { @@ -602,11 +743,15 @@ export default function WebRTCVideo({ } else { // Reset the mouse position when the window is blurred or the document is hidden window.addEventListener("blur", resetMousePosition, { signal }); - document.addEventListener("visibilitychange", resetMousePosition, { signal }); + document.addEventListener("visibilitychange", resetMousePosition, { + signal, + }); } const preventContextMenu = (e: MouseEvent) => e.preventDefault(); - videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal }); + videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { + signal, + }); // Suppress browser Back/Forward navigation on X1/X2 mouse buttons so // those presses are forwarded to the remote target instead. @@ -619,6 +764,9 @@ export default function WebRTCVideo({ return () => { abortController.abort(); + videoElmRefValue.style.touchAction = ""; + videoElmRefValue.style.userSelect = ""; + videoElmRefValue.draggable = true; }; }, [ @@ -627,9 +775,11 @@ export default function WebRTCVideo({ requestPointerLock, absMouseMoveHandler, relMouseMoveHandler, + digitizerMoveHandler, mouseWheelHandler, resetMousePosition, settings.mouseMode, + sendPicphoneAndroidBack, ], ); @@ -679,15 +829,25 @@ export default function WebRTCVideo({ }, [videoSaturation, videoBrightness, videoContrast]); return ( -
-
-
-
- - -
+
+ {!compactControllerMode && ( +
+
+
+ + +
+
-
+ )}
@@ -705,10 +865,23 @@ export default function WebRTCVideo({
{/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */} -
+
- {!hideStatusBar && ( + {compactControllerMode && } + {!hideStatusBar && !compactControllerMode && (
diff --git a/ui/src/hooks/hidRpc.ts b/ui/src/hooks/hidRpc.ts index a6b08b90f..848c43090 100644 --- a/ui/src/hooks/hidRpc.ts +++ b/ui/src/hooks/hidRpc.ts @@ -10,6 +10,7 @@ export const HID_RPC_MESSAGE_TYPES = { MouseReport: 0x06, KeyboardMacroReport: 0x07, CancelKeyboardMacroReport: 0x08, + TouchscreenReport: 0x0a, KeyboardLedState: 0x32, KeysDownState: 0x33, KeyboardMacroState: 0x34, @@ -358,6 +359,28 @@ export class PointerReportMessage extends RpcMessage { } } +export class TouchscreenReportMessage extends RpcMessage { + x: number; + y: number; + touching: boolean; + + constructor(x: number, y: number, touching: boolean) { + super(HID_RPC_MESSAGE_TYPES.TouchscreenReport); + this.x = x; + this.y = y; + this.touching = touching; + } + + marshal(): Uint8Array { + return new Uint8Array([ + this.messageType, + ...fromInt32toUint8(this.x), + ...fromInt32toUint8(this.y), + this.touching ? 1 : 0, + ]); + } +} + export class CancelKeyboardMacroReportMessage extends RpcMessage { constructor() { super(HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport); diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index a6290e23f..0d87318d6 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -360,12 +360,14 @@ export interface BacklightSettings { off_after: number; } +export type MouseMode = "absolute" | "relative" | "digitizer"; + export interface SettingsState { isCursorHidden: boolean; setCursorVisibility: (enabled: boolean) => void; - mouseMode: string; - setMouseMode: (mode: string) => void; + mouseMode: MouseMode; + setMouseMode: (mode: MouseMode) => void; debugMode: boolean; setDebugMode: (enabled: boolean) => void; @@ -416,7 +418,7 @@ export const useSettingsStore = create( setCursorVisibility: (enabled: boolean) => set({ isCursorHidden: enabled }), mouseMode: "absolute", - setMouseMode: (mode: string) => set({ mouseMode: mode }), + setMouseMode: (mode: MouseMode) => set({ mouseMode: mode }), debugMode: import.meta.env.DEV, setDebugMode: (enabled: boolean) => set({ debugMode: enabled }), diff --git a/ui/src/hooks/useHidRpc.ts b/ui/src/hooks/useHidRpc.ts index ae3055c5e..3cd456743 100644 --- a/ui/src/hooks/useHidRpc.ts +++ b/ui/src/hooks/useHidRpc.ts @@ -15,6 +15,7 @@ import { MouseReportMessage, PointerReportMessage, RpcMessage, + TouchscreenReportMessage, unmarshalHidRpcMessage, } from "./hidRpc"; @@ -248,6 +249,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { ); const lastAbsButtons = useRef(0); + const lastTouching = useRef(false); const reportAbsMouseEvent = useCallback( (x: number, y: number, buttons: number) => { @@ -273,6 +275,18 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { [sendMessage], ); + const reportTouchscreenEvent = useCallback( + (x: number, y: number, touching: boolean) => { + const touchingChanged = touching !== lastTouching.current; + lastTouching.current = touching; + + sendMessage(new TouchscreenReportMessage(x, y, touching), { + useUnreliableChannel: !touchingChanged, + }); + }, + [sendMessage], + ); + const reportKeyboardMacroEvent = useCallback( (steps: KeyboardMacroStep[]) => { sendMessage(new KeyboardMacroReportMessage(false, steps.length, steps)); @@ -331,6 +345,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { reportKeypressEvent, reportAbsMouseEvent, reportRelMouseEvent, + reportTouchscreenEvent, reportKeyboardMacroEvent, cancelOngoingKeyboardMacro, reportKeypressKeepAlive, diff --git a/ui/src/hooks/useMouse.ts b/ui/src/hooks/useMouse.ts index e69d2a3d8..ba4c3f90e 100644 --- a/ui/src/hooks/useMouse.ts +++ b/ui/src/hooks/useMouse.ts @@ -13,6 +13,53 @@ export interface AbsMouseMoveHandlerProps { videoHeight: number; } +function getVideoRelativePosition( + e: MouseEvent, + videoClientWidth: number, + videoClientHeight: number, + videoWidth: number, + videoHeight: number, +) { + const target = e.currentTarget; + if (!(target instanceof HTMLElement)) return null; + + const rect = target.getBoundingClientRect(); + const elementWidth = rect.width || videoClientWidth; + const elementHeight = rect.height || videoClientHeight; + if (!elementWidth || !elementHeight) return null; + + const streamWidth = videoWidth || elementWidth; + const streamHeight = videoHeight || elementHeight; + if (!streamWidth || !streamHeight) return null; + + const elementAspectRatio = elementWidth / elementHeight; + const streamAspectRatio = streamWidth / streamHeight; + + let effectiveWidth = elementWidth; + let effectiveHeight = elementHeight; + let offsetX = 0; + let offsetY = 0; + + if (elementAspectRatio > streamAspectRatio) { + effectiveWidth = elementHeight * streamAspectRatio; + offsetX = (elementWidth - effectiveWidth) / 2; + } else if (elementAspectRatio < streamAspectRatio) { + effectiveHeight = elementWidth / streamAspectRatio; + offsetY = (elementHeight - effectiveHeight) / 2; + } + + const pointerX = e.clientX - rect.left; + const pointerY = e.clientY - rect.top; + + const clampedX = Math.min(Math.max(offsetX, pointerX), offsetX + effectiveWidth); + const clampedY = Math.min(Math.max(offsetY, pointerY), offsetY + effectiveHeight); + + return { + x: (clampedX - offsetX) / effectiveWidth, + y: (clampedY - offsetY) / effectiveHeight, + }; +} + export default function useMouse() { // states const { setMousePosition, setMouseMove } = useMouseStore(); @@ -25,7 +72,8 @@ export default function useMouse() { // RPC hooks const { send } = useJsonRpc(); - const { reportAbsMouseEvent, reportRelMouseEvent, rpcHidReady } = useHidRpc(); + const { reportAbsMouseEvent, reportRelMouseEvent, reportTouchscreenEvent, rpcHidReady } = + useHidRpc(); // Mouse-related const sendRelMouseMovement = useCallback( @@ -83,37 +131,18 @@ export default function useMouse() { if (!videoClientWidth || !videoClientHeight) return; if (mouseMode !== "absolute") return; - // Get the aspect ratios of the video element and the video stream - const videoElementAspectRatio = videoClientWidth / videoClientHeight; - const videoStreamAspectRatio = videoWidth / videoHeight; - - // Calculate the effective video display area - let effectiveWidth = videoClientWidth; - let effectiveHeight = videoClientHeight; - let offsetX = 0; - let offsetY = 0; - - if (videoElementAspectRatio > videoStreamAspectRatio) { - // Pillarboxing: black bars on the left and right - effectiveWidth = videoClientHeight * videoStreamAspectRatio; - offsetX = (videoClientWidth - effectiveWidth) / 2; - } else if (videoElementAspectRatio < videoStreamAspectRatio) { - // Letterboxing: black bars on the top and bottom - effectiveHeight = videoClientWidth / videoStreamAspectRatio; - offsetY = (videoClientHeight - effectiveHeight) / 2; - } - - // Clamp mouse position within the effective video boundaries - const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth); - const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight); - - // Map clamped mouse position to the video stream's coordinate system - const relativeX = (clampedX - offsetX) / effectiveWidth; - const relativeY = (clampedY - offsetY) / effectiveHeight; + const position = getVideoRelativePosition( + e, + videoClientWidth, + videoClientHeight, + videoWidth, + videoHeight, + ); + if (!position) return; // Convert to HID absolute coordinate system (0-32767 range) - const x = Math.round(relativeX * 32767); - const y = Math.round(relativeY * 32767); + const x = Math.round(position.x * 32767); + const y = Math.round(position.y * 32767); // Send mouse movement const { buttons } = e; @@ -122,6 +151,36 @@ export default function useMouse() { [mouseMode, sendAbsMouseMovement], ); + const getDigitizerMoveHandler = useCallback( + ({ videoClientWidth, videoClientHeight, videoWidth, videoHeight }: AbsMouseMoveHandlerProps) => + (e: MouseEvent) => { + if (!videoClientWidth || !videoClientHeight) return; + if (mouseMode !== "digitizer") return; + + const position = getVideoRelativePosition( + e, + videoClientWidth, + videoClientHeight, + videoWidth, + videoHeight, + ); + if (!position) return; + + const x = Math.round(position.x * 32767); + const y = Math.round(position.y * 32767); + const touching = e.buttons !== 0; + + if (rpcHidReady) { + reportTouchscreenEvent(x, y, touching); + } else { + send("touchscreenReport", { x, y, touching }); + } + setMousePosition(x, y); + lastAbsPos.current = { x, y }; + }, + [mouseMode, reportTouchscreenEvent, rpcHidReady, send, setMousePosition], + ); + const getMouseWheelHandler = useCallback( () => (e: WheelEvent) => { if (scrollThrottling && blockWheelEvent) { @@ -154,12 +213,26 @@ export default function useMouse() { ); const resetMousePosition = useCallback(() => { + if (mouseMode === "digitizer") { + if (rpcHidReady) { + reportTouchscreenEvent(lastAbsPos.current.x, lastAbsPos.current.y, false); + } else { + send("touchscreenReport", { + x: lastAbsPos.current.x, + y: lastAbsPos.current.y, + touching: false, + }); + } + return; + } + sendAbsMouseMovement(lastAbsPos.current.x, lastAbsPos.current.y, 0); - }, [sendAbsMouseMovement]); + }, [mouseMode, reportTouchscreenEvent, rpcHidReady, send, sendAbsMouseMovement]); return { getRelMouseMoveHandler, getAbsMouseMoveHandler, + getDigitizerMoveHandler, getMouseWheelHandler, resetMousePosition, }; diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index 44cc3c91b..b86753c73 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -4,8 +4,10 @@ import { CheckCircleIcon } from "@heroicons/react/16/solid"; import { cx } from "@/cva.config"; import MouseIcon from "@assets/mouse-icon.svg"; import PointingFinger from "@assets/pointing-finger.svg"; -import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; +import { useJsonRpc } from "@hooks/useJsonRpc"; import { useSettingsStore } from "@hooks/stores"; +import type { JsonRpcResponse } from "@hooks/useJsonRpc"; +import type { MouseMode } from "@hooks/stores"; import { GridCard } from "@components/Card"; import { Checkbox } from "@components/Checkbox"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; @@ -62,6 +64,36 @@ const jigglerOptions = [ type JigglerValues = (typeof jigglerOptions)[number]["value"] | "custom"; +const mouseModeOptions: { + mode: MouseMode; + title: () => string; + description: () => string; + icon: string; + alt: () => string; +}[] = [ + { + mode: "absolute", + title: m.mouse_mode_absolute, + description: m.mouse_mode_absolute_description, + icon: PointingFinger, + alt: m.mouse_alt_finger, + }, + { + mode: "relative", + title: m.mouse_mode_relative, + description: m.mouse_mode_relative_description, + icon: MouseIcon, + alt: m.mouse_alt_mouse, + }, + { + mode: "digitizer", + title: m.mouse_mode_digitizer, + description: m.mouse_mode_digitizer_description, + icon: PointingFinger, + alt: m.mouse_alt_finger, + }, +]; + export default function SettingsMouseRoute() { const { isCursorHidden, @@ -245,71 +277,51 @@ export default function SettingsMouseRoute() { )}
-
- -
-
- - + + + ); + })}
diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index f14a78525..69a41d875 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -59,6 +59,7 @@ import { doRpcHidHandshake, useHidRpc } from "@hooks/useHidRpc"; import useKeyboard from "@hooks/useKeyboard"; import { registerTestHandlers, cleanupTestHooks } from "@/test/testHooks"; import { isLinuxDesktop } from "@/utils"; +import { isAndroidCompactControllerMode } from "@/utils/androidController"; export type AuthMode = "password" | "noPassword" | null; @@ -164,8 +165,9 @@ export default function KvmIdRoute() { const settingsHideHeaderBar = useSettingsStore(state => state.hideHeaderBar); const settingsHideStatusBar = useSettingsStore(state => state.hideStatusBar); - const hideHeaderBar = isEmbedMode || settingsHideHeaderBar; - const hideStatusBar = isEmbedMode || settingsHideStatusBar; + const androidCompactControllerMode = isAndroidCompactControllerMode(); + const hideHeaderBar = isEmbedMode || settingsHideHeaderBar || androidCompactControllerMode; + const hideStatusBar = isEmbedMode || settingsHideStatusBar || androidCompactControllerMode; const { peerConnection, @@ -1098,6 +1100,7 @@ export default function KvmIdRoute() { )}
{ + if (typeof window === "undefined") return false; + + const params = new URLSearchParams(window.location.search); + return params.get("jetkvmAndroid") === "1" || typeof window.JetKVMAndroid !== "undefined"; +}; + +export const isAndroidCompactControllerMode = () => { + if (typeof window === "undefined") return false; + + return isAndroidControllerApk(); +}; diff --git a/usb.go b/usb.go index 843abcabc..b8360c97b 100644 --- a/usb.go +++ b/usb.go @@ -93,12 +93,16 @@ func rpcRelMouseReport(dx int8, dy int8, buttons uint8) error { return rpcHidReport(func() error { return gadget.RelMouseReport(dx, dy, buttons) }) } +func rpcTouchscreenReport(x int, y int, touching bool) error { + return rpcHidReport(func() error { return gadget.TouchscreenReport(x, y, touching) }) +} + func rpcWheelReport(wheelY int8, wheelX int8) error { return rpcHidReport(func() error { - if gadget.HasAbsoluteMouse() { - return gadget.AbsMouseWheelReport(wheelY, wheelX) + if gadget.HasRelativeMouse() { + return gadget.RelMouseWheelReport(wheelY, wheelX) } - return gadget.RelMouseWheelReport(wheelY, wheelX) + return gadget.AbsMouseWheelReport(wheelY, wheelX) }) } diff --git a/video.go b/video.go index e981979d6..0d82659b7 100644 --- a/video.go +++ b/video.go @@ -29,7 +29,14 @@ func triggerVideoStateUpdate() { mqttManager.publishVideoState() } - nativeLogger.Info().Interface("state", lastVideoState).Msg("video state updated") + nativeLogger.Info(). + Bool("ready", lastVideoState.Ready). + Str("streaming", lastVideoState.Streaming.String()). + Str("error", lastVideoState.Error). + Int("width", lastVideoState.Width). + Int("height", lastVideoState.Height). + Float64("fps", lastVideoState.FramePerSecond). + Msg("actual HDMI input mode updated") } func rpcGetVideoState() (native.VideoState, error) { diff --git a/web.go b/web.go index 1e1bf796f..73091ced4 100644 --- a/web.go +++ b/web.go @@ -4,19 +4,30 @@ import ( "archive/zip" "bytes" "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "crypto/x509" "embed" + "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" "io" "io/fs" + "math/big" "net" "net/http" "net/http/pprof" + neturl "net/url" "os" "path/filepath" "slices" "strings" + stdsync "sync" "time" "github.com/coder/websocket" @@ -97,6 +108,80 @@ const ( authTokenMaxAge = 7 * 24 * 60 * 60 // 1 week ) +const companionPairRequestTTL = 120 * time.Second +const companionSignatureMaxSkew = 60 * time.Second + +type companionPairRequest struct { + ID string + Direction string + RemoteAddr string + UserAgent string + RequestedAt time.Time + Status string + OTP string + CompanionID string + CompanionPubKey string + RejectionReason string +} + +type companionPairRequestBody struct { + OTP string `json:"otp"` + CompanionPublicKey string `json:"companion_public_key"` + ClaimJetKVM bool `json:"claim_jetkvm,omitempty"` +} + +type companionPairApproveBody struct { + OTP string `json:"otp"` + CompanionPublicKey string `json:"companion_public_key"` +} + +type companionPairInitiateBody struct { + CompanionURL string `json:"companion_url"` +} + +type companionPermissionActionBody struct { + Permission string `json:"permission"` +} + +type companionIPEntry struct { + IP string `json:"ip"` + Hostname string `json:"hostname,omitempty"` + Source string `json:"source,omitempty"` + Interface string `json:"interface,omitempty"` +} + +type companionStatusSnapshot struct { + CompanionID string `json:"companion_id"` + RemoteAddr string `json:"remote_addr,omitempty"` + RemoteHostname string `json:"remote_hostname,omitempty"` + HasReport bool `json:"has_report"` + LastSeenUnixMilli int64 `json:"last_seen_unix_milli,omitempty"` + NotificationPermissionGranted bool `json:"notification_permission_granted"` + DisplayOverAppsPermissionGranted bool `json:"display_over_apps_permission_granted"` + BatteryUnrestrictedGranted bool `json:"battery_unrestricted_granted"` + PairedJetKVMURLs []string `json:"paired_jetkvm_urls,omitempty"` + VisibleIPs []string `json:"visible_ips,omitempty"` + VisibleIPEntries []companionIPEntry `json:"visible_ip_entries,omitempty"` + JetKVMUSBIdentity string `json:"jetkvm_usb_identity,omitempty"` + TargetType string `json:"target_type,omitempty"` + PreferredMouseMode string `json:"preferred_mouse_mode,omitempty"` + DisplayWidth int `json:"display_width,omitempty"` + DisplayHeight int `json:"display_height,omitempty"` + Evidence []string `json:"evidence,omitempty"` + Peripherals map[string]bool `json:"peripherals,omitempty"` + PendingActions []string `json:"pending_actions,omitempty"` +} + +var ( + companionPairingLock stdsync.Mutex + companionPairingRequest *companionPairRequest + companionNonceLock stdsync.Mutex + companionNonces = map[string]map[string]time.Time{} + companionStatusLock stdsync.Mutex + companionStatuses = map[string]companionStatusSnapshot{} + companionPendingActions = map[string]map[string]bool{} +) + func setupRouter() *gin.Engine { gin.SetMode(gin.ReleaseMode) gin.DisableConsoleColor() @@ -156,6 +241,19 @@ func setupRouter() *gin.Engine { // Public routes (no authentication required) r.POST("/auth/login-local", handleLogin) + r.POST("/companion/pair", handleCompanionPair) + r.GET("/companion/pair/jetkvm-pending", handleCompanionJetKVMPairPending) + r.GET("/companion/pair/:id", handleCompanionPairStatus) + r.POST("/companion/pair/:id/claim", handleCompanionPairClaim) + r.POST("/companion/target", handleCompanionTargetDeclaration) + r.POST("/companion/unpair", handleCompanionUnpair) + r.GET("/login-local", func(c *gin.Context) { + if shouldUseAndroidNativeLogin(c) { + handleAndroidNativeLogin(c) + return + } + c.FileFromFS("/", http.FS(staticFS)) + }) // We use this to determine if the device is setup r.GET("/device/status", handleDeviceStatus) @@ -219,6 +317,14 @@ func setupRouter() *gin.Engine { protected.POST("/storage/upload", handleUploadHttp) protected.POST("/device/send-wol/:mac-addr", handleSendWOLMagicPacket) + protected.GET("/companion/pair/requests", handleCompanionPairRequests) + protected.POST("/companion/pair/initiate", handleCompanionPairInitiate) + protected.POST("/companion/pair/:id/approve", handleCompanionPairApprove) + protected.POST("/companion/pair/:id/reject", handleCompanionPairReject) + protected.GET("/companion/status", handleCompanionStatus) + protected.POST("/companion/:id/request-permission", handleCompanionPermissionRequest) + protected.POST("/companion/:id/unpair-admin", handleCompanionUnpairOneAdmin) + protected.POST("/companion/unpair-admin", handleCompanionUnpairAdmin) protected.GET("/diagnostics", handleDiagnosticsDownload) } @@ -226,6 +332,10 @@ func setupRouter() *gin.Engine { // Catch-all route for SPA r.NoRoute(func(c *gin.Context) { if c.Request.Method == "GET" && c.NegotiateFormat(gin.MIMEHTML) == gin.MIMEHTML { + if shouldUseAndroidNativeLogin(c) && !hasValidLocalAuthCookie(c) { + handleAndroidNativeLogin(c) + return + } c.FileFromFS("/", http.FS(staticFS)) return } @@ -235,6 +345,1315 @@ func setupRouter() *gin.Engine { return r } +func handleCompanionTargetDeclaration(c *gin.Context) { + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + companionID, ok := companionIDFromSignedRequest(c, bodyBytes) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid companion signature"}) + return + } + + var declaration CompanionTargetDeclaration + if err := json.Unmarshal(bodyBytes, &declaration); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if declaration.TargetType != "android" { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported target_type"}) + return + } + if declaration.State == "" { + declaration.State = "connected" + } + if declaration.State != "connected" && declaration.State != "disconnected" { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported state"}) + return + } + if !isValidJetKVMUSBIdentity(declaration.JetKVMUSBIdentity) { + c.JSON(http.StatusBadRequest, gin.H{"error": "jetkvm_usb_identity does not match this JetKVM"}) + return + } + if declaration.PreferredMouseMode != "" && declaration.PreferredMouseMode != "digitizer" && + declaration.PreferredMouseMode != "absolute" && declaration.PreferredMouseMode != "relative" { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported preferred_mouse_mode"}) + return + } + if declaration.State == "connected" && len(declaration.Evidence) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "connection evidence is required"}) + return + } + if declaration.State == "connected" && (declaration.DisplayWidth <= 0 || declaration.DisplayHeight <= 0) { + c.JSON(http.StatusBadRequest, gin.H{"error": "display dimensions are required"}) + return + } + + metadata := setCompanionTargetMetadata(declaration) + applyDisplayModeForTarget(metadata) + metadata = withDisplayReconnectStatus(metadata) + rememberCompanionStatus(companionID, c.ClientIP(), declaration) + pendingActions := takeCompanionPendingActions(companionID) + logger.Info(). + Str("companion_id", companionID). + Str("state", declaration.State). + Strs("evidence", metadata.Evidence). + Str("preferred_mouse_mode", metadata.PreferredMouseMode). + Int("display_width", metadata.DisplayWidth). + Int("display_height", metadata.DisplayHeight). + Float64("display_aspect", metadata.DisplayAspect). + Bool("hdmi_reconnect_required", metadata.HDMIReconnectRequired). + Msg("companion target declaration received") + response := gin.H{ + "target_type": metadata.TargetType, + "preferred_mouse_mode": metadata.PreferredMouseMode, + "display_width": metadata.DisplayWidth, + "display_height": metadata.DisplayHeight, + "display_aspect": metadata.DisplayAspect, + "evidence": metadata.Evidence, + "source": metadata.Source, + "last_seen_unix_milli": metadata.LastSeenUnixMilli, + "lease_expires_unix_milli": metadata.LeaseExpiresUnixMilli, + "hdmi_reconnect_required": metadata.HDMIReconnectRequired, + "fallback_display_mode": metadata.FallbackDisplayMode, + "companion_notice": metadata.CompanionNotice, + "fresh": metadata.Fresh, + "requested_actions": pendingActions, + } + c.JSON(http.StatusOK, response) +} + +func handleCompanionPair(c *gin.Context) { + var body companionPairRequestBody + if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pairing request"}) + return + } + if !isValidCompanionOTP(body.OTP) { + c.JSON(http.StatusBadRequest, gin.H{"error": "valid 6 digit otp is required"}) + return + } + if !isValidCompanionPublicKey(body.CompanionPublicKey) { + c.JSON(http.StatusBadRequest, gin.H{"error": "valid companion public key is required"}) + return + } + + if body.ClaimJetKVM { + status, response := claimJetKVMInitiatedCompanionPairing(c, body.OTP, body.CompanionPublicKey) + c.JSON(status, response) + return + } + if companionOTPBelongsToDifferentRemote(c, body.OTP, body.CompanionPublicKey) { + c.JSON(http.StatusConflict, gin.H{"error": "otp belongs to an active pairing request from a different companion address"}) + return + } + + request := getOrCreateCompanionPairingRequest(c, body.OTP, body.CompanionPublicKey) + showCompanionPairingPrompt(request) + c.JSON(http.StatusAccepted, companionPairingPendingResponse(request)) +} + +func handleCompanionPairInitiate(c *gin.Context) { + var body companionPairInitiateBody + if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pairing request"}) + return + } + companionURL := normalizeCompanionURL(body.CompanionURL) + if companionURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "valid companion url is required"}) + return + } + + request := createJetKVMInitiatedCompanionPairingRequest(companionURL) + jetkvmURL := "https://" + c.Request.Host + if err := signalCompanionPairRequest(companionURL, jetkvmURL, request.ID); err != nil { + companionPairingLock.Lock() + request.Status = "rejected" + request.RejectionReason = "failed to signal companion: " + err.Error() + companionPairingLock.Unlock() + c.JSON(http.StatusBadGateway, gin.H{"error": request.RejectionReason}) + return + } + logger.Info(). + Str("companion_url", companionURL). + Str("request_id", request.ID). + Msg("JetKVM-initiated companion pairing code generated") + + c.JSON(http.StatusAccepted, gin.H{ + "paired": false, + "status": "pending", + "request_id": request.ID, + "companion_url": companionURL, + "otp": request.OTP, + }) +} + +func handleCompanionPairStatus(c *gin.Context) { + request := getCompanionPairingRequest(c.Param("id")) + if request == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "pairing request not found"}) + return + } + if request.Status == "paired" { + c.JSON(http.StatusOK, companionPairingResponseWithCompanion(request.CompanionID)) + return + } + c.JSON(http.StatusOK, companionPairingPendingResponse(request)) +} + +func handleCompanionPairRequests(c *gin.Context) { + request := getCurrentCompanionPairingRequest() + requests := []gin.H{} + if request != nil && request.Status == "pending" { + requests = append(requests, companionPairingAdminPendingResponse(request)) + } + c.JSON(http.StatusOK, gin.H{"requests": requests}) +} + +func handleCompanionJetKVMPairPending(c *gin.Context) { + request := getCurrentCompanionPairingRequest() + if request == nil || + request.Direction != "jetkvm" || + request.Status != "pending" || + !requestClientMatchesCompanionURL(c, request.RemoteAddr) { + c.JSON(http.StatusOK, gin.H{"pending": false}) + return + } + c.JSON(http.StatusOK, gin.H{ + "pending": true, + "request_id": request.ID, + "remote_addr": request.RemoteAddr, + "created_at": request.RequestedAt.UnixMilli(), + }) +} + +func handleCompanionStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "companions": getCompanionStatusSnapshots(), + "visible_ips": getVisibleLocalIPEntries(), + }) +} + +func handleCompanionPermissionRequest(c *gin.Context) { + companionID := strings.TrimSpace(c.Param("id")) + if companionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "companion id is required"}) + return + } + if _, ok := companionAuthorizations()[companionID]; !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "companion not paired"}) + return + } + + var body companionPermissionActionBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "permission is required"}) + return + } + action := companionPermissionAction(body.Permission) + if action == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported permission"}) + return + } + queueCompanionPendingAction(companionID, action) + c.JSON(http.StatusAccepted, gin.H{"queued": true, "action": action}) +} + +func handleCompanionUnpairOneAdmin(c *gin.Context) { + companionID := strings.TrimSpace(c.Param("id")) + if companionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "companion id is required"}) + return + } + if _, ok := companionAuthorizations()[companionID]; !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "companion not paired"}) + return + } + notifyCompanionAdminUnpair(companionID) + removeCompanionAuthorization(companionID) + forgetCompanionStatus(companionID) + _ = SaveConfig() + c.JSON(http.StatusOK, gin.H{"paired": len(companionAuthorizations()) > 0}) +} + +func handleCompanionPairClaim(c *gin.Context) { + request := getCompanionPairingRequest(c.Param("id")) + if request == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "pairing request not found"}) + return + } + if request.Status != "pending" { + c.JSON(http.StatusConflict, gin.H{"error": "pairing request is not pending"}) + return + } + if request.Direction != "jetkvm" { + c.JSON(http.StatusConflict, gin.H{"error": "pairing request must be approved in JetKVM web UI"}) + return + } + + var body companionPairApproveBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "otp is required"}) + return + } + if body.OTP != request.OTP { + c.JSON(http.StatusUnauthorized, gin.H{"error": "otp does not match"}) + return + } + if !requestClientMatchesCompanionURL(c, request.RemoteAddr) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "pairing request is bound to a different companion address"}) + return + } + if !isValidCompanionPublicKey(body.CompanionPublicKey) { + c.JSON(http.StatusBadRequest, gin.H{"error": "valid companion public key is required"}) + return + } + + companionID := addCompanionAuthorization(body.CompanionPublicKey) + if err := SaveConfig(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save companion pairing"}) + return + } + + companionPairingLock.Lock() + request.Status = "paired" + request.CompanionID = companionID + request.CompanionPubKey = body.CompanionPublicKey + companionPairingLock.Unlock() + c.JSON(http.StatusOK, companionPairingResponseWithCompanion(companionID)) +} + +func handleCompanionPairApprove(c *gin.Context) { + request := getCompanionPairingRequest(c.Param("id")) + if request == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "pairing request not found"}) + return + } + if request.Status != "pending" { + c.JSON(http.StatusConflict, gin.H{"error": "pairing request is not pending"}) + return + } + + var body companionPairApproveBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "otp is required"}) + return + } + if body.OTP != request.OTP { + c.JSON(http.StatusUnauthorized, gin.H{"error": "otp does not match"}) + return + } + if !isValidCompanionPublicKey(request.CompanionPubKey) { + c.JSON(http.StatusBadRequest, gin.H{"error": "valid companion public key is required"}) + return + } + + companionID := addCompanionAuthorization(request.CompanionPubKey) + if err := SaveConfig(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save companion pairing"}) + return + } + + companionPairingLock.Lock() + request.Status = "paired" + request.CompanionID = companionID + companionPairingLock.Unlock() + + logger.Info(). + Str("remote_addr", request.RemoteAddr). + Str("request_id", request.ID). + Msg("companion pairing approved") + c.JSON(http.StatusOK, companionPairingResponseWithCompanion(companionID)) +} + +func handleCompanionPairReject(c *gin.Context) { + request := getCompanionPairingRequest(c.Param("id")) + if request == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "pairing request not found"}) + return + } + + companionPairingLock.Lock() + request.Status = "rejected" + request.RejectionReason = "rejected on JetKVM" + companionPairingLock.Unlock() + + logger.Info(). + Str("remote_addr", request.RemoteAddr). + Str("request_id", request.ID). + Msg("companion pairing rejected") + c.JSON(http.StatusOK, companionPairingPendingResponse(request)) +} + +func handleCompanionUnpair(c *gin.Context) { + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + companionID, ok := companionIDFromSignedRequest(c, bodyBytes) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid companion signature"}) + return + } + removeCompanionAuthorization(companionID) + forgetCompanionStatus(companionID) + _ = SaveConfig() + c.JSON(http.StatusOK, gin.H{"paired": false}) +} + +func handleCompanionUnpairAdmin(c *gin.Context) { + notifyAllCompanionsAdminUnpair() + clearCompanionPairing() + c.JSON(http.StatusOK, gin.H{"paired": false}) +} + +func companionPairingResponse() gin.H { + companions := companionAuthorizations() + companionID := "" + for id := range companions { + companionID = id + break + } + return companionPairingResponseWithCompanion(companionID) +} + +func companionPairingResponseWithCompanion(companionID string) gin.H { + deviceID := GetDeviceID() + shortID := strings.ToLower(deviceID) + if len(shortID) > 8 { + shortID = shortID[:8] + } + + usbProduct := "" + usbSerial := "" + if config.UsbConfig != nil { + usbProduct = config.UsbConfig.Product + usbSerial = config.UsbConfig.SerialNumber + } + + return gin.H{ + "paired": true, + "status": "paired", + "companion_id": companionID, + "jetkvm_device_id": deviceID, + "jetkvm_identity_token": shortID, + "jetkvm_usb_product": usbProduct, + "jetkvm_usb_serial": usbSerial, + } +} + +func companionPairingPendingResponse(request *companionPairRequest) gin.H { + status := request.Status + if status == "" { + status = "pending" + } + response := gin.H{ + "paired": false, + "status": status, + "request_id": request.ID, + "direction": request.Direction, + "remote_addr": request.RemoteAddr, + "user_agent": request.UserAgent, + "created_at": request.RequestedAt.UnixMilli(), + } + if request.RejectionReason != "" { + response["error"] = request.RejectionReason + } + return response +} + +func companionPairingAdminPendingResponse(request *companionPairRequest) gin.H { + response := companionPairingPendingResponse(request) + status, _ := response["status"].(string) + if request.Direction == "jetkvm" && status == "pending" && request.OTP != "" { + response["otp"] = request.OTP + } + return response +} + +func companionOTPBelongsToDifferentRemote(c *gin.Context, otp string, companionPublicKey string) bool { + companionPairingLock.Lock() + defer companionPairingLock.Unlock() + + request := companionPairingRequest + if request == nil || request.Direction != "companion" || request.Status != "pending" || request.OTP != otp { + return false + } + return request.RemoteAddr != c.ClientIP() || request.CompanionPubKey != companionPublicKey +} + +func requestClientMatchesCompanionURL(c *gin.Context, companionURL string) bool { + parsed, err := neturl.Parse(companionURL) + if err != nil || parsed.Hostname() == "" { + return false + } + clientIP := net.ParseIP(c.ClientIP()) + if clientIP == nil { + return false + } + expectedHost := parsed.Hostname() + if expectedIP := net.ParseIP(expectedHost); expectedIP != nil { + return expectedIP.Equal(clientIP) + } + expectedIPs, err := net.LookupIP(expectedHost) + if err != nil { + return false + } + for _, expectedIP := range expectedIPs { + if expectedIP.Equal(clientIP) { + return true + } + } + return false +} + +func claimJetKVMInitiatedCompanionPairing(c *gin.Context, otp string, companionPublicKey string) (int, gin.H) { + request := getCurrentCompanionPairingRequest() + if request == nil || request.Direction != "jetkvm" { + return http.StatusNotFound, gin.H{"error": "JetKVM pairing request not found"} + } + if request.Status != "pending" { + return http.StatusConflict, gin.H{"error": "JetKVM pairing request is not pending"} + } + if request.OTP != otp { + return http.StatusUnauthorized, gin.H{"error": "otp does not match active JetKVM pairing request"} + } + if !requestClientMatchesCompanionURL(c, request.RemoteAddr) { + return http.StatusUnauthorized, gin.H{"error": "pairing request is bound to a different companion address"} + } + + companionID := addCompanionAuthorization(companionPublicKey) + if err := SaveConfig(); err != nil { + return http.StatusInternalServerError, gin.H{"error": "failed to save companion pairing"} + } + + companionPairingLock.Lock() + request.Status = "paired" + request.CompanionID = companionID + request.CompanionPubKey = companionPublicKey + companionPairingLock.Unlock() + + logger.Info(). + Str("remote_addr", request.RemoteAddr). + Str("request_id", request.ID). + Msg("JetKVM-initiated companion pairing claimed") + return http.StatusOK, companionPairingResponseWithCompanion(companionID) +} + +func getOrCreateCompanionPairingRequest(c *gin.Context, otp string, companionPublicKey string) *companionPairRequest { + companionPairingLock.Lock() + defer companionPairingLock.Unlock() + + now := time.Now() + if companionPairingRequest != nil && + companionPairingRequest.Direction == "companion" && + companionPairingRequest.Status == "pending" && + now.Sub(companionPairingRequest.RequestedAt) <= companionPairRequestTTL && + companionPairingRequest.CompanionPubKey == companionPublicKey { + return companionPairingRequest + } + + companionPairingRequest = &companionPairRequest{ + ID: uuid.NewString(), + Direction: "companion", + RemoteAddr: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + RequestedAt: now, + Status: "pending", + OTP: otp, + CompanionPubKey: companionPublicKey, + } + return companionPairingRequest +} + +func createJetKVMInitiatedCompanionPairingRequest(companionURL string) *companionPairRequest { + companionPairingLock.Lock() + defer companionPairingLock.Unlock() + + companionPairingRequest = &companionPairRequest{ + ID: uuid.NewString(), + Direction: "jetkvm", + RemoteAddr: companionURL, + UserAgent: "JetKVM", + RequestedAt: time.Now(), + Status: "pending", + OTP: generateCompanionPairingOTP(), + } + return companionPairingRequest +} + +func generateCompanionPairingOTP() string { + value, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + return fmt.Sprintf("%06d", time.Now().UnixNano()%1000000) + } + return fmt.Sprintf("%06d", value.Int64()) +} + +func getCurrentCompanionPairingRequest() *companionPairRequest { + companionPairingLock.Lock() + defer companionPairingLock.Unlock() + + if companionPairingRequest == nil { + return nil + } + if companionPairingRequest.Status == "pending" && + time.Since(companionPairingRequest.RequestedAt) > companionPairRequestTTL { + companionPairingRequest.Status = "expired" + companionPairingRequest.RejectionReason = "pairing request expired" + } + return companionPairingRequest +} + +func getCompanionPairingRequest(id string) *companionPairRequest { + companionPairingLock.Lock() + defer companionPairingLock.Unlock() + + if companionPairingRequest == nil || companionPairingRequest.ID != id { + return nil + } + if companionPairingRequest.Status == "pending" && + time.Since(companionPairingRequest.RequestedAt) > companionPairRequestTTL { + companionPairingRequest.Status = "expired" + companionPairingRequest.RejectionReason = "pairing request expired" + } + return companionPairingRequest +} + +func showCompanionPairingPrompt(request *companionPairRequest) { + logger.Info(). + Str("remote_addr", request.RemoteAddr). + Str("request_id", request.ID). + Msg("companion pairing pending approval") + if nativeInstance != nil { + nativeInstance.UpdateLabelAndChangeVisibility("cloud_status_label", "Companion pair request") + nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv4_addr", request.RemoteAddr) + } +} + +func clearCompanionPairing() { + config.CompanionPairing.Token = "" + config.CompanionPairing.Tokens = nil + config.CompanionPairing.Companions = nil + restoreDefaultEDIDIfAndroidModeIsUnleased("all companions unpaired", true) + _ = SaveConfig() + clearCompanionStatuses() + companionPairingLock.Lock() + companionPairingRequest = nil + companionPairingLock.Unlock() +} + +func companionPairingTokens() []string { + tokens := make([]string, 0, len(config.CompanionPairing.Tokens)+1) + seen := map[string]bool{} + for _, token := range config.CompanionPairing.Tokens { + token = strings.TrimSpace(token) + if token == "" || seen[token] { + continue + } + tokens = append(tokens, token) + seen[token] = true + } + legacyToken := strings.TrimSpace(config.CompanionPairing.Token) + if legacyToken != "" && !seen[legacyToken] { + tokens = append(tokens, legacyToken) + } + return tokens +} + +func companionAuthorizations() map[string]CompanionAuthorization { + result := map[string]CompanionAuthorization{} + for id, authorization := range config.CompanionPairing.Companions { + id = strings.TrimSpace(id) + publicKey := strings.TrimSpace(authorization.PublicKey) + if id != "" && publicKey != "" { + result[id] = CompanionAuthorization{PublicKey: publicKey} + } + } + return result +} + +func addCompanionAuthorization(publicKey string) string { + publicKey = strings.TrimSpace(publicKey) + if config.CompanionPairing.Companions == nil { + config.CompanionPairing.Companions = map[string]CompanionAuthorization{} + } + for id, authorization := range config.CompanionPairing.Companions { + if authorization.PublicKey == publicKey { + return id + } + } + companionID := uuid.NewString() + config.CompanionPairing.Companions[companionID] = CompanionAuthorization{PublicKey: publicKey} + config.CompanionPairing.Token = "" + config.CompanionPairing.Tokens = nil + return companionID +} + +func removeCompanionAuthorization(companionID string) { + companionID = strings.TrimSpace(companionID) + if companionID == "" || config.CompanionPairing.Companions == nil { + return + } + delete(config.CompanionPairing.Companions, companionID) + if len(config.CompanionPairing.Companions) == 0 { + config.CompanionPairing.Companions = nil + restoreDefaultEDIDIfAndroidModeIsUnleased("last companion unpaired", true) + } +} + +func rememberCompanionStatus(companionID string, remoteAddr string, declaration CompanionTargetDeclaration) { + companionID = strings.TrimSpace(companionID) + if companionID == "" { + return + } + peripherals := map[string]bool{} + for _, evidence := range declaration.Evidence { + switch strings.ToLower(strings.TrimSpace(evidence)) { + case "keyboard", "digitizer", "mouse", "monitor": + peripherals[strings.ToLower(strings.TrimSpace(evidence))] = true + } + } + status := companionStatusSnapshot{ + CompanionID: companionID, + RemoteAddr: remoteAddr, + RemoteHostname: hostnameForAddress(remoteAddr), + HasReport: true, + LastSeenUnixMilli: time.Now().UnixMilli(), + NotificationPermissionGranted: declaration.NotificationPermissionGranted, + DisplayOverAppsPermissionGranted: declaration.DisplayOverAppsPermissionGranted, + BatteryUnrestrictedGranted: declaration.BatteryUnrestrictedGranted, + PairedJetKVMURLs: cleanStringList(declaration.PairedJetKVMURLs), + VisibleIPs: cleanStringList(declaration.VisibleIPs), + VisibleIPEntries: companionVisibleIPEntries(declaration.VisibleIPs), + JetKVMUSBIdentity: strings.TrimSpace(declaration.JetKVMUSBIdentity), + TargetType: strings.TrimSpace(declaration.TargetType), + PreferredMouseMode: strings.TrimSpace(declaration.PreferredMouseMode), + DisplayWidth: declaration.DisplayWidth, + DisplayHeight: declaration.DisplayHeight, + Evidence: cleanStringList(declaration.Evidence), + Peripherals: peripherals, + } + + companionStatusLock.Lock() + companionStatuses[companionID] = status + companionStatusLock.Unlock() +} + +func companionVisibleIPEntries(ips []string) []companionIPEntry { + cleaned := cleanStringList(ips) + entries := make([]companionIPEntry, 0, len(cleaned)) + for _, ip := range cleaned { + entries = append(entries, companionIPEntry{ + IP: ip, + Hostname: resolveHostname(ip), + Source: "paired_device", + }) + } + return entries +} + +func getCompanionStatusSnapshots() []companionStatusSnapshot { + authorizations := companionAuthorizations() + companionStatusLock.Lock() + defer companionStatusLock.Unlock() + + snapshots := make([]companionStatusSnapshot, 0, len(authorizations)) + for companionID := range authorizations { + status := companionStatuses[companionID] + status.CompanionID = companionID + status.HasReport = status.LastSeenUnixMilli > 0 + if status.RemoteHostname == "" && status.RemoteAddr != "" { + status.RemoteHostname = hostnameForAddress(status.RemoteAddr) + } + if status.Peripherals == nil { + status.Peripherals = map[string]bool{} + } + status.PendingActions = pendingCompanionActionsLocked(companionID) + snapshots = append(snapshots, status) + } + slices.SortFunc(snapshots, func(a, b companionStatusSnapshot) int { + aKey := companionSortKey(a) + bKey := companionSortKey(b) + if aKey < bKey { + return -1 + } + if aKey > bKey { + return 1 + } + return 0 + }) + return snapshots +} + +func notifyAllCompanionsAdminUnpair() { + for companionID := range companionAuthorizations() { + notifyCompanionAdminUnpair(companionID) + } +} + +func notifyCompanionAdminUnpair(companionID string) { + status := companionStatusForID(companionID) + targets := companionUnpairNotifyTargets(status) + if len(targets) == 0 { + return + } + + body, err := json.Marshal(map[string]any{ + "jetkvm_urls": companionUnpairNotifyURLs(status), + }) + if err != nil { + logger.Warn().Err(err).Str("companion_id", companionID).Msg("failed to encode companion admin unpair notification") + return + } + for _, target := range targets { + if err := postCompanionUnpairNotification(target, body); err != nil { + logger.Warn(). + Err(err). + Str("companion_id", companionID). + Str("target", target). + Msg("failed to signal companion about admin unpair") + } + } +} + +func companionStatusForID(companionID string) companionStatusSnapshot { + companionStatusLock.Lock() + defer companionStatusLock.Unlock() + return companionStatuses[companionID] +} + +func companionUnpairNotifyTargets(status companionStatusSnapshot) []string { + hosts := []string{} + if host, _, err := net.SplitHostPort(strings.TrimSpace(status.RemoteAddr)); err == nil { + hosts = append(hosts, host) + } else if status.RemoteAddr != "" { + hosts = append(hosts, strings.TrimSpace(status.RemoteAddr)) + } + hosts = append(hosts, status.VisibleIPs...) + for _, entry := range status.VisibleIPEntries { + hosts = append(hosts, entry.IP) + } + + targets := []string{} + seen := map[string]bool{} + for _, host := range hosts { + host = strings.TrimSpace(host) + if host == "" || seen[host] { + continue + } + seen[host] = true + targets = append(targets, "https://"+net.JoinHostPort(host, "8787")+"/pair/unpair") + } + return targets +} + +func companionUnpairNotifyURLs(status companionStatusSnapshot) []string { + urls := []string{"https://jetkvm.local"} + urls = append(urls, status.PairedJetKVMURLs...) + + seen := map[string]bool{} + cleaned := []string{} + for _, rawURL := range urls { + url := strings.TrimSpace(rawURL) + if url == "" || seen[url] { + continue + } + seen[url] = true + cleaned = append(cleaned, url) + } + return cleaned +} + +func postCompanionUnpairNotification(target string, body []byte) error { + client := &http.Client{ + Timeout: 3 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("status %d", resp.StatusCode) + } + return nil +} + +func companionSortKey(status companionStatusSnapshot) string { + for _, value := range []string{ + status.RemoteHostname, + status.RemoteAddr, + status.JetKVMUSBIdentity, + status.CompanionID, + } { + value = strings.ToLower(strings.TrimSpace(value)) + if value != "" { + return value + } + } + return "" +} + +func queueCompanionPendingAction(companionID string, action string) { + companionStatusLock.Lock() + defer companionStatusLock.Unlock() + + if companionPendingActions[companionID] == nil { + companionPendingActions[companionID] = map[string]bool{} + } + companionPendingActions[companionID][action] = true +} + +func takeCompanionPendingActions(companionID string) []string { + companionStatusLock.Lock() + defer companionStatusLock.Unlock() + + actions := pendingCompanionActionsLocked(companionID) + delete(companionPendingActions, companionID) + return actions +} + +func pendingCompanionActionsLocked(companionID string) []string { + pending := companionPendingActions[companionID] + actions := make([]string, 0, len(pending)) + for action := range pending { + actions = append(actions, action) + } + slices.Sort(actions) + return actions +} + +func forgetCompanionStatus(companionID string) { + companionStatusLock.Lock() + defer companionStatusLock.Unlock() + + delete(companionStatuses, companionID) + delete(companionPendingActions, companionID) +} + +func clearCompanionStatuses() { + companionStatusLock.Lock() + defer companionStatusLock.Unlock() + + companionStatuses = map[string]companionStatusSnapshot{} + companionPendingActions = map[string]map[string]bool{} +} + +func companionPermissionAction(permission string) string { + switch strings.ToLower(strings.TrimSpace(permission)) { + case "notification", "notifications": + return "request_notification_permission" + case "overlay", "display_over_apps", "display-over-apps": + return "request_display_over_apps_permission" + case "battery", "unrestricted_battery", "unrestricted-battery": + return "request_unrestricted_battery_permission" + default: + return "" + } +} + +func getVisibleLocalIPEntries() []companionIPEntry { + interfaces, err := net.Interfaces() + if err != nil { + return nil + } + entries := []companionIPEntry{} + seen := map[string]bool{} + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ip := ipFromAddr(addr) + if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + text := ip.String() + if seen[text] { + continue + } + seen[text] = true + entries = append(entries, companionIPEntry{ + IP: text, + Hostname: resolveHostname(text), + Source: "backend", + Interface: iface.Name, + }) + } + } + slices.SortFunc(entries, func(a, b companionIPEntry) int { + aKey := strings.ToLower(strings.TrimSpace(a.Hostname + " " + a.IP + " " + a.Interface)) + bKey := strings.ToLower(strings.TrimSpace(b.Hostname + " " + b.IP + " " + b.Interface)) + if aKey < bKey { + return -1 + } + if aKey > bKey { + return 1 + } + return 0 + }) + return entries +} + +func hostnameForAddress(address string) string { + host := strings.TrimSpace(address) + if host == "" { + return "" + } + if splitHost, _, err := net.SplitHostPort(host); err == nil { + host = splitHost + } + return resolveHostname(host) +} + +func resolveHostname(ipOrHost string) string { + ipOrHost = strings.TrimSpace(ipOrHost) + if ipOrHost == "" || net.ParseIP(ipOrHost) == nil { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + names, err := net.DefaultResolver.LookupAddr(ctx, ipOrHost) + if err != nil || len(names) == 0 { + return "" + } + return strings.TrimSuffix(strings.TrimSpace(names[0]), ".") +} + +func ipFromAddr(addr net.Addr) net.IP { + switch v := addr.(type) { + case *net.IPNet: + return v.IP + case *net.IPAddr: + return v.IP + default: + return nil + } +} + +func cleanStringList(values []string) []string { + cleaned := []string{} + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + cleaned = append(cleaned, value) + } + return cleaned +} + +func normalizeCompanionURL(rawURL string) string { + trimmed := strings.TrimSpace(rawURL) + if trimmed == "" { + return "" + } + if !strings.Contains(trimmed, "://") { + trimmed = "https://" + trimmed + } + parsed, err := neturl.Parse(trimmed) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "" + } + if parsed.Port() == "" { + parsed.Host = net.JoinHostPort(parsed.Hostname(), "8787") + trimmed = parsed.String() + } + return strings.TrimRight(trimmed, "/") +} + +func companionIDFromSignedRequest(c *gin.Context, body []byte) (string, bool) { + companionID := strings.TrimSpace(c.GetHeader("X-JetKVM-Companion-ID")) + if companionID == "" { + return "", false + } + authorization, ok := companionAuthorizations()[companionID] + if !ok || authorization.PublicKey == "" { + return "", false + } + + timestamp := strings.TrimSpace(c.GetHeader("X-JetKVM-Timestamp")) + nonce := strings.TrimSpace(c.GetHeader("X-JetKVM-Nonce")) + signatureText := strings.TrimSpace(c.GetHeader("X-JetKVM-Signature")) + if timestamp == "" || nonce == "" || signatureText == "" { + return "", false + } + + requestTime, err := time.Parse(time.RFC3339Nano, timestamp) + if err != nil { + return "", false + } + now := time.Now() + if requestTime.Before(now.Add(-companionSignatureMaxSkew)) || requestTime.After(now.Add(companionSignatureMaxSkew)) { + return "", false + } + if hasCompanionNonce(companionID, nonce) { + return "", false + } + + publicKeyBytes, err := base64.StdEncoding.DecodeString(authorization.PublicKey) + if err != nil { + return "", false + } + publicKey, err := x509.ParsePKIXPublicKey(publicKeyBytes) + if err != nil { + return "", false + } + ecdsaPublicKey, ok := publicKey.(*ecdsa.PublicKey) + if !ok { + return "", false + } + signature, err := base64.StdEncoding.DecodeString(signatureText) + if err != nil { + return "", false + } + bodyHashBytes := sha256.Sum256(body) + bodyHash := hex.EncodeToString(bodyHashBytes[:]) + canonical := strings.Join([]string{ + c.Request.Method, + c.Request.URL.Path, + timestamp, + nonce, + bodyHash, + }, "\n") + digest := sha256.Sum256([]byte(canonical)) + if !ecdsa.VerifyASN1(ecdsaPublicKey, digest[:], signature) { + return "", false + } + if !rememberCompanionNonce(companionID, nonce, now.Add(companionSignatureMaxSkew)) { + return "", false + } + return companionID, true +} + +func hasValidCompanionSignature(c *gin.Context, body []byte) bool { + _, ok := companionIDFromSignedRequest(c, body) + return ok +} + +func hasCompanionNonce(companionID string, nonce string) bool { + if len(nonce) < 16 || len(nonce) > 128 { + return true + } + companionNonceLock.Lock() + defer companionNonceLock.Unlock() + pruneCompanionNoncesLocked(time.Now()) + + for knownNonce := range companionNonces[companionID] { + if subtle.ConstantTimeCompare([]byte(knownNonce), []byte(nonce)) == 1 { + return true + } + } + return false +} + +func rememberCompanionNonce(companionID string, nonce string, expiresAt time.Time) bool { + companionNonceLock.Lock() + defer companionNonceLock.Unlock() + pruneCompanionNoncesLocked(time.Now()) + + if companionNonces[companionID] == nil { + companionNonces[companionID] = map[string]time.Time{} + } + for knownNonce := range companionNonces[companionID] { + if subtle.ConstantTimeCompare([]byte(knownNonce), []byte(nonce)) == 1 { + return false + } + } + companionNonces[companionID][nonce] = expiresAt + return true +} + +func pruneCompanionNoncesLocked(now time.Time) { + for id, nonces := range companionNonces { + for knownNonce, expiry := range nonces { + if !expiry.After(now) { + delete(nonces, knownNonce) + } + } + if len(nonces) == 0 { + delete(companionNonces, id) + } + } +} + +func isValidCompanionPublicKey(publicKey string) bool { + publicKey = strings.TrimSpace(publicKey) + if publicKey == "" { + return false + } + publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKey) + if err != nil { + return false + } + parsed, err := x509.ParsePKIXPublicKey(publicKeyBytes) + if err != nil { + return false + } + _, ok := parsed.(*ecdsa.PublicKey) + return ok +} + +func signalCompanionPairRequest(companionURL string, jetkvmURL string, requestID string) error { + parsed, err := neturl.Parse(companionURL) + if err != nil || parsed.Scheme != "https" { + return fmt.Errorf("companion pairing signal requires https") + } + if parsedJetKVMURL, err := neturl.Parse(jetkvmURL); err != nil || parsedJetKVMURL.Scheme != "https" { + return fmt.Errorf("JetKVM pairing URL requires https") + } + bodyText := fmt.Sprintf( + `{"jetkvm_url":%q,"request_id":%q}`, + jetkvmURL, + requestID, + ) + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + var lastErr error + for attempt := 1; attempt <= 5; attempt++ { + req, err := http.NewRequest(http.MethodPost, companionURL+"/pair/request", strings.NewReader(bodyText)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + lastErr = err + logger.Warn(). + Err(err). + Str("companion_url", companionURL). + Int("attempt", attempt). + Msg("failed to signal companion pairing request") + } else { + defer resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + lastErr = fmt.Errorf("companion returned status %d", resp.StatusCode) + logger.Warn(). + Int("status", resp.StatusCode). + Str("companion_url", companionURL). + Int("attempt", attempt). + Msg("companion pairing request signal rejected") + } + if attempt < 5 { + time.Sleep(time.Duration(attempt*attempt) * time.Second) + } + } + if lastErr != nil { + return lastErr + } + return fmt.Errorf("companion signal failed") +} + +func isValidCompanionOTP(otp string) bool { + if len(otp) != 6 { + return false + } + for _, r := range otp { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func isValidJetKVMUSBIdentity(identity string) bool { + normalized := strings.ToLower(strings.TrimSpace(identity)) + if normalized == "" { + return false + } + deviceID := strings.ToLower(GetDeviceID()) + if normalized == deviceID { + return true + } + shortID := deviceID + if len(shortID) > 8 { + shortID = shortID[:8] + } + return normalized == shortID +} + +func shouldUseAndroidNativeLogin(c *gin.Context) bool { + userAgent := c.GetHeader("User-Agent") + if strings.Contains(userAgent, "JetKVMWebView/1") { + return true + } + return c.Query("jetkvmAndroid") == "1" +} + +func hasValidLocalAuthCookie(c *gin.Context) bool { + if config.LocalAuthMode == "noPassword" { + return true + } + authToken, err := c.Cookie("authToken") + return err == nil && authToken != "" && authToken == config.LocalAuthToken +} + +func handleAndroidNativeLogin(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, ` + + + + + JetKVM login + + + +
Open the JetKVM Android login screen.
+ + +`) +} + // TODO: support multiple sessions? var currentSession *Session