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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ luac.out
*.hex

.tests/
doc/tags
53 changes: 52 additions & 1 deletion lua/flutter-tools/runners/debugger_runner.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ local config = lazy.require("flutter-tools.config") ---@module "flutter-tools.co
local utils = lazy.require("flutter-tools.utils") ---@module "flutter-tools.utils"
local path = lazy.require("flutter-tools.utils.path") ---@module "flutter-tools.utils.path"
local vm_service_extensions = lazy.require("flutter-tools.runners.vm_service_extensions") ---@module "flutter-tools.runners.vm_service_extensions"
local vm_service = lazy.require("flutter-tools.vm_service") ---@module "flutter-tools.vm_service"
local success, dap = pcall(require, "dap")
if not success then
ui.notify(string.format("nvim-dap is not installed!\n%s", dap), ui.ERROR)
Expand Down Expand Up @@ -119,6 +120,45 @@ local function get_current_value(cmd)
end)
end

local function handle_inspect_event(isolate_id)
local session = dap.session()
if not session or not isolate_id then return end

local inspector_group = "flutter-tools-inspector"

local params = {
method = "ext.flutter.inspector.getSelectedSummaryWidget",
params = {
previousSelectionId = vim.NIL,
objectGroup = inspector_group,
isolateId = isolate_id,
},
}

session:request("callService", params, function(err, result)
if err or not result then return end

local widget_data = result.result or result
local location = widget_data.creationLocation
if not location and widget_data.children and widget_data.children[1] then
location = widget_data.children[1].creationLocation
end

if location and location.file and location.line then
local file = location.file:gsub("^file://", "")
vim.schedule(function()
vim.cmd("edit " .. vim.fn.fnameescape(file))
vim.api.nvim_win_set_cursor(0, { location.line, (location.column or 1) - 1 })
end)
end

session:request("callService", {
method = "ext.flutter.inspector.disposeGroup",
params = { objectGroup = inspector_group, isolateId = isolate_id },
}, function() end)
end)
end

local function register_dap_listeners(on_run_data, on_run_exit)
local started = false
local before_start_logs = {}
Expand All @@ -128,6 +168,7 @@ local function register_dap_listeners(on_run_data, on_run_exit)

local handle_termination = function()
if next(before_start_logs) ~= nil then on_run_exit(before_start_logs) end
if vm_service.is_connected() then vm_service.disconnect() end
end

dap.listeners.before["event_exited"][plugin_identifier] = function(_, _) handle_termination() end
Expand All @@ -140,7 +181,17 @@ local function register_dap_listeners(on_run_data, on_run_exit)
end

dap.listeners.before["event_dart.debuggerUris"][plugin_identifier] = function(_, body)
if body and body.vmServiceUri then dev_tools.register_profiler_url(body.vmServiceUri) end
if body and body.vmServiceUri then
dev_tools.register_profiler_url(body.vmServiceUri)

vm_service.connect(body.vmServiceUri, function()
vm_service.stream_listen("Debug", function(event)
if event and event.kind == "Inspect" and event.isolate and event.isolate.id then
handle_inspect_event(event.isolate.id)
end
end)
end)
end
end

dap.listeners.before["event_dart.serviceExtensionAdded"][plugin_identifier] = function(_, body)
Expand Down
279 changes: 279 additions & 0 deletions lua/flutter-tools/vm_service.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
--- VM Service WebSocket client for Flutter/Dart debugging
--- WebSocket framing per RFC 6455:
--- Client frames must be masked with a 4-byte key
--- Frame format: [FIN/opcode][mask/length][mask-key][payload]
local uv = vim.uv or vim.loop

local M = {}

local OPCODE_TEXT = 1
local OPCODE_CLOSE = 8
local OPCODE_PING = 9

local tcp = nil
local connected = false
local handshake_complete = false
local request_id = 0
local pending_requests = {}
local event_handlers = {}
local read_buffer = ""

local function generate_handshake(host, port, path)
local lines = {
"GET " .. path .. " HTTP/1.1",
"Host: " .. host .. ":" .. port,
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version: 13",
"",
"",
}
return table.concat(lines, "\r\n")
end

local function generate_mask_key()
return {
math.random(0, 255),
math.random(0, 255),
math.random(0, 255),
math.random(0, 255),
}
end

local function mask_payload(payload, key)
local masked = {}
for i = 1, #payload do
local byte = string.byte(payload, i, i)
local mask_byte = key[((i - 1) % 4) + 1]
table.insert(masked, string.char(bit.bxor(byte, mask_byte)))
end
return table.concat(masked, "")
end

local function create_frame(payload)
local key = generate_mask_key()
local len = #payload
local frame = {}

table.insert(frame, string.char(0x81))

