Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="sensorLandscape"
android:screenOrientation="fullSensor"
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:hardwareAccelerated="true">
<intent-filter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ fun DisplayScreen(

var showOverlay by remember { mutableStateOf(false) }
var showFps by remember { mutableStateOf(true) }
var fillBounds by remember { mutableStateOf(true) }

// Keep screen on
val context = LocalContext.current
Expand Down Expand Up @@ -100,7 +101,7 @@ fun DisplayScreen(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Second Screen",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
contentScale = if (fillBounds) ContentScale.FillBounds else ContentScale.Fit,
)
}

Expand Down
13 changes: 12 additions & 1 deletion scripts/install_apk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@ fi

if [ ! -f "$APK_PATH" ]; then
echo "📦 APK not found. Building with Gradle..."
export JAVA_HOME="${JAVA_HOME:-$HOME/.jdks/jbr-17.0.14}"
if [ -z "$JAVA_HOME" ] || [ ! -d "$JAVA_HOME" ]; then
for candidate in \
"$HOME/.jdks/jbr-17.0.14" \
"/usr/lib/jvm/java-17-openjdk" \
"/usr/lib/jvm/java-17" \
"/usr/lib/jvm/default-java"; do
if [ -d "$candidate" ]; then
export JAVA_HOME="$candidate"
break
fi
done
fi
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
cd "$SCRIPT_DIR/android"
./gradlew assembleDebug
Expand Down
33 changes: 24 additions & 9 deletions scripts/linuxextend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@

set -eo pipefail

INSTALL_DIR="/home/neonjava/LinuxExtend"
LOG_DIR="$HOME/.local/state/linuxextend"
INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/linuxextend"
LOG_FILE="$LOG_DIR/server.log"
PID_FILE="$LOG_DIR/server.pid"
ADB_BIN="/home/neonjava/Android/Sdk/platform-tools/adb"
ADB_BIN="${ANDROID_HOME:-$HOME/Android/Sdk}/platform-tools/adb"
[ -x "$ADB_BIN" ] || ADB_BIN="$(command -v adb 2>/dev/null || true)"

mkdir -p "$LOG_DIR"
Expand Down Expand Up @@ -44,7 +44,7 @@ get_pid() {
return
fi
fi
pgrep -f "python3 -m linuxextend" | head -1
pgrep -f "python3 -m linuxextend" 2>/dev/null | head -1 || true
}

