@pired/lightroom-mcp 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/LightroomMCP.lrplugin/HandlerAI.lua +466 -0
- package/dist/LightroomMCP.lrplugin/HandlerAIMasks.lua +549 -0
- package/dist/LightroomMCP.lrplugin/HandlerCatalog.lua +256 -0
- package/dist/LightroomMCP.lrplugin/HandlerCollections.lua +337 -0
- package/dist/LightroomMCP.lrplugin/HandlerDevelop.lua +1668 -0
- package/dist/LightroomMCP.lrplugin/HandlerExport.lua +149 -0
- package/dist/LightroomMCP.lrplugin/HandlerImport.lua +108 -0
- package/dist/LightroomMCP.lrplugin/HandlerLocalAdjustments.lua +330 -0
- package/dist/LightroomMCP.lrplugin/HandlerMetadata.lua +169 -0
- package/dist/LightroomMCP.lrplugin/HandlerOrganization.lua +783 -0
- package/dist/LightroomMCP.lrplugin/HandlerPreview.lua +189 -0
- package/dist/LightroomMCP.lrplugin/HandlerSearch.lua +185 -0
- package/dist/LightroomMCP.lrplugin/HandlerSelection.lua +302 -0
- package/dist/LightroomMCP.lrplugin/HandlerSpots.lua +313 -0
- package/dist/LightroomMCP.lrplugin/HandlerWatermark.lua +59 -0
- package/dist/LightroomMCP.lrplugin/Info.lua +25 -0
- package/dist/LightroomMCP.lrplugin/JSON.lua +270 -0
- package/dist/LightroomMCP.lrplugin/Log.lua +92 -0
- package/dist/LightroomMCP.lrplugin/MenuShowStatus.lua +22 -0
- package/dist/LightroomMCP.lrplugin/PhotoLookup.lua +39 -0
- package/dist/LightroomMCP.lrplugin/PluginInfoProvider.lua +865 -0
- package/dist/LightroomMCP.lrplugin/PluginInit.lua +48 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +31 -0
- package/dist/cli.js.map +1 -0
- package/dist/configs/claude-code.mcp.json +9 -0
- package/dist/configs/claude-desktop.json +12 -0
- package/dist/configs/generic-stdio-client.json +10 -0
- package/dist/configs/mcp-proxy-http.json +9 -0
- package/dist/create-server.d.ts +12 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +17 -0
- package/dist/create-server.js.map +1 -0
- package/dist/deepseek-harness/README.md +112 -0
- package/dist/deepseek-harness/deepseek-mcp-harness.mjs +292 -0
- package/dist/dispatcher.d.ts +34 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/dispatcher.js +72 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/heartbeat.d.ts +17 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +36 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +255 -0
- package/dist/index.js.map +1 -0
- package/dist/install-plugin.d.ts +16 -0
- package/dist/install-plugin.d.ts.map +1 -0
- package/dist/install-plugin.js +125 -0
- package/dist/install-plugin.js.map +1 -0
- package/dist/instance-lock.d.ts +5 -0
- package/dist/instance-lock.d.ts.map +1 -0
- package/dist/instance-lock.js +77 -0
- package/dist/instance-lock.js.map +1 -0
- package/dist/list-tools-handler.d.ts +16 -0
- package/dist/list-tools-handler.d.ts.map +1 -0
- package/dist/list-tools-handler.js +50 -0
- package/dist/list-tools-handler.js.map +1 -0
- package/dist/plugin-liveness.d.ts +48 -0
- package/dist/plugin-liveness.d.ts.map +1 -0
- package/dist/plugin-liveness.js +75 -0
- package/dist/plugin-liveness.js.map +1 -0
- package/dist/plugin-socket.d.ts +32 -0
- package/dist/plugin-socket.d.ts.map +1 -0
- package/dist/plugin-socket.js +97 -0
- package/dist/plugin-socket.js.map +1 -0
- package/dist/ports.d.ts +3 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +22 -0
- package/dist/ports.js.map +1 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +23 -0
- package/dist/token.js.map +1 -0
- package/dist/tool-contracts.d.ts +13 -0
- package/dist/tool-contracts.d.ts.map +1 -0
- package/dist/tool-contracts.js +1422 -0
- package/dist/tool-contracts.js.map +1 -0
- package/dist/tool-handler.d.ts +23 -0
- package/dist/tool-handler.d.ts.map +1 -0
- package/dist/tool-handler.js +96 -0
- package/dist/tool-handler.js.map +1 -0
- package/dist/validate-args.d.ts +7 -0
- package/dist/validate-args.d.ts.map +1 -0
- package/dist/validate-args.js +37 -0
- package/dist/validate-args.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/dist/wait-until.d.ts +7 -0
- package/dist/wait-until.d.ts.map +1 -0
- package/dist/wait-until.js +15 -0
- package/dist/wait-until.js.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
local LrTasks = import 'LrTasks'
|
|
2
|
+
local LrDialogs = import 'LrDialogs'
|
|
3
|
+
local LrFunctionContext = import 'LrFunctionContext'
|
|
4
|
+
local LrSocket = import 'LrSocket'
|
|
5
|
+
local LrPrefs = import 'LrPrefs'
|
|
6
|
+
local LrView = import 'LrView'
|
|
7
|
+
local LrUUID = import 'LrUUID'
|
|
8
|
+
local LrPathUtils = import 'LrPathUtils'
|
|
9
|
+
local LrFileUtils = import 'LrFileUtils'
|
|
10
|
+
|
|
11
|
+
local JSON = require 'JSON'
|
|
12
|
+
local HandlerSearch = require 'HandlerSearch'
|
|
13
|
+
local HandlerCollections = require 'HandlerCollections'
|
|
14
|
+
local HandlerMetadata = require 'HandlerMetadata'
|
|
15
|
+
local HandlerOrganization = require 'HandlerOrganization'
|
|
16
|
+
local HandlerImport = require 'HandlerImport'
|
|
17
|
+
local HandlerExport = require 'HandlerExport'
|
|
18
|
+
local HandlerSelection = require 'HandlerSelection'
|
|
19
|
+
local HandlerDevelop = require 'HandlerDevelop'
|
|
20
|
+
local HandlerAI = require 'HandlerAI'
|
|
21
|
+
local HandlerSpots = require 'HandlerSpots'
|
|
22
|
+
local HandlerLocalAdjustments = require 'HandlerLocalAdjustments'
|
|
23
|
+
local HandlerWatermark = require 'HandlerWatermark'
|
|
24
|
+
local HandlerAIMasks = require 'HandlerAIMasks'
|
|
25
|
+
local HandlerCatalog = require 'HandlerCatalog'
|
|
26
|
+
local HandlerPreview = require 'HandlerPreview'
|
|
27
|
+
local Log = require 'Log'
|
|
28
|
+
|
|
29
|
+
local DEFAULT_REQUEST_PORT = 58763
|
|
30
|
+
local DEFAULT_RESPONSE_PORT = 58764
|
|
31
|
+
|
|
32
|
+
-- LrSocket fires these on its accept-loop when no client is attached yet.
|
|
33
|
+
-- Both are benign listen-side states, not real failures.
|
|
34
|
+
local function isNoClientError(errStr)
|
|
35
|
+
if errStr == "timeout" then return true end
|
|
36
|
+
if errStr:find("failed to open", 1, true) then return true end
|
|
37
|
+
return false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
local function validPort(n)
|
|
41
|
+
return type(n) == "number" and n == math.floor(n) and n >= 1 and n <= 65535
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
local function readPortPrefs()
|
|
45
|
+
local prefs = LrPrefs.prefsForPlugin()
|
|
46
|
+
local req = tonumber(prefs.requestPort)
|
|
47
|
+
local res = tonumber(prefs.responsePort)
|
|
48
|
+
if not validPort(req) then req = DEFAULT_REQUEST_PORT end
|
|
49
|
+
if not validPort(res) then res = DEFAULT_RESPONSE_PORT end
|
|
50
|
+
return req, res
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
-- State on _G so it survives across re-execution of this module body
|
|
54
|
+
-- within the same Lua state. This body runs BOTH when PluginInit requires
|
|
55
|
+
-- it AND every time Lightroom loads it as the InfoProvider to render the
|
|
56
|
+
-- Plug-in Manager panel. A render must NOT disturb a running server, so we
|
|
57
|
+
-- only ever CREATE state here (when absent) — never tear it down. Teardown
|
|
58
|
+
-- of a stale prior instance on Reload Plug-in is handled by resetForReload,
|
|
59
|
+
-- which PluginInit calls (PluginInit's LrInitPlugin runs on load/reload but
|
|
60
|
+
-- NOT on a plain panel render). Tearing down from this body — as earlier
|
|
61
|
+
-- versions did when running == true — killed the live server every time the
|
|
62
|
+
-- Plug-in Manager was opened (issues #121, #137).
|
|
63
|
+
if not _G.LightroomMCP_State then
|
|
64
|
+
_G.LightroomMCP_State = {
|
|
65
|
+
running = false,
|
|
66
|
+
shuttingDown = false,
|
|
67
|
+
requestSocket = nil,
|
|
68
|
+
responseSocket = nil,
|
|
69
|
+
sendConnected = false,
|
|
70
|
+
receiveConnected = false,
|
|
71
|
+
requestsProcessed = 0,
|
|
72
|
+
lastEvent = nil,
|
|
73
|
+
log = {},
|
|
74
|
+
token = nil,
|
|
75
|
+
lastRequestTime = nil,
|
|
76
|
+
lastConnectedTime = nil,
|
|
77
|
+
needsFullRestart = false,
|
|
78
|
+
freshRestart = false,
|
|
79
|
+
inFlightRequests = 0,
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
local pluginState = _G.LightroomMCP_State
|
|
84
|
+
|
|
85
|
+
-- Nothing in the plugin raises this any more: the LrShutdownApp and
|
|
86
|
+
-- LrShutdownPlugin hooks were removed because they cost a second or more on
|
|
87
|
+
-- every quit and bought no teardown that Lightroom's own context cancellation
|
|
88
|
+
-- did not already do. The guards stay because shutdown() below is still the
|
|
89
|
+
-- programmatic stop, and re-registering a hook is a one-line change.
|
|
90
|
+
local function shutdownRequested()
|
|
91
|
+
return pluginState.shuttingDown == true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
local function tokenDir()
|
|
95
|
+
return LrPathUtils.child(LrPathUtils.getStandardFilePath("home"), ".config")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
local function tokenFilePath()
|
|
99
|
+
return LrPathUtils.child(LrPathUtils.child(tokenDir(), "lightroom-mcp"), "token")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
local function addLog(msg)
|
|
103
|
+
table.insert(pluginState.log, os.date("%H:%M:%S") .. " - " .. msg)
|
|
104
|
+
if #pluginState.log > 100 then
|
|
105
|
+
table.remove(pluginState.log, 1)
|
|
106
|
+
end
|
|
107
|
+
Log.info(msg)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
local function generateToken()
|
|
111
|
+
-- Two UUIDs (32 hex chars each after stripping dashes) → 256 bits of entropy.
|
|
112
|
+
local u1 = LrUUID.generateUUID():gsub("-", "")
|
|
113
|
+
local u2 = LrUUID.generateUUID():gsub("-", "")
|
|
114
|
+
return (u1 .. u2):lower()
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
local function writeTokenFile(token)
|
|
118
|
+
local dir = LrPathUtils.child(tokenDir(), "lightroom-mcp")
|
|
119
|
+
LrFileUtils.createAllDirectories(dir)
|
|
120
|
+
local path = tokenFilePath()
|
|
121
|
+
local fh, openErr = io.open(path, "w")
|
|
122
|
+
if not fh then
|
|
123
|
+
addLog("Token write failed: " .. tostring(openErr))
|
|
124
|
+
return false
|
|
125
|
+
end
|
|
126
|
+
fh:write(token)
|
|
127
|
+
fh:close()
|
|
128
|
+
-- Lightroom's Lua sandbox has no os.execute, so chmod is impossible here.
|
|
129
|
+
-- On macOS single-user installs ~/.config/ inherits home-dir privacy (700).
|
|
130
|
+
-- On Linux/multi-user systems run: chmod 700 ~/.config/lightroom-mcp
|
|
131
|
+
-- See README "Security" section. Token gates localhost only; threat is
|
|
132
|
+
-- local-user access on the same machine.
|
|
133
|
+
return true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
local DISPATCH = {
|
|
137
|
+
-- Heartbeat no-op. The MCP server pings on this action every
|
|
138
|
+
-- HEARTBEAT_INTERVAL_SECONDS (server/src/index.ts) so the plugin can
|
|
139
|
+
-- tell a healthy-but-idle session apart from a genuinely dead one
|
|
140
|
+
-- without waiting out a long fixed timer. It travels through the same
|
|
141
|
+
-- auth + dispatch path as any other action, so consumeMessage's
|
|
142
|
+
-- lastRequestTime update (below) already treats it as liveness — no
|
|
143
|
+
-- separate heartbeat bookkeeping needed. See STALE_RECONNECT_SECONDS.
|
|
144
|
+
ping = function(_params) return { pong = true } end,
|
|
145
|
+
search_photos = HandlerSearch.searchPhotos,
|
|
146
|
+
list_collections = HandlerCollections.listCollections,
|
|
147
|
+
create_collection = HandlerCollections.createCollection,
|
|
148
|
+
add_to_collection = HandlerCollections.addToCollection,
|
|
149
|
+
get_photo_metadata = HandlerMetadata.getPhotoMetadata,
|
|
150
|
+
set_keywords = HandlerOrganization.setKeywords,
|
|
151
|
+
set_rating = HandlerOrganization.setRating,
|
|
152
|
+
import_photos = HandlerImport.importPhotos,
|
|
153
|
+
export_photos = HandlerExport.exportPhotos,
|
|
154
|
+
get_selected_photos = HandlerSelection.getSelectedPhotos,
|
|
155
|
+
-- Test-only, no matching MCP tool contract: only the raw TCP probe
|
|
156
|
+
-- (manual-test.mjs / the e2e playbook) can reach it. See HandlerSelection.
|
|
157
|
+
set_selection = HandlerSelection.setSelection,
|
|
158
|
+
list_develop_presets = HandlerDevelop.listDevelopPresets,
|
|
159
|
+
get_develop_preset = HandlerDevelop.getDevelopPreset,
|
|
160
|
+
compare_develop_presets = HandlerDevelop.compareDevelopPresets,
|
|
161
|
+
create_develop_preset = HandlerDevelop.createDevelopPreset,
|
|
162
|
+
export_develop_preset = HandlerDevelop.exportDevelopPreset,
|
|
163
|
+
apply_develop_preset = HandlerDevelop.applyDevelopPreset,
|
|
164
|
+
copy_develop_settings = HandlerDevelop.copyDevelopSettings,
|
|
165
|
+
set_develop_settings = HandlerDevelop.setDevelopSettings,
|
|
166
|
+
-- AI / smart adjustments (@pired/lightroom-mcp additions).
|
|
167
|
+
-- NOTE: 'ai_denoise' is the only long-running action of this group and is
|
|
168
|
+
-- covered by the server's LONG_RUNNING_TIMEOUT_MS (see index.ts).
|
|
169
|
+
ai_denoise = HandlerAI.aiDenoise,
|
|
170
|
+
set_noise_reduction = HandlerAI.setNoiseReduction,
|
|
171
|
+
set_white_balance = HandlerDevelop.setWhiteBalance,
|
|
172
|
+
set_flags = HandlerOrganization.setFlags,
|
|
173
|
+
get_spots = HandlerSpots.getSpots,
|
|
174
|
+
add_spots = HandlerSpots.addSpots,
|
|
175
|
+
clear_spots = HandlerSpots.clearSpots,
|
|
176
|
+
add_local_adjustment = HandlerLocalAdjustments.addLocalAdjustment,
|
|
177
|
+
read_local_adjustments = HandlerLocalAdjustments.readLocalAdjustments,
|
|
178
|
+
list_watermarks = HandlerWatermark.listWatermarks,
|
|
179
|
+
-- v2.0 — AI masking, tone curves, Auto commands, catalog extras
|
|
180
|
+
-- (inspired by znznzna/lightroom-cli, MIT; adapted to this plugin's
|
|
181
|
+
-- gate/verification conventions).
|
|
182
|
+
-- NOTE: 'add_ai_mask' and 'apply_auto' drive the Develop module UI and
|
|
183
|
+
-- can run long on batches; both are covered by ACTION_TIMEOUTS_MS.
|
|
184
|
+
add_ai_mask = HandlerAIMasks.addAIMask,
|
|
185
|
+
list_masks = HandlerAIMasks.listMasks,
|
|
186
|
+
remove_mask = HandlerAIMasks.removeMask,
|
|
187
|
+
set_tone_curve = HandlerDevelop.setToneCurve,
|
|
188
|
+
get_tone_curve = HandlerDevelop.getToneCurve,
|
|
189
|
+
apply_auto = HandlerDevelop.applyAuto,
|
|
190
|
+
set_color_label = HandlerOrganization.setColorLabel,
|
|
191
|
+
create_virtual_copies = HandlerOrganization.createVirtualCopies,
|
|
192
|
+
create_smart_collection = HandlerOrganization.createSmartCollection,
|
|
193
|
+
-- v3.0 — 100% functionality: preview gate, selection/navigation,
|
|
194
|
+
-- catalog inventory, batch metadata, resets, process version,
|
|
195
|
+
-- snapshots, range masks (lightroom-cli port, MIT).
|
|
196
|
+
-- NOTE: 'get_photo_preview' renders asynchronously (up to 60s inside the
|
|
197
|
+
-- handler); 'reset_develop'/'set_process_version'/'add_range_mask' drive
|
|
198
|
+
-- the Develop module — all covered by ACTION_TIMEOUTS_MS.
|
|
199
|
+
get_photo_preview = HandlerPreview.getPhotoPreview,
|
|
200
|
+
get_develop_settings = HandlerDevelop.getDevelopSettings,
|
|
201
|
+
reset_develop = HandlerDevelop.resetDevelop,
|
|
202
|
+
set_process_version = HandlerDevelop.setProcessVersion,
|
|
203
|
+
create_snapshot = HandlerDevelop.createSnapshot,
|
|
204
|
+
select_photos = HandlerSelection.selectPhotos,
|
|
205
|
+
navigate_photo = HandlerSelection.navigatePhoto,
|
|
206
|
+
get_photo_status = HandlerOrganization.getPhotoStatus,
|
|
207
|
+
batch_metadata = HandlerOrganization.batchMetadata,
|
|
208
|
+
rotate_photo = HandlerOrganization.rotatePhoto,
|
|
209
|
+
remove_from_catalog = HandlerOrganization.removeFromCatalog,
|
|
210
|
+
list_folders = HandlerCatalog.listFolders,
|
|
211
|
+
list_keywords = HandlerCatalog.listKeywords,
|
|
212
|
+
manage_view_filter = HandlerCatalog.manageViewFilter,
|
|
213
|
+
get_collection_photos = HandlerCollections.getCollectionPhotos,
|
|
214
|
+
create_collection_set = HandlerCollections.createCollectionSet,
|
|
215
|
+
add_range_mask = HandlerAIMasks.addRangeMask,
|
|
216
|
+
toggle_mask_overlay = HandlerAIMasks.toggleMaskOverlay,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
-- Generous wait so the very first response after handshake doesn't get
|
|
220
|
+
-- dropped while LrSocket is still settling sendConnected on the response
|
|
221
|
+
-- side. Must stay below the server's dispatcher timeout (30s) so a real
|
|
222
|
+
-- send-side outage still surfaces as a server-side timeout, not silent
|
|
223
|
+
-- success.
|
|
224
|
+
local SEND_WAIT_SECONDS = 25
|
|
225
|
+
-- After this many seconds of waiting for sendConnected, request a fresh
|
|
226
|
+
-- response-side rebind. Recovers from states where the response listener
|
|
227
|
+
-- ended up bound-but-clientless without responseNeedsRebind being set —
|
|
228
|
+
-- observed on Windows in issue #110, where the post-rebind onConnected
|
|
229
|
+
-- fires but sendConnected is later false by the time sendResponse runs
|
|
230
|
+
-- and no event sets the rebind flag again.
|
|
231
|
+
local SEND_REBIND_TRIGGER_SECONDS = 5
|
|
232
|
+
-- The MCP server pings every HEARTBEAT_INTERVAL_SECONDS (server/src/index.ts).
|
|
233
|
+
-- Treat the connection as stale only after missing roughly three pings in a
|
|
234
|
+
-- row, rather than the old fixed 300s idle window. This is what lets a
|
|
235
|
+
-- healthy-but-quiet interactive session run indefinitely without tripping a
|
|
236
|
+
-- full restart (Automaat review, PR #151: "idle and stale connections are
|
|
237
|
+
-- indistinguishable") while still detecting a genuinely dead peer (Windows
|
|
238
|
+
-- onClosed unreliability, issue #134) in under two minutes.
|
|
239
|
+
local HEARTBEAT_INTERVAL_SECONDS = 30
|
|
240
|
+
local STALE_RECONNECT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 3
|
|
241
|
+
-- If a request is in flight when the soft threshold above is hit, give it a
|
|
242
|
+
-- chance to finish rather than yanking the socket out from under it — but
|
|
243
|
+
-- only up to this hard cap past the soft threshold. If the peer is truly
|
|
244
|
+
-- dead the in-flight request was already unrecoverable, so the hard cap
|
|
245
|
+
-- guarantees we still restart instead of waiting forever on a hung handler.
|
|
246
|
+
-- Bounds the blast radius of a stale-triggered restart (Automaat review,
|
|
247
|
+
-- PR #151: "a tool call landing in the restart window loses its response").
|
|
248
|
+
local STALE_RESTART_HARD_CAP_SECONDS = STALE_RECONNECT_SECONDS + 30
|
|
249
|
+
|
|
250
|
+
-- Pure decision extracted from the monitor loop so it can be unit tested
|
|
251
|
+
-- without driving the full async LrTasks loop (see PluginInfoProvider_spec.lua).
|
|
252
|
+
-- Returns whether to restart and, if so, a log-suffix noting whether the
|
|
253
|
+
-- hard cap (not just the soft threshold) is what triggered it.
|
|
254
|
+
local function shouldRestartForStaleConnection(idle, inFlightRequests, softSeconds, hardCapSeconds)
|
|
255
|
+
local inFlight = (inFlightRequests or 0) > 0
|
|
256
|
+
local pastSoft = idle > softSeconds
|
|
257
|
+
local pastHard = idle > hardCapSeconds
|
|
258
|
+
local restart = pastSoft and (not inFlight or pastHard)
|
|
259
|
+
local suffix = (inFlight and pastHard) and " [hard cap, request still in flight]" or ""
|
|
260
|
+
return restart, suffix
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
local function sendResponse(response)
|
|
264
|
+
if shutdownRequested() then
|
|
265
|
+
addLog("Drop response (shutting down) id=" .. tostring(response.id))
|
|
266
|
+
return
|
|
267
|
+
end
|
|
268
|
+
local waited = 0
|
|
269
|
+
local selfHealRequested = false
|
|
270
|
+
while not pluginState.sendConnected and waited < SEND_WAIT_SECONDS do
|
|
271
|
+
if shutdownRequested() then
|
|
272
|
+
addLog("Drop response (shutting down) id=" .. tostring(response.id))
|
|
273
|
+
return
|
|
274
|
+
end
|
|
275
|
+
if not selfHealRequested and waited >= SEND_REBIND_TRIGGER_SECONDS then
|
|
276
|
+
addLog("sendResponse stalled " .. SEND_REBIND_TRIGGER_SECONDS .. "s, requesting rebind id=" .. tostring(response.id))
|
|
277
|
+
pluginState.responseNeedsRebind = true
|
|
278
|
+
selfHealRequested = true
|
|
279
|
+
end
|
|
280
|
+
LrTasks.sleep(0.1)
|
|
281
|
+
waited = waited + 0.1
|
|
282
|
+
end
|
|
283
|
+
if not pluginState.responseSocket or not pluginState.sendConnected then
|
|
284
|
+
addLog("Drop response (send socket disconnected after " .. SEND_WAIT_SECONDS .. "s) id=" .. tostring(response.id))
|
|
285
|
+
return
|
|
286
|
+
end
|
|
287
|
+
local ok, payload = pcall(function() return JSON:encode(response) end)
|
|
288
|
+
if not ok then
|
|
289
|
+
addLog("JSON encode failed: " .. tostring(payload))
|
|
290
|
+
return
|
|
291
|
+
end
|
|
292
|
+
pluginState.responseSocket:send(payload .. "\n")
|
|
293
|
+
pluginState.requestsProcessed = pluginState.requestsProcessed + 1
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
local function dispatchAction(request)
|
|
297
|
+
if shutdownRequested() then return end
|
|
298
|
+
local id = request.id
|
|
299
|
+
local action = request.action
|
|
300
|
+
local params = request.params or {}
|
|
301
|
+
|
|
302
|
+
if request.undecodable then
|
|
303
|
+
addLog("Rejecting undecodable request id=" .. tostring(id))
|
|
304
|
+
sendResponse({ id = id, error = "Malformed request: " .. request.undecodable })
|
|
305
|
+
return
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
-- Heartbeat pings arrive every 30s and are pure liveness noise once the
|
|
309
|
+
-- connection is healthy; skip them here so they don't dominate the
|
|
310
|
+
-- 100-line ring buffer used by the status panel and drown out real
|
|
311
|
+
-- request activity.
|
|
312
|
+
local isHeartbeat = (action == "ping")
|
|
313
|
+
if not isHeartbeat then
|
|
314
|
+
addLog("Request id=" .. tostring(id) .. " action=" .. tostring(action))
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
-- Tracked so the stale-connection monitor can defer a restart while a
|
|
318
|
+
-- real request is in flight. Held through sendResponse (not just the
|
|
319
|
+
-- handler call) so the monitor can't yank the socket while a response
|
|
320
|
+
-- is still queued waiting on sendConnected (see STALE_RESTART_HARD_CAP_SECONDS).
|
|
321
|
+
pluginState.inFlightRequests = (pluginState.inFlightRequests or 0) + 1
|
|
322
|
+
|
|
323
|
+
local handler = DISPATCH[action]
|
|
324
|
+
if not handler then
|
|
325
|
+
sendResponse({ id = id, error = "Unknown action: " .. tostring(action) })
|
|
326
|
+
pluginState.inFlightRequests = math.max(0, pluginState.inFlightRequests - 1)
|
|
327
|
+
return
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
-- xpcall, debug.traceback, and os.getenv aren't reliably exposed by
|
|
331
|
+
-- Lightroom's Lua sandbox: using them in the dispatcher's error path
|
|
332
|
+
-- turns a handler error into a silent nil-call that never reaches the
|
|
333
|
+
-- client. Stick to LrTasks.pcall.
|
|
334
|
+
local execOk, resultOrErr = LrTasks.pcall(function()
|
|
335
|
+
return handler(params)
|
|
336
|
+
end)
|
|
337
|
+
if execOk then
|
|
338
|
+
sendResponse({ id = id, result = resultOrErr })
|
|
339
|
+
else
|
|
340
|
+
addLog("Handler " .. action .. " error: " .. tostring(resultOrErr))
|
|
341
|
+
sendResponse({ id = id, error = tostring(resultOrErr) })
|
|
342
|
+
end
|
|
343
|
+
pluginState.inFlightRequests = math.max(0, pluginState.inFlightRequests - 1)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
-- Runs SYNCHRONOUSLY in onMessage. Every request must carry the current
|
|
347
|
+
-- token in `hello`; we authenticate per-message so connection-state
|
|
348
|
+
-- races (reload, dual-instance, reconnect) can't desync auth from the
|
|
349
|
+
-- live token.
|
|
350
|
+
local function consumeMessage(message)
|
|
351
|
+
pluginState.lastEvent = os.date("%H:%M:%S")
|
|
352
|
+
pluginState.lastRequestTime = os.time() -- track for stale connection detection; a
|
|
353
|
+
-- heartbeat ping counts as activity same as
|
|
354
|
+
-- any other message.
|
|
355
|
+
|
|
356
|
+
local parsedOk, request = pcall(function() return JSON:decode(message) end)
|
|
357
|
+
if not parsedOk or type(request) ~= "table" then
|
|
358
|
+
addLog("JSON decode failed: " .. tostring(message))
|
|
359
|
+
-- A client that cannot be decoded still deserves an answer, or it sits
|
|
360
|
+
-- there until its own 30s timeout with no clue why. Salvage id+token by
|
|
361
|
+
-- pattern: only an authenticated caller gets the error back, so
|
|
362
|
+
-- unauthenticated garbage stays silent as before.
|
|
363
|
+
local salvagedToken = message:match('"hello"%s*:%s*"([^"]*)"')
|
|
364
|
+
local salvagedId = message:match('"id"%s*:%s*"([^"]*)"')
|
|
365
|
+
if salvagedId and pluginState.token and salvagedToken == pluginState.token then
|
|
366
|
+
return { id = salvagedId, undecodable = tostring(request) }
|
|
367
|
+
end
|
|
368
|
+
return nil
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
if not pluginState.token or request.hello ~= pluginState.token then
|
|
372
|
+
-- Drop silently. We CANNOT call sendResponse here: onMessage runs
|
|
373
|
+
-- in a non-yielding context and sendResponse uses LrTasks.sleep.
|
|
374
|
+
-- Server will time out, which is correct behaviour for auth fail.
|
|
375
|
+
addLog("Auth failed (token mismatch) id=" .. tostring(request.id))
|
|
376
|
+
return nil
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
return request
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
local function startServer()
|
|
383
|
+
if shutdownRequested() then
|
|
384
|
+
addLog("Start ignored (shutting down)")
|
|
385
|
+
return
|
|
386
|
+
end
|
|
387
|
+
if pluginState.running then
|
|
388
|
+
addLog("Already running")
|
|
389
|
+
return
|
|
390
|
+
end
|
|
391
|
+
-- Set running immediately after the guard so check-and-set is atomic.
|
|
392
|
+
-- generateToken/writeTokenFile/readPortPrefs below can yield the
|
|
393
|
+
-- cooperative LrTasks scheduler (token file I/O), and a second caller
|
|
394
|
+
-- waking in that window would otherwise pass the guard too and bind a
|
|
395
|
+
-- second pair of LrSocket listeners on the same ports.
|
|
396
|
+
pluginState.running = true
|
|
397
|
+
|
|
398
|
+
pluginState.token = generateToken()
|
|
399
|
+
if writeTokenFile(pluginState.token) then
|
|
400
|
+
addLog("Token written to " .. tokenFilePath())
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
local requestPort, responsePort = readPortPrefs()
|
|
404
|
+
pluginState.requestPort = requestPort
|
|
405
|
+
pluginState.responsePort = responsePort
|
|
406
|
+
|
|
407
|
+
-- Tag this invocation. resetForReload reuses the same _G state table in
|
|
408
|
+
-- place, so a prior instance's async context-cleanup handler (registered
|
|
409
|
+
-- below) and this fresh start share one table. If that old cleanup fires
|
|
410
|
+
-- AFTER we rebind here it would close the new sockets and wipe the new
|
|
411
|
+
-- token, leaving a dead-but-running server (issues #121, #137). The
|
|
412
|
+
-- handler captures this id and skips teardown once superseded (mirrors
|
|
413
|
+
-- responseGen). Bumped in the synchronous prologue so the id is live
|
|
414
|
+
-- before the old cleanup can interleave with the new binds.
|
|
415
|
+
pluginState.instanceId = (pluginState.instanceId or 0) + 1
|
|
416
|
+
local instanceId = pluginState.instanceId
|
|
417
|
+
addLog("Starting LrSocket servers")
|
|
418
|
+
|
|
419
|
+
LrFunctionContext.postAsyncTaskWithContext("LightroomMCPServer", function(context)
|
|
420
|
+
context:addCleanupHandler(function()
|
|
421
|
+
if pluginState.instanceId ~= instanceId then
|
|
422
|
+
-- A newer startServer superseded this instance; its sockets
|
|
423
|
+
-- and token now own the shared state table. Tearing them down
|
|
424
|
+
-- here would kill the live server, so leave them be.
|
|
425
|
+
addLog("Stale cleanup skipped (instance " .. instanceId .. " superseded)")
|
|
426
|
+
return
|
|
427
|
+
end
|
|
428
|
+
addLog("Server task context cleanup")
|
|
429
|
+
if pluginState.requestSocket then
|
|
430
|
+
pcall(function() pluginState.requestSocket:close() end)
|
|
431
|
+
end
|
|
432
|
+
if pluginState.responseSocket then
|
|
433
|
+
pcall(function() pluginState.responseSocket:close() end)
|
|
434
|
+
end
|
|
435
|
+
pluginState.requestSocket = nil
|
|
436
|
+
pluginState.responseSocket = nil
|
|
437
|
+
pluginState.sendConnected = false
|
|
438
|
+
pluginState.receiveConnected = false
|
|
439
|
+
pluginState.token = nil
|
|
440
|
+
end)
|
|
441
|
+
|
|
442
|
+
local function bindRequest()
|
|
443
|
+
return LrSocket.bind {
|
|
444
|
+
functionContext = context,
|
|
445
|
+
plugin = _PLUGIN,
|
|
446
|
+
port = requestPort,
|
|
447
|
+
mode = "receive",
|
|
448
|
+
onConnected = function()
|
|
449
|
+
pluginState.receiveConnected = true
|
|
450
|
+
pluginState.lastConnectedTime = os.time()
|
|
451
|
+
-- A stale lastRequestTime from a prior session (e.g. user
|
|
452
|
+
-- Stop/Start after the connection sat idle >90s) must not
|
|
453
|
+
-- leak into this connection's idle clock, or the monitor
|
|
454
|
+
-- loop sees a huge idle value on the very first tick and
|
|
455
|
+
-- restarts immediately. Idle now starts from
|
|
456
|
+
-- lastConnectedTime until the first real message arrives.
|
|
457
|
+
pluginState.lastRequestTime = nil
|
|
458
|
+
if pluginState.freshRestart then
|
|
459
|
+
-- Sockets were fully closed and rebound by the stale-detection
|
|
460
|
+
-- restart (issue #134 Windows workaround). The response listener
|
|
461
|
+
-- already accepted a fresh MCP client; no stale send-side
|
|
462
|
+
-- connection to flush. Skipping the rebind prevents the
|
|
463
|
+
-- request->response->request cycling that delays the first
|
|
464
|
+
-- post-restart response past the 30 s MCP timeout.
|
|
465
|
+
pluginState.freshRestart = false
|
|
466
|
+
addLog("REQUEST socket connected (post-restart)")
|
|
467
|
+
else
|
|
468
|
+
-- New request client on a live server = new MCP session. Force
|
|
469
|
+
-- a response-side rebind: LrSocket send-mode does not reliably
|
|
470
|
+
-- notice client disconnect on Windows, so sendConnected can stay
|
|
471
|
+
-- true pointing at a dead socket and :send() writes to the void.
|
|
472
|
+
pluginState.sendConnected = false
|
|
473
|
+
pluginState.responseNeedsRebind = true
|
|
474
|
+
addLog("REQUEST socket connected")
|
|
475
|
+
end
|
|
476
|
+
end,
|
|
477
|
+
onMessage = function(_, message)
|
|
478
|
+
if shutdownRequested() then return end
|
|
479
|
+
local request = consumeMessage(message)
|
|
480
|
+
if request then
|
|
481
|
+
LrTasks.startAsyncTask(function()
|
|
482
|
+
dispatchAction(request)
|
|
483
|
+
end)
|
|
484
|
+
end
|
|
485
|
+
end,
|
|
486
|
+
onClosed = function()
|
|
487
|
+
pluginState.receiveConnected = false
|
|
488
|
+
pluginState.requestNeedsReconnect = true
|
|
489
|
+
addLog("REQUEST socket closed (client disconnected)")
|
|
490
|
+
end,
|
|
491
|
+
onError = function(_, err)
|
|
492
|
+
local errStr = tostring(err)
|
|
493
|
+
if isNoClientError(errStr) then
|
|
494
|
+
if not pluginState.receiveConnected then
|
|
495
|
+
pluginState.requestNeedsReconnect = true
|
|
496
|
+
end
|
|
497
|
+
else
|
|
498
|
+
pluginState.receiveConnected = false
|
|
499
|
+
pluginState.requestNeedsReconnect = true
|
|
500
|
+
addLog("REQUEST socket error: " .. errStr)
|
|
501
|
+
end
|
|
502
|
+
end,
|
|
503
|
+
}
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
-- Each rebind bumps a generation. Old-listener callbacks compare
|
|
507
|
+
-- their captured gen to the live one and ignore themselves if
|
|
508
|
+
-- stale. Without this, an onError/onClosed from the just-closed
|
|
509
|
+
-- listener can flag rebind AGAIN immediately after we just
|
|
510
|
+
-- finished rebinding, looping us out of the new client.
|
|
511
|
+
pluginState.responseGen = 0
|
|
512
|
+
-- Clear loop-control flags so a reused state table (in-place
|
|
513
|
+
-- resetForReload, or a panel Stop->Start) doesn't enter the monitor
|
|
514
|
+
-- loop with a stale reconnect/rebind pending and churn the sockets we
|
|
515
|
+
-- just bound on the first tick.
|
|
516
|
+
pluginState.requestNeedsReconnect = false
|
|
517
|
+
pluginState.responseNeedsRebind = false
|
|
518
|
+
pluginState.responseNeedsReconnect = false
|
|
519
|
+
|
|
520
|
+
-- bindResponse takes myGen explicitly so callers can pre-bump the
|
|
521
|
+
-- generation BEFORE calling :close() on the prior listener. On
|
|
522
|
+
-- platforms where LrSocket invokes onClosed synchronously during
|
|
523
|
+
-- close (suspected on Windows, per issue #110), a stale callback
|
|
524
|
+
-- would otherwise see isLive()==true and re-set responseNeedsRebind
|
|
525
|
+
-- after we just cleared it.
|
|
526
|
+
local function bindResponse(myGen)
|
|
527
|
+
local function isLive() return pluginState.responseGen == myGen end
|
|
528
|
+
return LrSocket.bind {
|
|
529
|
+
functionContext = context,
|
|
530
|
+
plugin = _PLUGIN,
|
|
531
|
+
port = responsePort,
|
|
532
|
+
mode = "send",
|
|
533
|
+
onConnected = function()
|
|
534
|
+
if not isLive() then return end
|
|
535
|
+
pluginState.sendConnected = true
|
|
536
|
+
addLog("RESPONSE socket connected")
|
|
537
|
+
end,
|
|
538
|
+
onClosed = function()
|
|
539
|
+
if not isLive() then return end
|
|
540
|
+
pluginState.sendConnected = false
|
|
541
|
+
pluginState.responseNeedsRebind = true
|
|
542
|
+
addLog("RESPONSE socket closed (gen=" .. myGen .. ")")
|
|
543
|
+
end,
|
|
544
|
+
onError = function(_, err)
|
|
545
|
+
if not isLive() then return end
|
|
546
|
+
local errStr = tostring(err)
|
|
547
|
+
if isNoClientError(errStr) then
|
|
548
|
+
if not pluginState.sendConnected then
|
|
549
|
+
pluginState.responseNeedsReconnect = true
|
|
550
|
+
end
|
|
551
|
+
else
|
|
552
|
+
pluginState.sendConnected = false
|
|
553
|
+
pluginState.responseNeedsRebind = true
|
|
554
|
+
addLog("RESPONSE socket error: " .. errStr)
|
|
555
|
+
end
|
|
556
|
+
end,
|
|
557
|
+
}
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
-- Bump first, then bind, so the initial listener owns gen=1 (any
|
|
561
|
+
-- pre-existing stale callbacks from a Reload Plug-in cycle were
|
|
562
|
+
-- bound against gen=0 and stay ignored).
|
|
563
|
+
pluginState.responseGen = pluginState.responseGen + 1
|
|
564
|
+
pluginState.requestSocket = bindRequest()
|
|
565
|
+
addLog("REQUEST bound on " .. requestPort)
|
|
566
|
+
pluginState.responseSocket = bindResponse(pluginState.responseGen)
|
|
567
|
+
addLog("RESPONSE bound on " .. responsePort .. " gen=" .. pluginState.responseGen)
|
|
568
|
+
|
|
569
|
+
while pluginState.running and not shutdownRequested() do
|
|
570
|
+
if pluginState.requestNeedsReconnect and pluginState.requestSocket then
|
|
571
|
+
pluginState.requestNeedsReconnect = false
|
|
572
|
+
pluginState.requestSocket:reconnect()
|
|
573
|
+
end
|
|
574
|
+
-- Response socket has two recovery paths:
|
|
575
|
+
-- - rebind: full close+rebind for true client disconnect
|
|
576
|
+
-- - reconnect: cheap reconnect for listen-side timeouts
|
|
577
|
+
if pluginState.responseNeedsRebind then
|
|
578
|
+
-- Pre-bump gen BEFORE close so any synchronous onClosed
|
|
579
|
+
-- callback fired during close() sees isLive()==false and
|
|
580
|
+
-- ignores itself. Without this, the OLD listener's close
|
|
581
|
+
-- callback re-sets responseNeedsRebind after we clear it
|
|
582
|
+
-- below, looping the rebind on the next tick. Issue #110
|
|
583
|
+
-- suspected Windows trigger.
|
|
584
|
+
pluginState.responseGen = pluginState.responseGen + 1
|
|
585
|
+
local newGen = pluginState.responseGen
|
|
586
|
+
if pluginState.responseSocket then
|
|
587
|
+
pcall(function() pluginState.responseSocket:close() end)
|
|
588
|
+
end
|
|
589
|
+
pluginState.sendConnected = false
|
|
590
|
+
-- Brief yield so any kernel cleanup of the just-closed
|
|
591
|
+
-- listener completes before we try to bind the same port
|
|
592
|
+
-- again. The actual server-side reconnect takes ~1s, so
|
|
593
|
+
-- 100ms here doesn't meaningfully delay recovery.
|
|
594
|
+
LrTasks.sleep(0.1)
|
|
595
|
+
if shutdownRequested() then break end
|
|
596
|
+
pluginState.responseSocket = bindResponse(newGen)
|
|
597
|
+
pluginState.responseNeedsRebind = false
|
|
598
|
+
pluginState.responseNeedsReconnect = false
|
|
599
|
+
addLog("RESPONSE rebound on " .. responsePort .. " gen=" .. newGen)
|
|
600
|
+
elseif pluginState.responseNeedsReconnect and pluginState.responseSocket then
|
|
601
|
+
pluginState.responseNeedsReconnect = false
|
|
602
|
+
pluginState.responseSocket:reconnect()
|
|
603
|
+
end
|
|
604
|
+
-- Full restart requested by stale detection
|
|
605
|
+
if pluginState.needsFullRestart then
|
|
606
|
+
pluginState.needsFullRestart = false
|
|
607
|
+
LrTasks.startAsyncTask(function()
|
|
608
|
+
addLog("Restarting server (stale connection recovery)")
|
|
609
|
+
-- stopServer() is declared after startServer() in this file
|
|
610
|
+
-- and is not an upvalue here; inline its effect directly.
|
|
611
|
+
pluginState.running = false
|
|
612
|
+
LrTasks.sleep(0.5)
|
|
613
|
+
startServer()
|
|
614
|
+
end)
|
|
615
|
+
end
|
|
616
|
+
-- Stale connection detection: Windows LrSocket does not reliably
|
|
617
|
+
-- fire onClosed when the remote MCP server process exits (issue #134).
|
|
618
|
+
-- The MCP server pings every HEARTBEAT_INTERVAL_SECONDS, so as long as
|
|
619
|
+
-- the connection is genuinely alive, lastRequestTime keeps advancing
|
|
620
|
+
-- even with no real tool calls in flight. If receiveConnected is true
|
|
621
|
+
-- but nothing (including a heartbeat) has arrived for
|
|
622
|
+
-- STALE_RECONNECT_SECONDS, the peer is actually gone, not just idle.
|
|
623
|
+
-- Defer past that soft threshold while a real request is in flight,
|
|
624
|
+
-- up to STALE_RESTART_HARD_CAP_SECONDS, so we don't yank the socket
|
|
625
|
+
-- out from under a response that's about to be sent.
|
|
626
|
+
if pluginState.receiveConnected then
|
|
627
|
+
local ref = pluginState.lastRequestTime or pluginState.lastConnectedTime
|
|
628
|
+
if ref then
|
|
629
|
+
local idle = os.time() - ref
|
|
630
|
+
local restart, suffix = shouldRestartForStaleConnection(
|
|
631
|
+
idle, pluginState.inFlightRequests, STALE_RECONNECT_SECONDS, STALE_RESTART_HARD_CAP_SECONDS)
|
|
632
|
+
if restart then
|
|
633
|
+
addLog("Stale connection: " .. idle .. "s since last heartbeat, scheduling restart" .. suffix)
|
|
634
|
+
pluginState.lastRequestTime = nil
|
|
635
|
+
pluginState.lastConnectedTime = nil
|
|
636
|
+
pluginState.needsFullRestart = true
|
|
637
|
+
pluginState.freshRestart = true
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
LrTasks.sleep(0.2)
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
addLog("Server loop exiting")
|
|
645
|
+
-- Socket cleanup runs in context:addCleanupHandler above.
|
|
646
|
+
end)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
local function startServerFromPanel()
|
|
650
|
+
pluginState.shuttingDown = false
|
|
651
|
+
startServer()
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
local function stopServer()
|
|
655
|
+
if not pluginState.running then
|
|
656
|
+
addLog("Not running")
|
|
657
|
+
return
|
|
658
|
+
end
|
|
659
|
+
addLog("Stopping LrSocket servers")
|
|
660
|
+
pluginState.running = false
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
-- Called by PluginInit on plugin load/reload (never on a Plug-in Manager
|
|
664
|
+
-- render). Reload re-runs PluginInit while a prior instance's state may
|
|
665
|
+
-- still live on _G in the same Lua state, with `running` stale-true and
|
|
666
|
+
-- its task context already cancelled by Lightroom. Clear the flag so the
|
|
667
|
+
-- subsequent startServer() isn't blocked by its "Already running" guard,
|
|
668
|
+
-- and signal any surviving monitor loop to exit. Reset IN PLACE (not a new
|
|
669
|
+
-- table) so this module's pluginState and the old loop's closure keep
|
|
670
|
+
-- pointing at the same table — flipping running here is what stops it.
|
|
671
|
+
local function resetForReload()
|
|
672
|
+
pluginState.shuttingDown = false
|
|
673
|
+
if not pluginState.running then return end
|
|
674
|
+
addLog("Reload detected - resetting previous server instance")
|
|
675
|
+
pluginState.running = false
|
|
676
|
+
if pluginState.requestSocket then
|
|
677
|
+
pcall(function() pluginState.requestSocket:close() end)
|
|
678
|
+
end
|
|
679
|
+
if pluginState.responseSocket then
|
|
680
|
+
pcall(function() pluginState.responseSocket:close() end)
|
|
681
|
+
end
|
|
682
|
+
pluginState.requestSocket = nil
|
|
683
|
+
pluginState.responseSocket = nil
|
|
684
|
+
pluginState.sendConnected = false
|
|
685
|
+
pluginState.receiveConnected = false
|
|
686
|
+
pluginState.token = nil
|
|
687
|
+
-- Return the rest of the transient runtime state to fresh-state defaults
|
|
688
|
+
-- so the Plug-in Manager reports honest status after a reload (no
|
|
689
|
+
-- carried-over lastEvent / counters / ports) and the next startServer
|
|
690
|
+
-- can't inherit a stale reconnect/rebind request. instanceId is
|
|
691
|
+
-- deliberately NOT reset -- it must keep advancing so a superseded
|
|
692
|
+
-- instance's cleanup handler stays a no-op (see startServer).
|
|
693
|
+
pluginState.requestNeedsReconnect = false
|
|
694
|
+
pluginState.responseNeedsRebind = false
|
|
695
|
+
pluginState.responseNeedsReconnect = false
|
|
696
|
+
pluginState.lastEvent = nil
|
|
697
|
+
pluginState.requestsProcessed = 0
|
|
698
|
+
pluginState.requestPort = nil
|
|
699
|
+
pluginState.responsePort = nil
|
|
700
|
+
pluginState.lastRequestTime = nil
|
|
701
|
+
pluginState.lastConnectedTime = nil
|
|
702
|
+
pluginState.needsFullRestart = false
|
|
703
|
+
pluginState.freshRestart = false
|
|
704
|
+
pluginState.inFlightRequests = 0
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
local function shutdown()
|
|
708
|
+
pluginState.shuttingDown = true
|
|
709
|
+
pluginState.running = false
|
|
710
|
+
pluginState.requestNeedsReconnect = false
|
|
711
|
+
pluginState.responseNeedsRebind = false
|
|
712
|
+
pluginState.responseNeedsReconnect = false
|
|
713
|
+
pluginState.needsFullRestart = false
|
|
714
|
+
pluginState.freshRestart = false
|
|
715
|
+
pluginState.sendConnected = false
|
|
716
|
+
pluginState.receiveConnected = false
|
|
717
|
+
pluginState.token = nil
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
addLog("PluginInfoProvider loaded")
|
|
721
|
+
|
|
722
|
+
local PluginInfoProvider = {
|
|
723
|
+
startServer = startServer,
|
|
724
|
+
startServerFromPanel = startServerFromPanel,
|
|
725
|
+
stopServer = stopServer,
|
|
726
|
+
shutdown = shutdown,
|
|
727
|
+
resetForReload = resetForReload,
|
|
728
|
+
-- Exposed for PluginInfoProvider_spec.lua only; not used elsewhere in the plugin.
|
|
729
|
+
shouldRestartForStaleConnection = shouldRestartForStaleConnection,
|
|
730
|
+
handlePing = DISPATCH.ping,
|
|
731
|
+
HEARTBEAT_INTERVAL_SECONDS = HEARTBEAT_INTERVAL_SECONDS,
|
|
732
|
+
STALE_RECONNECT_SECONDS = STALE_RECONNECT_SECONDS,
|
|
733
|
+
STALE_RESTART_HARD_CAP_SECONDS = STALE_RESTART_HARD_CAP_SECONDS,
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function PluginInfoProvider.sectionsForTopOfDialog(f, propertyTable)
|
|
737
|
+
local prefs = LrPrefs.prefsForPlugin()
|
|
738
|
+
if prefs.autoStartServer == nil then
|
|
739
|
+
prefs.autoStartServer = true
|
|
740
|
+
end
|
|
741
|
+
propertyTable.autoStartServer = prefs.autoStartServer
|
|
742
|
+
propertyTable:addObserver('autoStartServer', function(_, _, value)
|
|
743
|
+
prefs.autoStartServer = value
|
|
744
|
+
end)
|
|
745
|
+
|
|
746
|
+
local cfgRequestPort, cfgResponsePort = readPortPrefs()
|
|
747
|
+
propertyTable.requestPort = cfgRequestPort
|
|
748
|
+
propertyTable.responsePort = cfgResponsePort
|
|
749
|
+
propertyTable:addObserver('requestPort', function(_, _, value)
|
|
750
|
+
local n = tonumber(value)
|
|
751
|
+
if validPort(n) then prefs.requestPort = n end
|
|
752
|
+
end)
|
|
753
|
+
propertyTable:addObserver('responsePort', function(_, _, value)
|
|
754
|
+
local n = tonumber(value)
|
|
755
|
+
if validPort(n) then prefs.responsePort = n end
|
|
756
|
+
end)
|
|
757
|
+
|
|
758
|
+
local activeRequest = pluginState.requestPort or cfgRequestPort
|
|
759
|
+
local activeResponse = pluginState.responsePort or cfgResponsePort
|
|
760
|
+
|
|
761
|
+
local statusText = "=== Lightroom MCP Status ===\n\n"
|
|
762
|
+
statusText = statusText .. "Running: " .. tostring(pluginState.running) .. "\n"
|
|
763
|
+
statusText = statusText .. "Request socket connected: " .. tostring(pluginState.receiveConnected) .. "\n"
|
|
764
|
+
statusText = statusText .. "Response socket connected: " .. tostring(pluginState.sendConnected) .. "\n"
|
|
765
|
+
statusText = statusText .. "Last event: " .. (pluginState.lastEvent or "Never") .. "\n"
|
|
766
|
+
statusText = statusText .. "Requests processed: " .. pluginState.requestsProcessed .. "\n"
|
|
767
|
+
statusText = statusText .. "Request port: " .. activeRequest .. " (mode=receive)\n"
|
|
768
|
+
statusText = statusText .. "Response port: " .. activeResponse .. " (mode=send)\n"
|
|
769
|
+
statusText = statusText .. "Log file: " .. (Log.filePath() or "(unavailable)") .. "\n"
|
|
770
|
+
statusText = statusText .. "\nRecent logs:\n"
|
|
771
|
+
local startIdx = math.max(1, #pluginState.log - 15)
|
|
772
|
+
for i = startIdx, #pluginState.log do
|
|
773
|
+
statusText = statusText .. " " .. pluginState.log[i] .. "\n"
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
{
|
|
778
|
+
title = "Lightroom MCP Server Status",
|
|
779
|
+
f:static_text {
|
|
780
|
+
title = statusText,
|
|
781
|
+
fill_horizontal = 1,
|
|
782
|
+
width_in_chars = 70,
|
|
783
|
+
height_in_lines = 25,
|
|
784
|
+
},
|
|
785
|
+
f:checkbox {
|
|
786
|
+
title = "Auto-start server on Lightroom launch",
|
|
787
|
+
value = LrView.bind('autoStartServer'),
|
|
788
|
+
},
|
|
789
|
+
f:row {
|
|
790
|
+
f:static_text { title = "Request port:", width = 110 },
|
|
791
|
+
f:edit_field {
|
|
792
|
+
value = LrView.bind('requestPort'),
|
|
793
|
+
width_in_chars = 7,
|
|
794
|
+
precision = 0,
|
|
795
|
+
min = 1,
|
|
796
|
+
max = 65535,
|
|
797
|
+
},
|
|
798
|
+
f:static_text { title = "(default 58763)" },
|
|
799
|
+
},
|
|
800
|
+
f:row {
|
|
801
|
+
f:static_text { title = "Response port:", width = 110 },
|
|
802
|
+
f:edit_field {
|
|
803
|
+
value = LrView.bind('responsePort'),
|
|
804
|
+
width_in_chars = 7,
|
|
805
|
+
precision = 0,
|
|
806
|
+
min = 1,
|
|
807
|
+
max = 65535,
|
|
808
|
+
},
|
|
809
|
+
f:static_text { title = "(default 58764)" },
|
|
810
|
+
},
|
|
811
|
+
f:static_text {
|
|
812
|
+
title = "Port changes apply on next Start. Server env vars must match: LIGHTROOM_MCP_REQUEST_PORT / LIGHTROOM_MCP_RESPONSE_PORT.",
|
|
813
|
+
fill_horizontal = 1,
|
|
814
|
+
width_in_chars = 70,
|
|
815
|
+
height_in_lines = 2,
|
|
816
|
+
},
|
|
817
|
+
f:row {
|
|
818
|
+
-- Two buttons rather than one toggle. The toggle's title was
|
|
819
|
+
-- computed once at render, so after Stop it still read "Stop
|
|
820
|
+
-- Server" and clicking it only logged "Not running" -- there was
|
|
821
|
+
-- no way to start again without closing and reopening the
|
|
822
|
+
-- dialog. A bound title does not work here: Lightroom ignores a
|
|
823
|
+
-- binding on a push_button title.
|
|
824
|
+
f:push_button {
|
|
825
|
+
title = "Start Server",
|
|
826
|
+
action = function()
|
|
827
|
+
if pluginState.running then
|
|
828
|
+
addLog("Start ignored (already running)")
|
|
829
|
+
return
|
|
830
|
+
end
|
|
831
|
+
startServerFromPanel()
|
|
832
|
+
end,
|
|
833
|
+
},
|
|
834
|
+
f:push_button {
|
|
835
|
+
title = "Stop Server",
|
|
836
|
+
action = function()
|
|
837
|
+
stopServer()
|
|
838
|
+
end,
|
|
839
|
+
},
|
|
840
|
+
f:push_button {
|
|
841
|
+
title = "Show Status",
|
|
842
|
+
action = function()
|
|
843
|
+
local lines = {
|
|
844
|
+
"Running: " .. tostring(pluginState.running),
|
|
845
|
+
"Request socket connected: " .. tostring(pluginState.receiveConnected),
|
|
846
|
+
"Response socket connected: " .. tostring(pluginState.sendConnected),
|
|
847
|
+
"Last event: " .. (pluginState.lastEvent or "Never"),
|
|
848
|
+
"Requests processed: " .. pluginState.requestsProcessed,
|
|
849
|
+
"Log file: " .. (Log.filePath() or "(unavailable)"),
|
|
850
|
+
"",
|
|
851
|
+
"Recent logs:",
|
|
852
|
+
}
|
|
853
|
+
local logStart = math.max(1, #pluginState.log - 30)
|
|
854
|
+
for i = logStart, #pluginState.log do
|
|
855
|
+
table.insert(lines, " " .. pluginState.log[i])
|
|
856
|
+
end
|
|
857
|
+
LrDialogs.message("Lightroom MCP Status", table.concat(lines, "\n"), "info")
|
|
858
|
+
end,
|
|
859
|
+
},
|
|
860
|
+
},
|
|
861
|
+
},
|
|
862
|
+
}
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
return PluginInfoProvider
|