if len < 126 then
table.insert(frame, string.char(0x80 + len))
elseif len < 65536 then
table.insert(frame, string.char(0x80 + 126))
table.insert(frame, string.char(bit.rshift(len, 8)))
table.insert(frame, string.char(bit.band(len, 0xFF)))
else
table.insert(frame, string.char(0x80 + 127))
for i = 7, 0, -1 do
table.insert(frame, string.char(bit.band(bit.rshift(len, i * 8), 0xFF)))
end
end

for _, k in ipairs(key) do
table.insert(frame, string.char(k))
end

table.insert(frame, mask_payload(payload, key))

return table.concat(frame, "")
end

local function parse_frame(data)
if #data < 2 then return nil, data end

local b1 = string.byte(data, 1)
local b2 = string.byte(data, 2)

local opcode = bit.band(b1, 0x0F)
local payload_len = bit.band(b2, 0x7F)

local header_len = 2
if payload_len == 126 then
if #data < 4 then return nil, data end
payload_len = bit.lshift(string.byte(data, 3), 8) + string.byte(data, 4)
header_len = 4
elseif payload_len == 127 then
if #data < 10 then return nil, data end
payload_len = 0
for i = 3, 10 do
payload_len = bit.lshift(payload_len, 8) + string.byte(data, i)
end
header_len = 10
end

local has_mask = bit.band(b2, 0x80) > 0
if has_mask then header_len = header_len + 4 end

local total_len = header_len + payload_len
if #data < total_len then return nil, data end

local payload = data:sub(header_len + 1, total_len)
local remaining = data:sub(total_len + 1)

return { opcode = opcode, payload = payload }, remaining
end

local function handle_message(message)
local ok, data = pcall(vim.json.decode, message)
if not ok then return end

if data.id and pending_requests[data.id] then
local callback = pending_requests[data.id]
pending_requests[data.id] = nil
vim.schedule(function() callback(data.error, data.result) end)
return
end

if data.method == "streamNotify" and data.params then
local stream_id = data.params.streamId
local event = data.params.event
if event_handlers[stream_id] then
vim.schedule(function() event_handlers[stream_id](event) end)
end
end
end

local function parse_uri(uri)
local protocol, rest = uri:match("^(wss?)://(.+)$")
if not protocol then
protocol, rest = uri:match("^(https?)://(.+)$")
end
if not rest then return nil, nil, nil end

local host_port, path = rest:match("^([^/]+)(/.*)$")
if not host_port then
host_port = rest
path = "/"
end

local host, port = host_port:match("^([^:]+):(%d+)$")
if not host then
host = host_port
port = (protocol == "wss" or protocol == "https") and 443 or 80
end

if not path:match("/ws$") then path = path:gsub("/$", "") .. "/ws" end

return host, tonumber(port), path
end

function M.connect(uri, on_connect, on_error)
if connected then M.disconnect() end

local host, port, path = parse_uri(uri)
if not host or not port then
if on_error then on_error("Invalid URI: " .. uri) end
return
end

tcp = uv.new_tcp()
handshake_complete = false
read_buffer = ""

tcp:connect(host, port, function(err)
if err then
vim.schedule(function()
if on_error then on_error("Connection failed: " .. tostring(err)) end
end)
return
end

tcp:write(generate_handshake(host, port, path))

tcp:read_start(function(read_err, chunk)
if read_err then
vim.schedule(function()
if on_error then on_error("Read error: " .. tostring(read_err)) end
end)
M.disconnect()
return
end

if not chunk then
M.disconnect()
return
end

if not handshake_complete then
if chunk:match("HTTP/1.1 101") then
handshake_complete = true
connected = true
vim.schedule(function()
if on_connect then on_connect() end
end)
end
return
end

read_buffer = read_buffer .. chunk
while true do
local frame, remaining = parse_frame(read_buffer)
if not frame then break end
read_buffer = remaining

if frame.opcode == OPCODE_TEXT then
handle_message(frame.payload)
elseif frame.opcode == OPCODE_PING then
local pong = string.char(0x8A, 0x80 + #frame.payload)
.. mask_payload(frame.payload, generate_mask_key())
tcp:write(pong)
elseif frame.opcode == OPCODE_CLOSE then
M.disconnect()
return
end
end
end)
end)
end

function M.request(method, params, callback)
if not connected or not tcp then
if callback then callback("Not connected", nil) end
return
end

request_id = request_id + 1
local id = tostring(request_id)

local message = vim.json.encode({
jsonrpc = "2.0",
id = id,
method = method,
params = params or {},
})

if callback then pending_requests[id] = callback end

tcp:write(create_frame(message))
end

function M.stream_listen(stream_id, handler, callback)
event_handlers[stream_id] = handler
M.request("streamListen", { streamId = stream_id }, callback)
end

function M.is_connected() return connected end

function M.disconnect()
connected = false
handshake_complete = false

for _, callback in pairs(pending_requests) do
vim.schedule(function() callback("Service connection closed", nil) end)
end
pending_requests = {}
event_handlers = {}
read_buffer = ""

if tcp then
if not tcp:is_closing() then
tcp:read_stop()
tcp:close()
end
tcp = nil
end
end

return M