cleanup_displays() {
Expand All @@ -68,10 +68,13 @@ setup_usb() {
"$ADB_BIN" reverse tcp:8080 tcp:8080 >/dev/null 2>&1 || true
echo -e " ${C_GREEN}✓ USB ADB reverse active${C_RESET}"

# Check if app is installed and launch it
# Launch on tablet: native app if installed, otherwise browser
if "$ADB_BIN" shell pm list packages 2>/dev/null | grep -q "com.linuxextend"; then
echo -e " ${C_CYAN}Launching LinuxExtend on tablet...${C_RESET}"
"$ADB_BIN" shell am start -n com.linuxextend/.MainActivity >/dev/null 2>&1 || true
elif "$ADB_BIN" shell pm list packages 2>/dev/null | grep -q "com.android.chrome"; then
echo -e " ${C_CYAN}Launching stream in Chrome on tablet...${C_RESET}"
"$ADB_BIN" shell am start -n com.android.chrome/com.google.android.apps.chrome.Main -d "http://127.0.0.1:8080" >/dev/null 2>&1 || true
fi
fi
fi
Expand All @@ -92,10 +95,17 @@ cmd_start() {
echo -e "${C_BOLD}${C_BLUE}Starting LinuxExtend server...${C_RESET}"
cd "$INSTALL_DIR/server"

# Start server in background
nohup python3 -m linuxextend "$@" > "$LOG_FILE" 2>&1 &
local s_pid=$!
echo "$s_pid" > "$PID_FILE"
# Start server in background (using systemd-run if available, else nohup)
if command -v systemd-run >/dev/null 2>&1; then
systemd-run --user --unit=linuxextend-server --working-directory="$INSTALL_DIR/server" python3 -u -m linuxextend "$@" >/dev/null 2>&1
Comment on lines +99 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fall back to nohup when the user service manager is unavailable

When systemd-run is installed but no user service manager is reachable, start now exits without launching the server. The installed command's help describes --user as “Run as user unit”; executable availability alone does not establish that this works. In the inspected environment, systemd-run --user ... /bin/true fails with Failed to connect to bus: No medium found. Because this invocation runs under set -e and suppresses its diagnostics, the existing nohup branch is never reached. Test whether service creation succeeds and fall back on failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve log access for the new systemd launch path

When the systemd branch is used, the server no longer writes to $LOG_FILE, but both cmd_logs() and the startup-failure diagnostic still read only that file. Consequently, linuxextend logs reports no log file on a fresh installation or follows stale output from an earlier nohup launch, and startup errors lose their Python diagnostics. Route the service output to the existing log file or update both readers to retrieve the unit's journal.

Useful? React with 👍 / 👎.

local s_pid
s_pid=$(pgrep -f "python3 -u -m linuxextend" 2>/dev/null | head -1 || true)
[ -n "$s_pid" ] && echo "$s_pid" > "$PID_FILE"
else
nohup python3 -u -m linuxextend "$@" < /dev/null > "$LOG_FILE" 2>&1 &
local s_pid=$!
echo "$s_pid" > "$PID_FILE"
fi

# Wait for virtual display initialization
local timeout=10
Expand Down Expand Up @@ -143,6 +153,11 @@ cmd_stop() {
echo -e "${C_BOLD}${C_BLUE}Stopping LinuxExtend...${C_RESET}"
local stopped=0

# Stop systemd unit if running
if command -v systemctl >/dev/null 2>&1; then
systemctl --user stop linuxextend-server 2>/dev/null || true
fi

local pid
pid=$(get_pid)
if [ -n "$pid" ]; then
Expand Down
59 changes: 43 additions & 16 deletions scripts/switch_monitor.sh
Original file line number Diff line number Diff line change
@@ -1,19 +1,46 @@
#!/usr/bin/env bash
# Switch mouse cursor and monitor focus between laptop (eDP-1) and tablet (HEADLESS-*)

CURSOR_POS=$(hyprctl cursorpos | tr -d ' ')
CURSOR_X=$(echo "$CURSOR_POS" | cut -d',' -f1)

# Laptop width is 1920
if [ "${CURSOR_X:-0}" -lt 1920 ]; then
# Currently on Laptop -> Jump to Tablet (centered at 2880, 540)
hyprctl dispatch movecursor 2880 540
HEADLESS_NAME=$(hyprctl monitors -j | jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1)
if [ -n "$HEADLESS_NAME" ]; then
hyprctl dispatch focusmonitor "$HEADLESS_NAME"
fi
# Switch mouse cursor and monitor focus between primary monitor and virtual headless monitor

if ! command -v hyprctl >/dev/null 2>&1; then
exit 0
fi

FOCUSED_MON=$(hyprctl monitors -j 2>/dev/null | jq -r '.[] | select(.focused) | .name')
HEADLESS_MON=$(hyprctl monitors -j 2>/dev/null | jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1)
PRIMARY_MON=$(hyprctl monitors -j 2>/dev/null | jq -r '.[] | select((.name | startswith("HEADLESS") | not)) | .name' | head -1)

if [ -z "$HEADLESS_MON" ]; then
# No virtual monitor active
exit 0
fi

if [ "$FOCUSED_MON" = "$HEADLESS_MON" ]; then
TARGET_MON="$PRIMARY_MON"
else
# Currently on Tablet -> Jump to Laptop (centered at 960, 540)
hyprctl dispatch movecursor 960 540
hyprctl dispatch focusmonitor eDP-1
TARGET_MON="$HEADLESS_MON"
fi

[ -z "$TARGET_MON" ] && exit 0

# Calculate center coordinates of target monitor
TARGET_INFO=$(hyprctl monitors -j 2>/dev/null | jq -r ".[] | select(.name == \"$TARGET_MON\")")
TARGET_X=$(echo "$TARGET_INFO" | jq -r '.x // 0')
TARGET_Y=$(echo "$TARGET_INFO" | jq -r '.y // 0')
TARGET_W=$(echo "$TARGET_INFO" | jq -r '.width // 1920')
TARGET_H=$(echo "$TARGET_INFO" | jq -r '.height // 1080')
TARGET_TRANSFORM=$(echo "$TARGET_INFO" | jq -r '.transform // 0')

# Account for rotated orientation
if [ "$TARGET_TRANSFORM" -eq 1 ] || [ "$TARGET_TRANSFORM" -eq 3 ]; then
EFF_W=$TARGET_H
EFF_H=$TARGET_W
else
EFF_W=$TARGET_W
EFF_H=$TARGET_H
fi

CENTER_X=$(( TARGET_X + EFF_W / 2 ))
CENTER_Y=$(( TARGET_Y + EFF_H / 2 ))

hyprctl dispatch focusmonitor "$TARGET_MON" >/dev/null 2>&1 || true
hyprctl dispatch movecursor "$CENTER_X" "$CENTER_Y" >/dev/null 2>&1 || true
39 changes: 33 additions & 6 deletions server/linuxextend/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,31 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument(
"--fps", "-f",
type=int,
default=25,
help="Target capture FPS (default: 25)",
default=30,
help="Target capture FPS (default: 30)",
)
parser.add_argument(
"--quality", "-q",
type=int,
default=75,
help="JPEG quality 1-100 (default: 75)",
default=60,
help="JPEG quality 1-100 (default: 60)",
)
parser.add_argument(
"--position",
choices=["left", "right", "auto"],
default="auto",
help="Monitor position relative to primary: left, right, or auto (default: auto)",
)
parser.add_argument(
"--transform", "-t",
type=int,
default=0,
help="Display transform (0=normal, 1=portrait 90deg, 2=180deg, 3=270deg) (default: 0)",
)
parser.add_argument(
"--portrait",
action="store_true",
help="Quick setup: portrait orientation on the left (transform=1, position=left)",
)
parser.add_argument(
"--port", "-p",
Expand Down Expand Up @@ -140,6 +157,11 @@ def _parse_args() -> argparse.Namespace:

def main() -> None:
args = _parse_args()
if args.portrait:
if args.transform == 0:
args.transform = 1
if args.position == "auto":
args.position = "left"
_setup_logging(args.verbose)
_print_banner()

Expand All @@ -150,6 +172,8 @@ def main() -> None:

config = Config(
resolution=args.resolution,
position=args.position,
transform=args.transform,
target_fps=args.fps,
jpeg_quality=args.quality,
port=args.port,
Expand Down Expand Up @@ -196,16 +220,19 @@ def shutdown(signum: int = 0, frame: object = None) -> None:
display = VirtualDisplay(
resolution=config.resolution,
refresh_rate=config.refresh_rate,
position=config.position,
transform=config.transform,
)
display.create()
print(f" {_GREEN}✓ {display.name}{_RESET} — {config.resolution}@{config.refresh_rate}Hz")
print(f" {_GREEN}✓ {display.name}{_RESET} — {config.resolution}@{config.refresh_rate}Hz (pos: {config.position}, transform: {config.transform})")

# Step 2: Start screen capture
print(f"{_CYAN}📸 Starting screen capture...{_RESET}")
capture = ScreenCapture(
output_name=display.name,
fps=config.target_fps,
quality=config.jpeg_quality,
num_workers=config.num_workers,
)
capture.start()
print(f" {_GREEN}✓ Capturing at {config.target_fps} FPS, JPEG quality {config.jpeg_quality}{_RESET}")
Expand All @@ -218,7 +245,7 @@ def shutdown(signum: int = 0, frame: object = None) -> None:
name=config.service_name,
resolution=config.resolution,
)
advertiser.start()
advertiser.start(background=True)
local_ip = advertiser.local_ip
print(f" {_GREEN}✓ Advertising as {config.service_name}._linuxextend._tcp.local.{_RESET}")
else:
Expand Down
6 changes: 4 additions & 2 deletions server/linuxextend/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def _check_grim(self) -> None:
def _worker_loop(self, worker_id: int) -> None:
"""Staggered worker loop that continuously grabs frames."""
# Stagger worker starts to space out frame captures evenly
time.sleep((worker_id * self.frame_interval) / self.num_workers)
time.sleep(worker_id * (self.frame_interval / max(1, self.num_workers)))

cmd = [
"grim",
Expand All @@ -72,6 +72,8 @@ def _worker_loop(self, worker_id: int) -> None:
"-",
]

worker_interval = self.frame_interval * self.num_workers

while self._running:
t0 = time.monotonic()
try:
Expand All @@ -86,7 +88,7 @@ def _worker_loop(self, worker_id: int) -> None:

# Frame pacing to prevent worker overlap
elapsed = time.monotonic() - t0
sleep_time = max(0.005, self.frame_interval - elapsed)
sleep_time = max(0.005, worker_interval - elapsed)
time.sleep(sleep_time)

def _fps_tracker_loop(self) -> None:
Expand Down
4 changes: 3 additions & 1 deletion server/linuxextend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ class Config:
# Virtual display settings
resolution: str = "1920x1080"
refresh_rate: int = 60
position: str = "auto" # "auto" = right of primary monitor
position: str = "auto" # "auto" (right of primary), "left", "right", or custom XxY
transform: int = 0 # 0 = normal, 1 = 90deg, 2 = 180deg, 3 = 270deg

# Capture settings
target_fps: int = 25
jpeg_quality: int = 75
num_workers: int = 2

# Server settings
host: str = "0.0.0.0"
Expand Down
61 changes: 34 additions & 27 deletions server/linuxextend/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,34 +51,41 @@ def __init__(
self._service_info: ServiceInfo | None = None
self._local_ip = _get_local_ip()

def start(self) -> None:
def start(self, background: bool = False) -> None:
"""Register the mDNS service."""
try:
hostname = socket.gethostname()

self._service_info = ServiceInfo(
type_="_linuxextend._tcp.local.",
name=f"{self.name}._linuxextend._tcp.local.",
addresses=[socket.inet_aton(self._local_ip)],
port=self.port,
properties={
"version": "1.0",
"resolution": self.resolution,
"hostname": hostname,
},
server=f"{hostname}.local.",
)

self._zeroconf = Zeroconf()
self._zeroconf.register_service(self._service_info)

logger.info(
"mDNS service advertised: %s._linuxextend._tcp.local. at %s:%d",
self.name, self._local_ip, self.port,
)
except Exception as e:
logger.error("Failed to start mDNS advertising: %s", e)
self._zeroconf = None
def _do_register():
try:
hostname = socket.gethostname()

self._service_info = ServiceInfo(
type_="_linuxextend._tcp.local.",
name=f"{self.name}._linuxextend._tcp.local.",
addresses=[socket.inet_aton(self._local_ip)],
port=self.port,
properties={
"version": "1.0",
"resolution": self.resolution,
"hostname": hostname,
},
server=f"{hostname}.local.",
)

self._zeroconf = Zeroconf()
self._zeroconf.register_service(self._service_info)

logger.info(
"mDNS service advertised: %s._linuxextend._tcp.local. at %s:%d",
self.name, self._local_ip, self.port,
)
except Exception as e:
logger.error("Failed to start mDNS advertising: %s", e)
self._zeroconf = None

if background:
import threading
threading.Thread(target=_do_register, daemon=True).start()
else:
_do_register()

def stop(self) -> None:
"""Unregister the mDNS service and close Zeroconf."""
Expand Down
Loading