Making a click on a .stl in Nextcloud open the self-hosted Online3DViewer instead of downloading it. The custom action registered silently, into nothing — this note is the corrected API contract for Nextcloud 34 (@nextcloud/files 3.x) plus the two reusable lessons from the hunt.
The dead global — read this first
window._nc_fileactions does not exist in Nextcloud 34. The string does not appear in a single served bundle. Pushing an action onto it throws no error, logs nothing server-side, and simply never runs. The live registry is:
window._nc_files_scope.v4_0.fileActions // a Map, keyed by action.id
Every tutorial, blog post and LLM answer that says _nc_fileactions is written against an older major. Registering there is a silent no-op.
Failure signature
Clicking a .stl downloads it instead of opening the viewer.
DevTools Network shows an XHR HEAD on the file, sourced at downloadAction.ts:69 — that is the built-in download action’s triggerDownload → await axios.head(url). Seeing downloadAction.ts:69 means your action was never selected, so the built-in default won by elimination.
Zero console errors. Zero cadviewer/warn+ lines in nextcloud.log. Nothing is broken enough to complain.
The two stacked defects
A — wrong registry (primary, sufficient on its own)
Extracted from dist/core-common.js.mapsourcesContent (@nextcloud/files/dist/index.mjs + chunks/folder-*.mjs):
getFileActions() only ever reads that Map. An action on _nc_fileactions is absent from the candidate list entirely.
Duck typing is fine
registerFileAction validates shape (id, displayName, iconSvgInline, exec present and correctly typed; enabled/order typed if present). There is no instanceof FileAction check — a plain object is accepted. The plain-object approach was never the problem; the global and the signatures were.
B — wrong callback signature (also fatal on its own)
NC 34 invokes enabled and exec with a single context object, not positional args. From apps/files/src/components/FileEntryMixin.ts (dist/files-main.js.map):
So the classic enabled(nodes) { return Array.isArray(nodes) && ... } receives the context object, Array.isArray({nodes, view, folder, contents}) is false, the action is filtered out before default selection, and exec(node) would read .basename off the context (undefined).
Note also: selection tests a.default !== undefined — presence, not === DefaultType.DEFAULT. DefaultType.DEFAULT is just the string 'default'.
default: 'default' + low order. Built-in download is { default: DefaultType.DEFAULT, order: 30 }; the list is sorted ascending by order then .find(a => a.default !== undefined) picks the first. order: -100 wins.
Dispatch register:action so useFileActions re-reads regardless of script load order.
Dedupe by id (Map.set already does) and create-if-absent the Map — your script may load before or after the Files bundle.
Match by file extension, not mimetype
Nextcloud serves .stl / .step / .stp / .3mf / .obj / .gltf as application/octet-stream. A mimetype-based enabled predicate will never match. Test basename against an extension list.
Bump the theming cachebuster or nobody sees the fix
The served asset URL is ?v = md5(appVersion)[:8] + '-' + <theming cachebuster> (TemplateLayout::getVersionHashSuffix). A custom app whose version stays 1.0.0 produces an unchanged ?v= → clients keep the stale Files bundle forever.
One hard refresh (Cmd/Ctrl-Shift-R) is still required per client — the action registers at page load.
Deployment shape
Minimal custom app cadviewer at /var/www/html/custom_apps/cadviewerinside the persistent app named volume — no compose edit needed, survives container recreation.
JS injected via \OCP\Util::addScript from a LoadAdditionalScriptsEvent listener.
Rollback: restore js/cadviewer-main.js.stl.bak (chown www-data) and occ config:app:delete theming cachebuster.
This resolves only for files in the CAD folder. Online3DViewer mounts /home/levander/freecad/exports read-only and serves it at /exports/; a .stl living anywhere else in Nextcloud produces a valid-looking URL that 404s in the viewer. Broadening this needs O3DV to reach Nextcloud storage (WebDAV or a second mount), deliberately out of scope for v1.
Generalizable lessons
A silent no-op is a signal, not the absence of one. Neither an error nor an effect is a distinct, diagnosable state — it almost always means you wrote to something nothing reads. Before trusting any doc, tutorial, or prior code, grep the actually-served bundles for the identifier. Here, grep -r '_nc_fileactions' across everything the server hands the browser returned zero hits, which closed the case in one command.
When you can’t reach the UI, extract the real predicate from source maps and drive it in a harness. The mainframe could not reach its own tailnet-only URL, so there was no browser. Instead: pull sourcesContent out of dist/files-main.js.map / dist/files-init.js.map / dist/core-common.js.map, lift the exact enabledFileActions/defaultFileAction logic, and run it in Node against a fake node object. That yields a genuine PASS/FAIL (defaultFileAction = cadviewer-view-3d; disabled for folders and .pdf) with zero browser access — far stronger than “looks right.”
Corollary to (2): walk every selection predicate explicitly — present in getFileActions()? passes enabled? sorts first? has default !== undefined? Each step is a separate silent-drop opportunity.
Verification gates used
Isolation harness driving the real FileEntryMixin logic → defaultFileAction = cadviewer-view-3d, exec opens the correct O3DV URL, disabled for folders/pdf.
GET /custom_apps/cadviewer/js/cadviewer-main.js → 200 text/javascript, sha256 byte-identical to the authored file.
node --check on the JS, php -l on both PHP files, occ app:list shows cadviewer enabled, zero warn+ lines in nextcloud.log.