- McpToolsSection: add the asset_gen -> "Asset Gen" group display name so
the tool-group tab no longer falls back to the raw "Asset_gen".
- Update the asset_gen group blurb (registry + CLI) to include audio gen.
- Regenerate the tool reference docs from the Python registry: add the
missing generate_audio.md and refresh the asset_gen landscape/index.
Claude-Session: https://claude.ai/code/session_015KYy51gwBuhDuLZXXoqc98
Security (from a dynamic security audit of the branch):
- H1: UnityWebRequestTransport disables auto-redirect on auth-bearing
requests (redirectLimit=0) so a provider 3xx can't re-send the API key
to a redirect host.
- H2/P8: per-kind result-extension allowlist in AssetGenJobManager.WriteFile
(+ defense-in-depth in the audio/image import pipelines) — a provider
can no longer land a .cs/.asmdef/.meta/.asset under Assets/ (Editor RCE).
- H3: ProviderHttp.RequireHost pins the submit URL and the provider-supplied
response_url to https://queue.fal.run before the fal key is attached
(both fal image + audio adapters).
Correctness (from code review):
- C1: Tripo image->3D now sends model_version.
- C2/C3/C6/C10: FalAudioAdapter.BuildBody is catalog-driven — duration-
required models (CassetteAI SFX/Music, Stable Audio) send a default
duration when the caller passes 0 (fixes the default-input 422),
fractional durations floor to >=1, Lyria stays prompt-only and its GUI
no longer advertises a duration it ignores, and the clamp ceilings come
from the catalog (no more duplicated 190/30/180).
- C4: an unmapped fal poll status now fails fast instead of polling to the
600s timeout (both fal adapters).
- C5: a stale/invalid selected-model pref is cleared on dropdown fallback.
- C7: the audio fal-key status refreshes when the shared 2D fal key changes.
Cleanup: extract AssetGenModelCatalog.ResolveModel (dedupes the model-
resolution chain across the three generate tools) + DefaultModelId no-alloc.
Verified: full EditMode suite 1166 tests, 0 failures (+19 new regression
tests); 34 Python asset-gen tests pass.
Claude-Session: https://claude.ai/code/session_015KYy51gwBuhDuLZXXoqc98
Wraps each category (3D Models / 2D Images / Sound) in its own darker rounded panel
so they read as distinct blocks, and moves the per-provider key status (saved/not set)
up into the header row to the right of the provider name — reclaiming a line per
provider. Verified live: 3 panels (bg alpha 0.20), status inline in each header.
Claude-Session: https://claude.ai/code/session_01GCxmdd4qo7MG6J4M6WcT9Y
The provider row is a vertical (column) container, so the setting-dropdown-inline
class's flex-grow:1 stretched the DropdownField vertically. Wrap the Model dropdown
in a horizontal .setting-row with a .setting-label (matching the Format row) and use
a label-less DropdownField. Verified live: all 5 model dropdowns now render at 18px
inside setting-row containers instead of ~400px boxes.
Claude-Session: https://claude.ai/code/session_01GCxmdd4qo7MG6J4M6WcT9Y
Phases 4-5. Extends the existing Asset Gen tab (no new window): per-provider
'Model' dropdowns on the image + 3D rows with price/duration/use-case metadata, a
fal audio row (no key field — reuses the shared fal key) with a model dropdown and
the Stable Audio license caveat, and a Refresh button that re-validates key
presence + the curated catalog. Selecting a model writes the per-(kind,provider)
pref that generate_* reads as its default. fal has no public list-models API, so
Refresh is a curated re-validate (the plan's gated fallback), never a network fetch.
Characterization tests assert the new builders + three-phase lifecycle.
Claude-Session: https://claude.ai/code/session_01GCxmdd4qo7MG6J4M6WcT9Y
Two related stdio connection-UI reliability bugs surfaced while testing
with a live domain-reload cycle:
1. Start Session race (severe). StdioTransportClient.StartAsync returned
true unconditionally, then callers immediately verified — but
VerifyAsync only reads StdioBridgeHost.IsRunning, which is still false
while the previous port releases after a reload (Start() defers the
bind to an editor-idle retry, or falls back to a new port after
BusyPortFallbackWindowSeconds). Result: "Connection verification
failed: Bridge not running", and Start Session only connected after
several clicks. StartAsync now waits (bounded, ReadyWaitTimeoutSeconds)
for the bridge to actually bind before reporting success.
2. Health-indicator flash. VerifyBridgeConnectionInternalAsync flipped the
indicator to Unhealthy on a single transient verify miss during a
reload/port-hop, then recovered — misleading. It now debounces via
UnhealthyVerificationThreshold (mirrors the #1207 orphan-session
debounce), resetting on any reachable result.
Both decisions are pure, unit-tested helpers (ShouldKeepWaitingForReady,
ShouldReportUnhealthy). 16 EditMode tests green (4 readiness + 5 debounce
+ 7 existing #1207 orphan, no regression).
Claude-Session: https://claude.ai/code/session_015JRaRFZy4piZzZtW5NabJS
Review follow-up: a streak accumulated while no session was running (detector
inert, counter still counting) survived into a freshly started session and
could satisfy orphan detection before the first post-start probe refreshed.
Reset on the not-running -> running transition, which covers every start path
(manual Connect, auto-start, resume) since UpdateConnectionStatus runs on the
UI tick.
The orphaned-session detector ended an active session on a SINGLE stale
reachability reading, and the reading came from a lone 50ms TCP connect
cached for 0.75s — trivially false-negative on a machine busy with test
runs or domain reloads. Evidence bundles in #1207 show 13 teardowns and
147 socket closures in one session from exactly this loop, wedging the
bridge in no_unity_session churn until manual recovery.
- Require 3 consecutive failed polls (0.75s cadence) before declaring a
session orphaned; probe readings taken while the editor is compiling or
importing don't count toward teardown, and detection is skipped entirely
while busy.
- Raise the probe's connect wait 50ms -> 250ms, as an overall budget shared
across candidate hosts so the worst-case main-thread wait cannot multiply.
- Honor UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S above 20s (ceiling now 120s;
default unchanged): the old ceiling equalled the default, silently
neutering the documented escape hatch for projects whose reloads or test
boundaries legitimately exceed 20s. Same treatment for
UNITY_MCP_SESSION_READY_WAIT_SECONDS, and both now share one bounded
env-read helper.
The remaining piece of #1207 (keepalive reload-awareness in
WebSocketTransportClient) is untouched here: the resume machinery reworked
in #1234 already covers reload boundaries, and the detector debounce
removes the dominant churn source dsarno identified.
Auto-start died for the whole session whenever startup included a compile:
the ctor latched SessionState before the delayCall ran, and the reload wiped
the delayCall. Reload-resume died at multi-pass compiles: the one-shot flag
was consumed before the deferred (delayCall) resume ran, and the next
boundary deleted it again.
- Replace delayCall with EditorApplication.update ticks that the
[InitializeOnLoad] ctor re-arms on every domain load; latch only when the
deferred work actually dispatches, retry (bounded per domain) while editor
services are still initializing, and skip the subscription entirely in the
common case where auto-start is off and nothing is pending.
- Move the resume flag from EditorPrefs (per-user machine-global, survives
crashes, leaks across concurrently open editors) to SessionState; keep it
until the resume succeeds, is cancelled, or exhausts its retries, instead
of consuming it at boundaries where the bridge is down. Manual Connect,
End Session, transport switch, and orphan cleanup cancel a pending resume
through a named seam (CancelPendingResume), which also aborts an in-flight
retry loop; exhaustion erases the flag so later reloads don't replay 49s
failure loops.
- Serialize TransportManager.StartAsync per mode: concurrent starts coalesce
onto one in-flight attempt, so a manual Connect can no longer race the
resume/auto-start loops into bouncing a just-established session
(WebSocketTransportClient.StartAsync tears down a live connection first).
- A SessionState connect-pending marker lets the next domain load finish an
auto-start whose connect phase a reload killed — connect-only, never
re-spawning (StartLocalHttpServer stops a still-booting server first).
Whether a launch-process handle exists is now answered live by
ServerManagementService.HasManagedServerLaunchHandle; without one (post-
reload, or an externally started server) the wait polls to the 5-minute
hard cap instead of fail-fasting.
- Busy gate uses EditorStateCache.GetActualIsCompiling (now internal, with
the CompilationPipeline reflection bound once as a delegate): raw
isCompiling stays true all play session under
Recompile-After-Finished-Playing (#549).
- One-time (per session) migration deletes the legacy EditorPrefs flag.
The stdio sibling has the same defect class (StdioBridgeReloadHandler.cs:65
delete-when-not-running, :133 delayCall) — follow-up, kept out of scope here,
along with the remaining stdio copies of the isCompiling probe.
- Remove OceanMarkTests (geometry/color mapping + construction) per request;
the mark is verified visually in-Editor.
- With the test gone, MapSvgPoint/FromHex no longer need to be `internal`,
and they (plus the SvgOrigin/SvgSize consts) are only used by the Painter2D
path — move them inside `#if UNITY_2022_1_OR_NEWER` and make them `private`,
so the 2021.3 fallback carries no unused members.
Result of a /simplify pass; reuse/altitude/efficiency angles came back clean
(logo-injection duplication left at 2 call sites per the repo's 3+-uses rule).
Addresses AI review (Copilot + CodeRabbit) on #1231:
- PollUvInstall: bail out (and detach) if the window/UI was torn down while
the task ran, guarding against dereferencing UI fields after teardown.
- OnEnable: resume polling if an install was still in flight when the window
was disabled, so completion is still processed (button reset, deps re-checked).
- OceanMark.LoadBrandTexture: log a warning instead of silently swallowing
asset-load failures on the 2021.3 raster-fallback path.
Declined CodeRabbit's "use a compat shim" suggestion for OceanMark's
#if UNITY_2022_1_OR_NEWER: per UnityCompatShims.cs policy, shims are for
[Obsolete] APIs, 3+ gated call sites, or announced removals — Painter2D is a
new API used via static dispatch in one self-contained file, which the policy
explicitly says to handle with #if, not a shim.
- Setup wizard: de-box the title beside the logo; footer buttons size to
content and sit together on one row (Refresh no longer stretches
full-width and overflows); rename the deps-step "Done" button to "Next".
- Client Configuration dialog: show the count + only failures + next step
instead of enumerating every successfully configured client.
- Main window header: version pill never shrinks/clips (flex-shrink 0);
title stays at natural width.
- Tabs: flex-shrink so all six stay reachable when docked narrow.
- Connection status label wraps/shrinks instead of sliding under the
Disconnect button (scoped to #connection-section).
Add the Ocean split-cube brand mark to the Editor UI and land three
low-risk installation-flow improvements.
Logo:
- OceanMark: reusable UI Toolkit control drawing the mark in Painter2D
vector (geometry transcribed from logo-mark.svg, brand colors baked in),
crisp at any DPI with no new package dependency. Painter2D is 2022.1+,
so it is guarded by #if UNITY_2022_1_OR_NEWER with a raster fallback
(package-icon.png) on the 2021.3 floor.
- Embedded in the main window header (#header-left group) and the
setup-wizard header.
- Ship package-icon.png inside the package (listing icon + 2021.3 fallback).
Installation polish:
- One-click uv install: UvInstaller builds the official installer command
per platform; setup wizard runs it off-thread with a confirm dialog and
re-checks dependencies.
- Claude CLI auto-discovery: consolidate the weaker resolver into
ExecPath.ResolveClaude() and add the ~/.claude/local (migrate-installer)
location both resolvers missed.
- Setup wizard clarity: success/next-step copy after configuring clients.
Tests: EditMode coverage for the geometry/color mapping, per-platform uv
command, and the Claude override contract.
Verified: compiles clean on 2021.3.45f2 (fallback path) and 6000.4.11f1
(Painter2D path).
Informational row (not a keyed provider) with a best-effort "Blender app detected"
status and a pointer to the blender-to-unity workflow / import_model_file. BlenderMCP
runs in the AI client and isn't detectable from Unity, so this reports only the
locally installed Blender app.
- BlenderDetection helper: checks well-known install paths + PATH per platform; the
pure DetectIn(candidates, exists) core is unit-tested with a fake predicate.
- McpAssetGenSection: AddBlenderHandoffRow() appended after the provider rows.
- README: note the tab's best-effort detection + that BlenderMCP lives in the AI client.
Verified: package compiles clean; EditMode 972/1018 pass, 0 failures (4 new
BlenderDetection tests) via tools/local_harness.py.
Claude-Session: https://claude.ai/code/session_015DAUrMR5UaSEzEn2wNPrEP
Security review + code review of the asset-gen feature surfaced concrete issues;
this fixes them and adds regression tests (request-shaping layer, FakeHttpTransport).
Security
- SafeZipExtractor enforces an extension allowlist; ModelImportPipeline passes an
inert model/texture allowlist so a provider archive can't drop a .cs/.dll under
Assets/ and have the Editor compile/load it (code execution on import).
- AssetGenJobManager refuses non-http(s) download URLs before fetching
(file:// SSRF / local-file read into the project).
Provider correctness
- Meshy image->3D polls /openapi/v1/image-to-3d/{id} (was the v2 text URL).
- Meshy text->3D honors texture=true via the preview->refine two-phase flow.
- OpenRouter image->image attaches the reference image (content image_url part).
- fal image->image uses the /edit endpoint + image_urls array; width/height
forwarded as image_size.
- Sketchfab search forwards categories/count/cursor/downloadable; preview doc
corrected (returns metadata, not a base64 thumbnail).
- Job import calls AssetDatabase.Refresh() before importing a freshly written file.
Local image input (image_path)
- New LocalImage helper; image_path is read and sent inline as a base64 data URI
for Meshy / fal / OpenRouter. Tripo rejects local images with a clear error
(needs a hosted image_url; its upload flow is not wired).
Cleanup (no behavior change)
- Shared AssetGenPaths + ProviderHttp helpers, HttpResult.Ok, MissingKeyMessage,
cached glTFast probe, dead-field / per-frame-alloc removal, CLI _emit.
Docs: README + manual-verification updated (image_path support; transparency is
import-flag-only; width/height fal-only).
Verified: package compiles clean; Python 1306 passed / 3 skipped. Meshy refine,
fal /edit, and image_path data-URI paths are unit-tested at the request layer
only -- live smoke per provider (real keys) still pending.
Claude-Session: https://claude.ai/code/session_015DAUrMR5UaSEzEn2wNPrEP
Drop Hunyuan (Tencent TC3-HMAC) — high effort and not testable by the maintainer:
delete HunyuanAdapter + TencentCloud3Signer (+tests), unwire from AssetGenProviders,
SecureKeyStoreConstants, the GUI provider list, and Python/CLI/README/manual-verify docs.
Refresh model defaults to current (2026) SOTA, verified against provider docs:
- fal: fal-ai/flux/dev -> fal-ai/flux-2 (FLUX.2 dev; cheaper and higher quality)
- Tripo: v2.5-20250123 -> v3.1-20260211 (current recommended model)
- Meshy: drop deprecated art_style (errors on Meshy-6); pin ai_model=meshy-6
- OpenRouter: gemini-2.5-flash-image-preview (delisted) -> gemini-2.5-flash-image
Fix two tests that asserted the now-implemented 'meshy' provider throws (repointed to the
now-removed 'hunyuan'). They were silently wrong: the headless --full runs never executed
EditMode tests (Unity -quit exits before -runTests; compile-only). A real -runTests run
now passes 62/62 AssetGen EditMode tests; Python suite 1299 passed.
Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv
Add glTFast (com.unity.cloud.gltfast) as an optional dependency in the Dependencies tab
(detect + Install/Remove + bulk Install-All), so GLB generation/import is one click away.
README 'AI Asset Generation' section (providers, BYO-key in the Asset Gen tab, OS secure
store, manage_tools to enable, async tool usage). docs/asset-gen-manual-verification.md
checklist for live validation (real keys + licensed editor; Hunyuan TC3 two-header
caveat). Drop an incidental 'manage_tools' mention from a Phase 0 test comment so the
tool-symmetry quarantine guard stays honest.
Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv
New 'Asset Gen' tab in MCPForUnityEditorWindow: per-provider API-key entry backed by
SecureKeyStore (never EditorPrefs/project — field never reads the stored key back),
enable toggles, Test presence-check, Clear; non-secret prefs (default format, output
root, auto-normalize); glTFast-missing notice pointing to the Dependencies tab. No
Generate button / prompt — generation is MCP/CLI only. Wired exactly like the Advanced
tab. Compiles clean on Unity 2021.3.45f2.
Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv
Single ProductInfo.{ProductName,MenuRoot} const; routes all user-facing
'MCP For Unity' (capital F) display strings to it. Internal identifiers,
namespaces, and manifest.json 'Unity MCP' unchanged.
Claude-Session: https://claude.ai/code/session_01XFiuAUxNS9riUJFFBEHvui
Convert the local MCP HTTP server launch from a visible-terminal model to a
headless background launch and make startup diagnosable and robust.
- Launch windowless via TerminalLauncher.CreateHeadlessProcessStartInfo;
combined stdout/stderr redirected to a per-port log at
Library/MCPForUnity/Logs/server-launch-{port}.log (truncated each launch)
- Replace the per-launch confirmation dialog with a one-time confirm gated on
the new EditorPrefs key HttpServerLaunchConfirmed; the quiet auto-start path
skips it and does not set the flag
- Prepend platform uv/uvx PATH entries so bare uvx/uv resolves under
GUI-launched Unity's minimal non-login PATH (notably macOS)
- Replace the fixed ~30-attempt reachability waits with open-ended polling
tied to the launched process's liveness (5-minute hard cap), emitting a
tail-of-log failure report when the server dies — in both
HttpAutoStartHandler and McpConnectionSection
- Simplify quit-time cleanup to a handshake-scoped StopManagedLocalHttpServer
so headless servers are not left as invisible orphans
- Transport-aware UI button labels (Connect/Disconnect vs Start/End Session),
a transient "Starting…" state, and clearer lifecycle logging
Tests: TerminalLauncherTests covers the headless start-info contract;
ServerManagementServiceCharacterizationTests covers the one-time-confirm gate,
quiet-path bypass, and per-port log redirection.
User-reported regression from the previous commit on this PR:
"Configure All Detected Clients does not actually configure all IDEs;
after I click Configure each individual IDE still shows missing
configs, and if one is configured Configure All resets it."
Root cause was the toggle I'd put inside JsonFileMcpConfigurator.Configure():
when ConfigureAllDetectedClients walked the registry and called
configurator.Configure() on each one, any client whose status was
already Configured took the Unregister branch — so the bulk action
wiped every already-configured JSON client instead of refreshing it.
(The same trap existed for ClaudeCli even before this PR, but Claude
Code's CLI registration path is rarely hit through the bulk button so
nobody had reported it.)
Fix: move the Configure↔Unregister toggle out of the configurator and
into the UI handler. The configurator API now has two clearly-split
operations:
- IMcpClientConfigurator.Configure(): always idempotent-write. Safe to
call repeatedly. This is what ConfigureAllDetectedClients calls and
what makes the "refresh transport / server version drift" use case
work without resetting anything.
- IMcpClientConfigurator.Unregister(): removes UnityMCP from this
client's config. McpClientConfiguratorBase ships a no-op default;
JsonFileMcpConfigurator overrides with the JObject-parse + remove
path that used to be the private UnregisterFromConfig helper.
Codex's TOML still has no remove path so it inherits the no-op,
matching the previous commit's stance.
UI per-client click (OnConfigureClicked) now reads client.Status and
routes: Configured → client.Unregister(); else →
MCPServiceLocator.Client.ConfigureClient(client). The button label
toggle from GetConfigureActionLabel is preserved — it's purely
informational and stays consistent with the routed action.
Secondary fix in OnConfigureAllClientsClicked: clear the
lastStatusChecks cache after the bulk run so dropdown-switching to
any non-currently-selected client immediately reads its post-bulk
status from disk instead of waiting out the 45-second throttle. This
was the "after clicking Configure All each individual IDE still shows
missing configs" symptom even when the writes had actually succeeded.
ClaudeCli's existing internal toggle is preserved (its async path in
ConfigureClaudeCliAsync handles the Configuring…/Unregistering… UX
itself, and OnConfigureClicked still early-routes to that helper).
Community report: "the Unregister button was removed in 9.7.0 — it did a
wrong registration with stdio transport and now it's difficult to
re-register; something is stuck on stdio and I can't switch to local."
The button wasn't actually removed — the 9.7.0 UI shuffle put the
single-client Configure (which toggles to Unregister for CLI-based
clients like Claude Code) inside a "Per-client setup" foldout that
defaulted to collapsed. With Configure All sitting prominently above it,
the foldout looked like terminal styling rather than the entry point to
manual per-client management, so users who needed to wipe a bad stdio
registration and re-add with HTTP couldn't find the button.
Flip the default to expanded in both UXML and the EditorPrefs fallback.
The state still persists per-user, so anyone who explicitly collapses it
keeps that preference — only the never-touched default changes.
Four smaller items flagged on PR #1142.
MCPSetupWindow.OnConfigureSelectedClicked marked setup as completed
and closed the window even when the user hadn't ticked any client —
clicking "Configure Selected" with everything unchecked would silently
skip setup forever. Show a prompt and return early instead.
README listed Claude Desktop under the HTTP-default group while a
neighboring paragraph correctly says Claude Desktop is stdio-only;
removed it from the HTTP list and pointed at the stdio block.
StartupConfigRewrite_TypeExists only checked that the type resolved,
not that it's public — added an explicit IsPublic assertion so the
test matches its own message about the [InitializeOnLoad] requirement.
ConfigureDetectedClientsTests calls the real ClientConfigurationService
which walks McpClientRegistry and Configure()s every detected client —
on CI this is harmless (no clients installed) but on a dev machine it
mutates real user config files. Marked both tests [Explicit] until we
DI the configurator list through the service.
AddDependencyRow's click handlers flipped the button to "Installing..."
and only restored it inside a synchronous try/catch. But the per-package
call sites passed `() => InstallUpmPackage(...)` — no onComplete — so
when the UPM AddAndRemove request eventually completed (success OR
failure), nothing in PollUpmRequest's continuation ever told the button
to revert. A network timeout on com.unity.cinemachine therefore parked
the button on "Installing..." forever (Discord report); same for
Removing... on uninstall. The "Install All" path was fine because it
already threaded an onComplete callback through.
Change the install/uninstall hooks to Action<Action>, thread a `restore`
callback from the click handler into the UPM helpers, and invoke it
from PollUpmRequest's completion regardless of StatusCode. Roslyn (the
one synchronous install) just invokes the callback inline after Install
returns. The unrelated CRLF/LF noise in the diff is a side effect of
normalizing the file (988 CRLF lines, 41 LF lines pre-existing) onto
the dominant CRLF convention while my edits were in flight.
Helper line under the green button was visual noise — the button label
already says what it does. "Configure a single client" was redundant
inside a section titled "Client Configuration"; "Per-client setup"
reads cleaner. Also drop the now-orphan .primary-button-hint style.
Now that "Configure All Detected Clients" actually does what its name
says (auto-rewrite + per-client transport coercion + IsInstalled
filtering all landed in the recent client-config work), it's the path
we want first-run users on — not buried at the bottom of the panel.
Layout changes in McpClientConfigSection.uxml:
- Move the Configure-All button to the top of the section, right under
the "Client Configuration" header, with a one-line helper underneath.
- Wrap the dropdown / status / single-client Configure button / Claude
CLI path / project-dir / Manual Configuration foldout in a new
"Configure a single client" foldout, collapsed by default. Persist
its open/closed state via a new EditorPrefs key.
Style changes in Common.uss:
- New .primary-button class (bright green, 34px, bold) for the
one-click action so it visually distinguishes itself from the regular
blue .action-button rows.
- Light/dark-aware foldout header styling for the new client-details
foldout that matches the existing manual-command-foldout treatment.
- ToolDiscoveryService: add AppDomain fallback scan for [McpForUnityTool] types
- StdioBridgeHost: include project_scoped_tools flag in heartbeat JSON
- McpToolsSection: update tooltip and default to reflect stdio support
- models.py: add project_scoped_tools field to UnityInstanceInfo
- port_discovery.py: read project_scoped_tools from status JSON
- main.py: enable project-scoped tools when Unity instance requests it
1.Add Compat based scripts revolving around UnityCompatShims.cs, that will document our current API Compatibility changes in several files.
2.Add custom screenshot folder selection
Address CodeRabbit review feedback on PR #954: CheckForUpdate was running
entirely inside Task.Run, including EditorPrefs reads/writes and
MCPServiceLocator.Updates lazy initialization — both unsafe off the main
thread.
Split CheckForUpdate into three thread-aware methods:
- TryGetCachedResult: reads EditorPrefs cache (main thread)
- FetchAndCompare: network I/O only (background thread safe)
- CacheFetchResult: writes EditorPrefs cache (main thread)
The editor window now resolves the service and checks cache on the main
thread, runs only network I/O in Task.Run, and caches results back on the
main thread via EditorApplication.delayCall.