mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
render: add cross-platform desktop companion display
This commit is contained in:
@@ -272,6 +272,38 @@ function HostShell.quote(s)
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- Launch another instance of this packaged app without waiting for it. The
|
||||
-- same path works on all process-capable desktop hosts; only the shell's
|
||||
-- background spelling differs. Source checkouts include their game folder,
|
||||
-- while fused releases and AppImages already carry it in the executable.
|
||||
function HostShell.spawnSelfDetached(args)
|
||||
if not require("src.core.Platform").canSpawnProcess() then return false end
|
||||
local fs = love and love.filesystem
|
||||
if not (fs and fs.getExecutablePath) then return false end
|
||||
local executable = os.getenv("APPIMAGE") or fs.getExecutablePath()
|
||||
if type(executable) ~= "string" or executable == "" then return false end
|
||||
|
||||
local argv = {}
|
||||
local fused = fs.isFused and fs.isFused()
|
||||
if not os.getenv("APPIMAGE") and not fused and fs.getSource then
|
||||
argv[#argv + 1] = fs.getSource()
|
||||
end
|
||||
for _, value in ipairs(args or {}) do argv[#argv + 1] = tostring(value) end
|
||||
|
||||
local command = HostShell.quote(executable)
|
||||
for _, value in ipairs(argv) do
|
||||
command = command .. " " .. HostShell.quote(value)
|
||||
end
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
if osName == "Windows" then
|
||||
command = 'start "" /b ' .. command .. " >NUL 2>&1"
|
||||
else
|
||||
command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &"
|
||||
end
|
||||
local ok, _, code = os.execute(command)
|
||||
return ok == true or ok == 0 or code == 0
|
||||
end
|
||||
|
||||
-- MEMOISED per Lua state (so once per thread). This used to spawn a whole
|
||||
-- `curl --version` process on every single fetch -- twice for a GET through
|
||||
-- the Android-bridge fallback -- which doubled the number of spawns the lock
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Minimal second-window process for src/render/DesktopScreen.lua.
|
||||
|
||||
local DesktopCompanion = {}
|
||||
|
||||
function DesktopCompanion.install(config)
|
||||
local enet = require("enet")
|
||||
local host = assert(enet.host_create())
|
||||
local peer = assert(host:connect(("127.0.0.1:%d"):format(config.port), 2))
|
||||
local image, sourceW, sourceH, preference
|
||||
local background = { 0, 0, 0, 1 }
|
||||
local connected, commandedQuit = false, false
|
||||
local pointerDown = false
|
||||
local started = love.timer.getTime()
|
||||
local lastContact = started
|
||||
|
||||
local function send(kind, payload)
|
||||
if not connected then return end
|
||||
pcall(peer.send, peer, kind .. config.token .. (payload or ""), 1, "reliable")
|
||||
end
|
||||
|
||||
local function receiveFrame(data)
|
||||
local prefix = "F" .. config.token .. "\n"
|
||||
if data:sub(1, #prefix) ~= prefix then return end
|
||||
local split = data:find("\n", #prefix + 1, true)
|
||||
if not split then return end
|
||||
local w, h, rgb, mode = data:sub(#prefix + 1, split - 1)
|
||||
:match("^(%d+),(%d+),(%d+),([%w_:.-]+)$")
|
||||
w, h, rgb = tonumber(w), tonumber(h), tonumber(rgb)
|
||||
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return end
|
||||
local ok, raw = pcall(love.data.decompress, "string", "lz4",
|
||||
data:sub(split + 1))
|
||||
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return end
|
||||
local made, pixels = pcall(love.image.newImageData, w, h, "rgba8", raw)
|
||||
if not made then return end
|
||||
if not image or sourceW ~= w or sourceH ~= h then
|
||||
if image and image.release then image:release() end
|
||||
image = love.graphics.newImage(pixels)
|
||||
else
|
||||
image:replacePixels(pixels)
|
||||
end
|
||||
sourceW, sourceH, preference = w, h, mode
|
||||
image:setFilter(mode:find("cover", 1, true) and "linear" or "nearest",
|
||||
mode:find("cover", 1, true) and "linear" or "nearest")
|
||||
background = {
|
||||
math.floor(rgb / 0x10000) % 0x100 / 255,
|
||||
math.floor(rgb / 0x100) % 0x100 / 255,
|
||||
rgb % 0x100 / 255, 1,
|
||||
}
|
||||
end
|
||||
|
||||
local function service()
|
||||
while true do
|
||||
local event = host:service(0)
|
||||
if not event then break end
|
||||
if event.type == "connect" then
|
||||
connected, lastContact = true, love.timer.getTime()
|
||||
send("H")
|
||||
elseif event.type == "receive" then
|
||||
lastContact = love.timer.getTime()
|
||||
if event.data == "Q" .. config.token then
|
||||
commandedQuit = true
|
||||
love.event.quit()
|
||||
elseif event.data ~= "P" .. config.token then
|
||||
receiveFrame(event.data)
|
||||
end
|
||||
elseif event.type == "disconnect" then
|
||||
love.event.quit()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function placement()
|
||||
if not image then return 0, 0, 1 end
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local cover = preference and preference:find("cover", 1, true)
|
||||
local scale = (cover and math.max or math.min)(ww / sourceW, wh / sourceH)
|
||||
return (ww - sourceW * scale) / 2, (wh - sourceH * scale) / 2, scale
|
||||
end
|
||||
|
||||
local function input(action, x, y)
|
||||
if not image then return false end
|
||||
local dx, dy, scale = placement()
|
||||
local sx, sy = math.floor((x - dx) / scale), math.floor((y - dy) / scale)
|
||||
if sx < 0 or sy < 0 or sx >= sourceW or sy >= sourceH then return false end
|
||||
send("I", ("\n%s,%d,%d"):format(action, sx, sy))
|
||||
return true
|
||||
end
|
||||
|
||||
function love.update()
|
||||
service()
|
||||
local t = love.timer.getTime()
|
||||
if (not connected and t - started > 5) or t - lastContact > 5 then
|
||||
love.event.quit()
|
||||
end
|
||||
end
|
||||
|
||||
function love.draw()
|
||||
love.graphics.clear(background[1], background[2], background[3], background[4])
|
||||
if not image then return end
|
||||
local x, y, scale = placement()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(image, x, y, 0, scale, scale)
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button)
|
||||
if button == 1 then pointerDown = input("down", x, y) end
|
||||
end
|
||||
function love.mousereleased(x, y, button)
|
||||
if button == 1 and pointerDown then
|
||||
if not input("up", x, y) then send("I", "\ncancel,0,0") end
|
||||
pointerDown = false
|
||||
end
|
||||
end
|
||||
function love.touchpressed(_, x, y) input("down", x, y) end
|
||||
function love.touchreleased(_, x, y) input("up", x, y) end
|
||||
function love.keypressed(key)
|
||||
if key == "escape" then love.event.quit() end
|
||||
end
|
||||
function love.quit()
|
||||
if not commandedQuit then send("C") end
|
||||
pcall(peer.disconnect_now, peer)
|
||||
end
|
||||
end
|
||||
|
||||
return DesktopCompanion
|
||||
@@ -0,0 +1,137 @@
|
||||
-- Cross-platform desktop secondary display. LOVE owns one window, so a
|
||||
-- second minimal instance of this same app owns the companion window. ENet
|
||||
-- is bundled with LOVE; binding it to loopback keeps frames and input local.
|
||||
|
||||
local Platform = require("src.core.Platform")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local okEnet, enet = pcall(require, "enet")
|
||||
|
||||
local DesktopScreen = {}
|
||||
local state = {
|
||||
enabled = false, blocked = false, host = nil, peer = nil,
|
||||
token = nil, port = nil, touches = {}, retryAt = 0, heartbeatAt = 0,
|
||||
}
|
||||
|
||||
local function now()
|
||||
return love and love.timer and love.timer.getTime and love.timer.getTime()
|
||||
or os.clock()
|
||||
end
|
||||
|
||||
local function destroy(sendQuit)
|
||||
if state.peer and sendQuit then
|
||||
pcall(state.peer.send, state.peer, "Q" .. state.token, 1, "reliable")
|
||||
end
|
||||
if state.peer then pcall(state.peer.disconnect_now, state.peer) end
|
||||
if state.host then pcall(state.host.destroy, state.host) end
|
||||
state.host, state.peer, state.token, state.port = nil, nil, nil, nil
|
||||
state.touches = {}
|
||||
end
|
||||
|
||||
local function token()
|
||||
local seed = table.concat({ tostring(os.time()), tostring(now()), tostring({}) }, ":")
|
||||
local digest = love.data.hash("sha256", seed)
|
||||
return love.data.encode("string", "hex", digest):sub(1, 24)
|
||||
end
|
||||
|
||||
local function start()
|
||||
if state.host or state.blocked or now() < state.retryAt then
|
||||
return state.host ~= nil
|
||||
end
|
||||
local base = 49152 + math.floor(now() * 1000) % 12000
|
||||
for attempt = 0, 31 do
|
||||
local port = 49152 + (base - 49152 + attempt * 37) % 12000
|
||||
local ok, host = pcall(enet.host_create,
|
||||
("127.0.0.1:%d"):format(port), 1, 2)
|
||||
if ok and host then
|
||||
state.host, state.port, state.token = host, port, token()
|
||||
local launched = HostShell.spawnSelfDetached({
|
||||
("--display-companion=%d,%s"):format(port, state.token),
|
||||
})
|
||||
if launched then return true end
|
||||
destroy(false)
|
||||
break
|
||||
end
|
||||
end
|
||||
state.retryAt = now() + 1
|
||||
return false
|
||||
end
|
||||
|
||||
local function service()
|
||||
if not state.enabled or state.blocked then return end
|
||||
if not state.host and not start() then return end
|
||||
while state.host do
|
||||
local ok, event = pcall(state.host.service, state.host, 0)
|
||||
if not ok then
|
||||
destroy(false)
|
||||
state.retryAt = now() + 1
|
||||
return
|
||||
end
|
||||
if not event then break end
|
||||
if event.type == "receive" then
|
||||
local data = event.data or ""
|
||||
if data == "H" .. state.token then
|
||||
state.peer = event.peer
|
||||
elseif event.peer == state.peer
|
||||
and data:sub(1, #state.token + 2) == "I" .. state.token .. "\n" then
|
||||
state.touches[#state.touches + 1] = data:sub(#state.token + 3)
|
||||
elseif event.peer == state.peer and data == "C" .. state.token then
|
||||
state.blocked = true
|
||||
destroy(false)
|
||||
return
|
||||
end
|
||||
elseif event.type == "disconnect" and event.peer == state.peer then
|
||||
destroy(false)
|
||||
state.retryAt = now() + 1
|
||||
return
|
||||
end
|
||||
end
|
||||
if state.peer and now() >= state.heartbeatAt then
|
||||
state.heartbeatAt = now() + 1
|
||||
pcall(state.peer.send, state.peer, "P" .. state.token, 1, "unreliable")
|
||||
end
|
||||
end
|
||||
|
||||
function DesktopScreen.usable()
|
||||
return okEnet and enet ~= nil and Platform.canSpawnProcess()
|
||||
end
|
||||
|
||||
function DesktopScreen.available()
|
||||
return DesktopScreen.detected()
|
||||
end
|
||||
|
||||
function DesktopScreen.detected()
|
||||
service()
|
||||
return state.peer ~= nil
|
||||
end
|
||||
|
||||
function DesktopScreen.push(imageData, w, h, background, preference)
|
||||
service()
|
||||
if not state.peer or not imageData or not imageData.getString then return false end
|
||||
w, h = tonumber(w), tonumber(h)
|
||||
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return false end
|
||||
local ok, raw = pcall(imageData.getString, imageData)
|
||||
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return false end
|
||||
local packed = love.data.compress("string", "lz4", raw, 1)
|
||||
local header = ("F%s\n%d,%d,%u,%s\n"):format(state.token, w, h,
|
||||
tonumber(background) or 0, tostring(preference or "auto"):gsub("[^%w_:.-]", ""))
|
||||
local sent = pcall(state.peer.send, state.peer, header .. packed, 0, "reliable")
|
||||
return sent
|
||||
end
|
||||
|
||||
function DesktopScreen.pollTouch()
|
||||
service()
|
||||
return table.remove(state.touches, 1)
|
||||
end
|
||||
|
||||
function DesktopScreen.setEnabled(on)
|
||||
on = on == true
|
||||
if on == state.enabled then
|
||||
if on then service() end
|
||||
return
|
||||
end
|
||||
state.enabled = on
|
||||
state.blocked = false
|
||||
if on then start(); service() else destroy(true) end
|
||||
end
|
||||
|
||||
return DesktopScreen
|
||||
@@ -1,11 +1,11 @@
|
||||
-- Bridge to native secondary-display output (Android Presentation). The C
|
||||
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
|
||||
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
|
||||
-- this stays inert and the renderer keeps the in-window stacked layout.
|
||||
-- Shared secondary-display facade. Android uses its native Presentation
|
||||
-- bridge; process-capable desktop hosts fall back to a companion window.
|
||||
-- Everything is guarded, so unsupported hosts keep the in-window layout.
|
||||
|
||||
local SecondScreen = {}
|
||||
local C = nil
|
||||
local ffi = nil
|
||||
local desktop = nil
|
||||
|
||||
local function log(msg)
|
||||
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
|
||||
@@ -37,17 +37,36 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
if not C then
|
||||
local ok, backend = pcall(require, "src.render.DesktopScreen")
|
||||
if ok and backend and backend.usable and backend.usable() then
|
||||
desktop = backend
|
||||
log("desktop companion backend ready")
|
||||
end
|
||||
end
|
||||
|
||||
function SecondScreen.usable()
|
||||
return C ~= nil
|
||||
return C ~= nil or desktop ~= nil
|
||||
end
|
||||
|
||||
function SecondScreen.available()
|
||||
if desktop then return desktop.available() end
|
||||
if not C then return false end
|
||||
local ok, r = pcall(C.love_android_secondary_ready)
|
||||
return ok and r ~= 0
|
||||
end
|
||||
|
||||
function SecondScreen.push(imageData, w, h)
|
||||
-- A connected display is not necessarily the current Presentation yet. This
|
||||
-- distinction lets a companion retry its first frame after hotplug/re-target.
|
||||
function SecondScreen.detected()
|
||||
if desktop then return desktop.detected() end
|
||||
return SecondScreen.available()
|
||||
end
|
||||
|
||||
function SecondScreen.push(imageData, w, h, background, preference)
|
||||
if desktop then
|
||||
return desktop.push(imageData, w, h, background, preference)
|
||||
end
|
||||
if not C or not imageData then return false end
|
||||
return pcall(function()
|
||||
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
|
||||
@@ -57,6 +76,7 @@ end
|
||||
-- Returns the oldest queued secondary-display event as "action,x,y", where
|
||||
-- coordinates are in the submitted frame's pixel space.
|
||||
function SecondScreen.pollTouch()
|
||||
if desktop then return desktop.pollTouch() end
|
||||
if not C then return nil end
|
||||
local ok, event = pcall(function()
|
||||
return C.love_android_poll_secondary_touch()
|
||||
@@ -66,6 +86,7 @@ function SecondScreen.pollTouch()
|
||||
end
|
||||
|
||||
function SecondScreen.setEnabled(on)
|
||||
if desktop then return desktop.setEnabled(on) end
|
||||
if not C then return end
|
||||
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user