# Kiosk Plugin Development Guide

A guide for pipeline TDs writing **plugins** (full DCC integrations) and **custom scripts** (per-user or per-studio command extensions) for Kiosk Library.

> **TL;DR for juniors — jump to the [ELI5 section](#eli5-for-juniors) at the bottom.**

---

## Table of Contents

1. [Architecture in one diagram](#architecture-in-one-diagram)
2. [Plugins vs custom scripts — what's the difference?](#plugins-vs-custom-scripts)
3. [Where things live on disk](#where-things-live-on-disk)
4. [The `manifest.json` contract](#the-manifestjson-contract)
5. [Step-by-step: build a new plugin from scratch](#step-by-step-build-a-new-plugin-from-scratch)
6. [Writing `kiosk_*` command functions](#writing-kiosk_-command-functions)
7. [The four command-discovery locations](#the-four-command-discovery-locations)
8. [The adapter (optional, for installable DCCs)](#the-adapter-optional)
9. [Default command stacks](#default-command-stacks)
10. [The DCC ↔ Kiosk launch protocol](#the-dcc--kiosk-launch-protocol)
11. [Writing the DCC-side bootstrap (the addon)](#writing-the-dcc-side-bootstrap-the-addon)
12. [Distributing custom plugins to a studio](#distributing-custom-plugins-to-a-studio)
13. [Plugin updates — bundled vs custom](#plugin-updates--bundled-vs-custom)
14. [Per-user custom scripts (New Script / Publish to Shared)](#per-user-custom-scripts)
15. [Troubleshooting & the validation contract](#troubleshooting--the-validation-contract)
16. [ELI5 for juniors](#eli5-for-juniors)

---

## Architecture in one diagram

```
+---------------------------------------------------------------+
|                       Kiosk.exe (main app)                    |
|                                                               |
|  Scans on launch:                                             |
|    %APPDATA%\Kiosk\Plugins\<plugin>\manifest.json   [bundled] |
|    <custom_plugins_dir>\<plugin>\manifest.json      [custom]  |
|                                                               |
|  Builds a PluginRegistry (id -> manifest).                    |
|  Each manifest tells Kiosk where the icon, commands, and      |
|  adapter live for that plugin.                                |
+---------------------------------------------------------------+
                 |                              ^
                 | launches with                | reads
                 | --session-id <id>            | export.json
                 | --host <plugin_id>           |
                 | --parent-pid <pid>           |
                 v                              |
+---------------------------------------------------------------+
|                        DCC (Maya / Nuke / ...)                |
|                                                               |
|  Watches %APPDATA%\Kiosk\Sessions\<session_id>\               |
|  for export.json. When one arrives, runs the command stack    |
|  configured for the active category.                          |
+---------------------------------------------------------------+
```

The two important takeaways:

- **No sockets, no TCP ports.** Communication is one-way launch args + a shared watchfolder.
- **The manifest is the contract.** If Kiosk can't validate it, the plugin won't appear.

---

## Plugins vs custom scripts

|                          | **Plugin**                                    | **Custom script**                                  |
|--------------------------|-----------------------------------------------|----------------------------------------------------|
| Scope                    | A whole DCC integration (Maya, Nuke, ...)     | One extra `kiosk_*` command for an existing DCC    |
| Ships with               | `manifest.json` + commands + (optional) adapter | A single `.py` file with `def kiosk_xxx(file_info):` |
| Lives in                 | Bundled `Plugins/` or `<custom_plugins_dir>/`  | Per-user appdata or studio-shared commands folder  |
| Adds                     | A new tab in the Plugin Manager               | A new entry in the command-stack editor            |
| Who writes them          | Pipeline TDs                                   | Pipeline TDs, sometimes senior artists             |

Most studio pipeline work falls into "custom script" — a small `.py` per task. Plugins are the bigger commitment.

---

## Where things live on disk

```
<install_dir>\
    Plugins\                  # factory bundle (shipped with installer)
        Maya\
        Blender\
        Houdini\
        Cinema4D\
        Common\
            commands\         # shared kiosk_* commands callable from any DCC
        version.json          # plugins_version — bumps trigger update sync

%APPDATA%\Kiosk\
    Plugins\                  # LIVE bundled plugins (hot-updatable, always
                              # mirrored from <install_dir>\Plugins on launch)
    <DCC>\commands\           # LOCAL user-authored scripts (User mode)
    Sessions\<session_id>\    # watchfolder for an active DCC session
    app_config.json           # has kiosk.custom_plugins_dir
    config.json (in DB)       # user's edited command stacks

<custom_plugins_dir>\         # path set in Plugin Manager (network share OK)
    Nuke\
    StudioMaya\               # can override bundled by using the same id

<database_dir>\               # Studio mode shared DB (network)
    <DCC>\commands\           # SHARED custom scripts (visible to everyone)
    kiosk.db
```

Some paths to remember:

- `kiosk.plugins_dir` is **always** `%APPDATA%\Kiosk\Plugins` — never the install dir at runtime.
- `kiosk.custom_plugins_dir` is whatever the user set in the Plugin Manager (empty by default).
- `kiosk.database_dir` is local (User mode) or a network share (Studio mode).

---

## The `manifest.json` contract

One file at the root of every plugin folder. The fields:

| Field                     | Req | Type        | What it does |
|---------------------------|-----|-------------|--------------|
| `id`                      | yes | `str`       | Unique identifier. **Must equal the value the DCC plugin passes as `--host`.** For the bundled DCCs this is the capitalized name (`Maya`, `Blender`, `Houdini`, `Cinema4D`). The folder name should match. |
| `name`                    | yes | `str`       | Display label on the tab. Free-form (`"Cinema 4D"`, `"Nuke (Studio)"`). |
| `version`                 | yes | `str`       | SemVer string. Surfaced in tooltips; used for `kiosk_min_version` checks. |
| `kiosk_min_version`       | no  | `str`       | If current Kiosk is older, plugin is skipped with a warning. |
| `icon`                    | yes | `str`       | Path relative to plugin root (e.g. `"source/logo.png"`). 128×128 PNG with transparency is the convention. |
| `adapter`                 | no  | `str`       | Relative path to a Python file exposing `get_adapter()` returning a `DccAdapter` subclass. Omit for plugins that don't install anything into a host app. |
| `launcher`                | no¹ | `str`       | Relative path to the DCC-side bootstrap module (the `.py` that runs **inside** the DCC and launches Kiosk via `--session-id` / `--parent-pid` / `--host`). ¹ **Required whenever `adapter` is declared** — installable plugins must ship a launcher for the adapter to copy into the DCC's user folder. See [Writing the DCC-side bootstrap](#writing-the-dcc-side-bootstrap-the-addon). |
| `commands_dir`            | yes | `str`       | Relative path to the folder containing your bundled `kiosk_*.py` files. |
| `render_engines`          | no  | `list[str]` | Choices for the Render Engine dropdown. Empty/missing hides the dropdown. |
| `categories`              | no  | `list[str]` | Restrict the plugin to a subset of Kiosk categories (`hdri`, `assets`, `textures`). Missing = all. |
| `default_command_stacks`  | no  | `object`    | Per-category default stacks new users see before they edit anything. See below. |
| `description`             | no  | `str`       | One-liner shown in tooltips. |

### `default_command_stacks` shape

```json
"default_command_stacks": {
    "hdri": [
        {"command": "kiosk_copy_to_project", "active": true, "args": {}},
        {"command": "kiosk_import_hdri",     "active": true, "args": {}}
    ],
    "assets":   [ ... ],
    "textures": [ ... ]
}
```

- `command` is the function name (must start with `kiosk_`).
- `active` is whether the command runs by default.
- `args` is a dict matching the kwargs of the function (anything not passed uses the function's Python default).

Defaults are **read at runtime** — bumping a default in a new manifest version reaches existing users on their next launch. Users who have customized a stack keep their override; the defaults only apply when there's no saved entry for that `(plugin, category)` pair.

### Minimal manifest

```json
{
    "id": "Nuke",
    "name": "Nuke",
    "version": "0.1.0",
    "icon": "source/logo.png",
    "commands_dir": "plugin/commands"
}
```

That's the smallest valid manifest. It will appear as a tab; no render engine, no defaults, no installer — just a place to add commands.

---

## Step-by-step: build a new plugin from scratch

Pretend you're adding Nuke. The plugin id is `Nuke`. You're authoring it in `<custom_plugins_dir>/Nuke/`.

### 1. Folder layout

```
Nuke/
    manifest.json
    source/
        logo.png
    plugin/
        commands/
            kiosk_send_to_read.py
            kiosk_create_camera_card.py
        kiosk_nuke_plugin.py      # only needed if you want it to launch Kiosk
```

The folder names inside the plugin (`source/`, `plugin/commands/`) are **conventions**, not hard requirements — your `manifest.json` tells Kiosk where to look. Sticking to the convention makes the bundled plugins easier to copy as a starting point.

### 2. Write the manifest

```json
{
    "id": "Nuke",
    "name": "Nuke",
    "version": "0.1.0",
    "icon": "source/logo.png",
    "commands_dir": "plugin/commands",
    "render_engines": [],
    "default_command_stacks": {
        "hdri":     [{"command": "kiosk_send_to_read", "active": true, "args": {}}],
        "assets":   [{"command": "kiosk_send_to_read", "active": true, "args": {}}],
        "textures": [{"command": "kiosk_send_to_read", "active": true, "args": {}}]
    }
}
```

### 3. Add an icon

128×128 PNG with a transparent background at `source/logo.png`. The tab bar renders it at 20×20, the plugin manager card at 22×22, so anything bigger than that downscales fine.

### 4. Write a command

`plugin/commands/kiosk_send_to_read.py`:

```python
# Creates a Nuke Read node pointing at the asset's primary file.
def kiosk_send_to_read(file_info):
    import nuke
    active = file_info.get('active', {})
    path = active.get('file_path')
    if not path:
        return
    node = nuke.createNode('Read')
    node['file'].setValue(path.replace('\\', '/'))
```

The line **immediately above** `def kiosk_send_to_read` is the UI tooltip. Keep it short and useful.

### 5. (Optional) write the DCC bootstrap

If you want Nuke to be able to **launch Kiosk** with a session id (so the user can browse a library and double-click an asset to push it into the running Nuke), you need a small bootstrap module. Use [Plugins/Blender/plugin/kiosk_blender_plugin.py](Blender/plugin/kiosk_blender_plugin.py) as a starting point — it shows the launch arg construction and the watchfolder poll loop in ~80 lines.

A useful pattern: reuse the helpers from [Plugins/Common/kiosk_plugin_core.py](Common/kiosk_plugin_core.py):

- `discover_install_dir(plugin_file)` — finds the Kiosk install dir from the sidecar.
- `import_modules_from_folder(plugin_commands_dir, plugins_root, kiosk_appdata_dir, dcc_name)` — loads `kiosk_*` from all four discovery locations.
- `build_kiosk_launch_cmd(kiosk_root, session_id, parent_pid, dcc_name)` — builds the exact command string that Kiosk expects (`--session-id ... --parent-pid ... --host <dcc_name>`).
- `process_data(data_str, loaded_functions, warn_fn, reload_fn)` — handles dispatching a stack to your loaded commands.

### 6. Drop it in the custom plugins folder

In Kiosk's Plugin Manager dialog, set "Custom Plugins Folder" to the parent directory (e.g. `\\studio-share\kiosk\custom_plugins\`). Restart Kiosk. Your new "Nuke" tab appears.

### 7. Iterate

Edit the manifest, commands, or icon. Restart Kiosk to re-validate. (Hot-reload of manifests is not supported — discovery only happens on startup.)

---

## Writing `kiosk_*` command functions

**Hard rules:**

- Function name must start with `kiosk_`. That's how Kiosk discovers it.
- First parameter must be `file_info`. Kiosk passes the full export context as a dict — do not rename it.
- Any additional parameters become **user-configurable arguments** surfaced in the command stack editor (per-command, with type inferred from the default value: `bool` → checkbox, `int` → spinbox, `float` → double spinbox, anything else → text field).
- The function is imported into the DCC's Python interpreter at runtime; do all DCC-specific imports (e.g. `import nuke`) **inside** the function body so the module can be statically loaded by Kiosk for argument inspection without crashing.

**The `file_info` dict has roughly this shape:**

```python
file_info = {
    'active':   {'file_path': '...', 'file_name': '...', 'extension': '.exr', ...},
    'selection': [ {...}, {...}, ... ],    # all selected tiles, including the active one
    'dcc':      'Nuke',                     # the host plugin id
    'render_engine': 'arnold',              # if configured
    'command':  [ ... command stack ... ],  # the resolved stack for this category
    'pbr_config': { ... },                  # PBR tag config
}
```

`file_info['active']` is the asset under the cursor or the explicit pick. `file_info['selection']` is the full multi-select.

**The comment above the def becomes the tooltip:**

```python
# Imports the selected asset as a Nuke Read node with framerange detection.
def kiosk_send_to_read(file_info, framerange=True, premult=False):
    ...
```

Both `framerange` (bool) and `premult` (bool) will appear as checkboxes in the command-stack editor's "Arguments" panel.

---

## The four command-discovery locations

When a DCC plugin starts up and asks "what commands do I have available?", `import_modules_from_folder()` in [Plugins/Common/kiosk_plugin_core.py](Common/kiosk_plugin_core.py) scans four locations in this order:

1. **Bundled** — the plugin's own `commands_dir` (from the manifest).
2. **Shared custom** — `<database_dir>/<dcc>/commands/` — only populated when Kiosk is in Studio mode pointing at a network share. Visible to every artist on the studio share.
3. **Common** — `<install_dir>/Plugins/Common/commands/` — commands callable from any DCC (e.g. `kiosk_copy_to_project`).
4. **Local custom** — `%APPDATA%\Kiosk\<dcc>\commands\` — per-user-machine scripts. Useful for one-artist tweaks or for not-yet-published prototypes.

A `kiosk_*` function found in multiple locations is loaded once (last writer wins in dict-merge order: bundled, shared, common, local — so local can shadow bundled if you ever want to). Authoring tip: prefix experimental scripts with `kiosk_dev_` to avoid clashing with shipped names.

---

## The adapter (optional)

An adapter handles **installing the plugin into the DCC's user folder** (Maya modules folder, Blender addons, Houdini packages, etc.). If your plugin has no install step (e.g. Nuke where you point users at the script via `init.py` themselves), omit `adapter` from the manifest.

When present, the adapter file must expose:

```python
def get_adapter():
    return YourAdapterInstance()
```

…where `YourAdapterInstance` subclasses `DccAdapter` from [Scripts/functions/dcc_adapter.py](../Scripts/functions/dcc_adapter.py):

```python
class DccAdapter(abc.ABC):

    @abc.abstractmethod
    def get_example_path(self):
        """The default install location to suggest in the UI."""

    @abc.abstractmethod
    def get_backup_subdirectories(self):
        """Folders the install routine should backup before overwriting."""

    @abc.abstractmethod
    def install(self, target_path, plugins_dir, base_dir):
        """Copy plugin files into target_path. Bake plugins_dir / base_dir
           into any loader files that need an absolute path. Return True."""

    @abc.abstractmethod
    def remove(self, target_path):
        """Reverse install. Return True."""
```

See [Plugins/Blender/adapter.py](Blender/adapter.py) for a clean reference. The `install()` method gets `plugins_dir` (= `%APPDATA%\Kiosk\Plugins`) and `base_dir` (= the Kiosk install dir) so it can write absolute paths into any loader files it copies into the user's DCC config.

---

## Default command stacks

In your manifest:

```json
"default_command_stacks": {
    "assets": [
        {"command": "kiosk_copy_to_project", "active": true, "args": {}},
        {"command": "kiosk_import_asset",    "active": true, "args": {"merge_namespaces": true}}
    ]
}
```

This is what a brand-new user sees the first time they open the Plugin Manager. It's also what the "Revert" button restores.

**The defaults are read at runtime, not saved into the user's config.** That means:

- You can ship a new plugin version with an updated default and every existing user sees it next time (as long as they haven't manually edited that category — their edits stay).
- You don't need a migration path for default tweaks.
- The user's `config.json` in the database only contains **their** overrides, not the defaults.

If you want a user to start with no commands for a category, just omit it from `default_command_stacks` — the manager will show an empty stack and the export will warn the user that no command is assigned.

---

## The DCC ↔ Kiosk launch protocol

When the DCC plugin calls `build_kiosk_launch_cmd()`, it produces something like:

```
"C:\Program Files\Kiosk\kiosk.exe" --session-id ABCDEF12 --parent-pid 12345 --host Nuke
```

- `--session-id` is an 8-char random string. Both sides use it as the watchfolder name under `%APPDATA%\Kiosk\Sessions\`.
- `--parent-pid` is the DCC's own PID. Kiosk monitors it and exits when the DCC quits.
- `--host` is the plugin id from the manifest. Kiosk uses it to pick which command stack and which icon to show.

When the user exports an asset from Kiosk, it writes `Sessions\<session_id>\export.json` atomically. Your DCC plugin should poll that file on a QTimer (or equivalent), and when it appears: read it, dispatch the command stack, delete it (so the next export overwrites cleanly).

The Blender bootstrap shows this pattern end-to-end in ~50 lines: see `launch_kiosk_session()` and the poll handler in [Plugins/Blender/plugin/kiosk_blender_plugin.py](Blender/plugin/kiosk_blender_plugin.py).

---

## Writing the DCC-side bootstrap (the addon)

A `manifest.json` and a `commands/` folder are enough to make a plugin **appear** in Kiosk's Plugin Manager, but not to make it **functional**. For the export round-trip to work, you also need a small Python module that runs **inside the DCC** and does four things:

1. Generates a unique `session_id` and creates `%APPDATA%\Kiosk\Sessions\<session_id>\`.
2. Launches `kiosk.exe` with `--session-id`, `--parent-pid`, `--host` args pointing at that session.
3. Polls the session folder on the DCC's main thread for an incoming `export.json`.
4. When `export.json` appears, dispatches the saved command stack against the loaded `kiosk_*` functions and deletes the file.

That module is what artists "install" — copy a `.py` into Nuke's `init.py`-discovered location, drop a Maya module into `~/Documents/maya/<ver>/modules/`, register a Blender addon, etc. The bootstrap can be installed automatically by your plugin's `adapter.py` or manually by the artist.

### Responsibilities split between Kiosk and the bootstrap

| Concern                                | Owned by Kiosk     | Owned by your bootstrap   |
|----------------------------------------|---------------------|---------------------------|
| Library browsing UI                    | yes                 | no                        |
| Generating `session_id`                | no (uses arg)       | **yes**                   |
| Creating `Sessions/<id>/` folder       | both                | **yes** (first writer)    |
| Spawning `kiosk.exe`                   | no                  | **yes**                   |
| Writing `export.json`                  | yes                 | no                        |
| Reading + deleting `export.json`       | no                  | **yes**                   |
| Running the command stack in the DCC   | no                  | **yes**                   |
| Detecting when DCC quits → Kiosk quits | yes (via `--parent-pid`) | no                  |

### Helpers in `Plugins/Common/kiosk_plugin_core.py`

Reuse them — they handle the platform-quirky bits already:

- `discover_install_dir(plugin_file)` — finds Kiosk's install dir from the `kiosk_install_dir.txt` sidecar Kiosk drops next to the plugins tree on every launch. Falls back to a relative-path heuristic for dev trees.
- `import_modules_from_folder(commands_dir, plugins_root, kiosk_appdata_dir, dcc_name)` — loads every `kiosk_*` function from all four discovery locations (your commands_dir, the shared studio commands, `Common/commands/`, and the artist's local appdata). Returns a `{function_name: callable}` dict.
- `build_kiosk_launch_cmd(kiosk_root, session_id, parent_pid, dcc_name)` — returns `(cmd_str, exec_dir)` for `subprocess.Popen`. Handles dev (`python kiosk.py`) and frozen (`kiosk.exe`) installs transparently.
- `process_data(data_str, loaded_functions, warn_fn, reload_fn)` — parses the export payload, looks up each command in `loaded_functions`, calls it with `file_info` + any saved args, and routes warnings to your `warn_fn`. Also re-imports modules when the file requests it. **Always go through this helper** instead of dispatching by hand — it's how things like the auto module-reload and the per-command `active` flag stay consistent across DCCs.

### Minimal Nuke bootstrap (annotated)

Save this as `kiosk_nuke_plugin.py` inside your plugin's `plugin/` folder (the path that `commands_dir` is inside of):

```python
import os
import sys
import random
import string
import shutil
import subprocess

# 1. Make the shared Common/ helpers importable.
#    The plugin's commands_dir lives at <plugin_root>/plugin/commands/, so
#    Common/ is two levels up.
_plugin_dir = os.path.dirname(os.path.abspath(__file__))
_common_dir = os.path.normpath(os.path.join(_plugin_dir, '..', '..', 'Common'))
if _common_dir not in sys.path:
    sys.path.insert(0, _common_dir)

import importlib
import kiosk_plugin_core as _kpc
from kiosk_plugin_core import (
    import_modules_from_folder,
    build_kiosk_launch_cmd,
    discover_install_dir,
    reload_modules_common,
)


class NukeKiosk:
    DCC_NAME = 'Nuke'   # MUST match manifest.json `id` exactly.

    def __init__(self):
        self.current_directory = os.path.dirname(os.path.abspath(__file__))
        # plugins_root is the parent of all plugin folders (Nuke, Maya, Common...).
        self.kiosk_plugins_root = os.path.normpath(os.path.join(self.current_directory, '..', '..'))
        # kiosk_dir is the *install* directory of Kiosk.exe.
        self.kiosk_dir = discover_install_dir(__file__)

        self.kiosk_appdata_dir = os.path.join(os.getenv('APPDATA'), 'Kiosk')

        self.session_id = None
        self.session_dir = None
        self.kiosk_process = None
        self.poll_timer = None

        self.loaded_functions = self._import_commands()

    def _import_commands(self):
        commands_dir = os.path.join(self.current_directory, 'commands')
        return import_modules_from_folder(
            commands_dir,
            self.kiosk_plugins_root,
            self.kiosk_appdata_dir,
            self.DCC_NAME,
        )

    def reload(self):
        # Picks up new local-appdata or shared scripts the artist just wrote
        # without restarting Nuke.
        importlib.reload(_kpc)
        self.loaded_functions = reload_modules_common(self._import_commands)

    def launch_kiosk(self):
        if self.kiosk_process and self.kiosk_process.poll() is None:
            return                                  # Already running for this session.

        self.reload()
        self.session_id = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
        self.session_dir = os.path.join(self.kiosk_appdata_dir, 'Sessions', self.session_id)
        os.makedirs(self.session_dir, exist_ok=True)

        cmd_str, exec_dir = build_kiosk_launch_cmd(
            self.kiosk_dir, self.session_id, os.getpid(), self.DCC_NAME,
        )
        if not cmd_str:
            self._warn("Could not find Kiosk.exe — check the install path.")
            return

        # PYTHONPATH/PYTHONHOME would leak into Kiosk and break PyQt6.
        clean_env = os.environ.copy()
        for k in ('PYTHONPATH', 'PYTHONHOME'):
            clean_env.pop(k, None)

        self.kiosk_process = subprocess.Popen(
            cmd_str, cwd=exec_dir, shell=True, env=clean_env,
        )
        self._start_poll()

    def _start_poll(self):
        # Nuke does not have a built-in interval-callback for non-script-editor
        # code, so we drive the poll from a QTimer on the main thread.
        from PySide2.QtCore import QTimer            # Nuke 13+ uses PySide2.
        self.poll_timer = QTimer()
        self.poll_timer.setInterval(500)            # 500 ms — same cadence as Blender.
        self.poll_timer.timeout.connect(self._poll_once)
        self.poll_timer.start()

    def _poll_once(self):
        # Bail if the user closed Kiosk.
        if self.kiosk_process and self.kiosk_process.poll() is not None:
            self._cleanup()
            return

        if not self.session_dir or not os.path.exists(self.session_dir):
            self._cleanup()
            return

        export_file = os.path.join(self.session_dir, 'export.json')
        if not os.path.exists(export_file):
            return

        try:
            with open(export_file, 'r') as f:
                data_str = f.read()
            _kpc.process_data(data_str, self.loaded_functions, self._warn, self.reload)
        except Exception:
            import traceback; traceback.print_exc()
        finally:
            try:
                os.remove(export_file)              # One-shot: always delete after read.
            except OSError:
                pass

    def _cleanup(self):
        if self.poll_timer:
            self.poll_timer.stop()
            self.poll_timer = None
        if self.session_dir and os.path.exists(self.session_dir):
            shutil.rmtree(self.session_dir, ignore_errors=True)
        self.session_id = None
        self.session_dir = None
        self.kiosk_process = None

    def _warn(self, message):
        import nuke
        nuke.message(f"Kiosk: {message}")


# Singleton + entry point. Hook this to a menu button or shelf tool.
_INSTANCE = None

def launch_kiosk_session():
    global _INSTANCE
    if _INSTANCE is None:
        _INSTANCE = NukeKiosk()
    _INSTANCE.launch_kiosk()
```

### Hooking it into the DCC's UI

Once the module above is importable inside the DCC, you wire it into a menu or shelf button. Examples:

**Nuke** — in `~/.nuke/menu.py` (or your studio's equivalent):

```python
import nuke
import kiosk_nuke_plugin

menubar = nuke.menu("Nuke")
m = menubar.addMenu("Kiosk")
m.addCommand("Open Library", "kiosk_nuke_plugin.launch_kiosk_session()", "Ctrl+Alt+K")
```

**Maya** — in `userSetup.py` or a shelf button:

```python
from maya import cmds
import kiosk_maya_plugin

cmds.shelfButton(
    parent="Kiosk", label="Open Library", image="kiosk.png",
    command="kiosk_maya_plugin.launch_kiosk_session()",
)
```

**Blender** — register the operator in your addon `__init__.py`:

```python
class KIOSK_OT_open_library(bpy.types.Operator):
    bl_idname = "kiosk.open_library"
    bl_label = "Open Kiosk Library"

    def execute(self, context):
        import kiosk_blender_plugin
        kiosk_blender_plugin.launch_kiosk_session()
        return {'FINISHED'}
```

### Two delivery paths for the bootstrap

When the artist installs your plugin, your bootstrap `.py` has to end up somewhere the DCC will auto-import on startup. There are two ways:

**A) Automatic, via an adapter (recommended for installable DCCs)**

Implement `DccAdapter.install(target_path, plugins_dir, base_dir)` in your `adapter.py`. Inside `install()`, copy your bootstrap (and any `init.py` / shelf / module hooks) into `target_path`. Bake `plugins_dir` and `base_dir` as absolute paths into any loader files that need them — that way the artist's DCC can find your bootstrap module even though it lives under `%APPDATA%\Kiosk\Plugins\<DCC>\plugin\` rather than in the DCC's own folder. See [Plugins/Blender/adapter.py](Blender/adapter.py) for the pattern: it copies the `blender_kiosk.py` addon stub into Blender's addons folder and rewrites the absolute path to the real plugin tree at install time.

The artist clicks "Install Plugin" in Kiosk's Plugin Manager, picks their DCC's user folder, and the adapter handles the rest.

**B) Manual, via a docs README**

For DCCs without a clean install convention (Nuke, custom in-house tools), it's often simpler to ship a `README` with a copy-paste snippet for the artist's `init.py` or equivalent. The bootstrap stays in your plugin tree and the artist just adds `sys.path.insert(...)` + `import` lines pointing at it. No adapter needed; omit the `adapter` field from your manifest.

### Common pitfalls

- **`DCC_NAME` must match the manifest `id` exactly** — including case. `'Nuke'` ≠ `'nuke'`. Kiosk routes export.json based on `--host <DCC_NAME>`, and the Plugin Manager tab is keyed off the manifest id.
- **Poll on the DCC's main thread.** All DCCs serialize Python execution on the main thread; running your `process_data` from a background thread will corrupt scene state. Use Blender's `bpy.app.timers`, Nuke/Maya's `QTimer`, or whatever main-thread scheduler your DCC exposes.
- **Always delete `export.json` after reading**, even if `process_data` raised. Otherwise the next export will arrive into a stale file and the poll loop will keep re-running the same payload.
- **Strip `PYTHONPATH` and `PYTHONHOME`** before spawning Kiosk. DCCs (especially Maya and Houdini) inject their own Python paths that crash PyQt6 if leaked into Kiosk's process.
- **Pass `os.getpid()` as `--parent-pid`** so Kiosk auto-closes when the DCC quits. Without this, Kiosk will linger as an orphan.
- **Singleton your bootstrap class.** Without a module-level cache, every menu click instantiates a new poll timer and a new Kiosk window — and the old ones keep polling silently.

---

## Distributing custom plugins to a studio

The custom plugins folder is just a path on disk. Two common setups:

### A) Network share, single source of truth (recommended)

1. Put the plugin under a network share: `\\studio-fs\pipeline\kiosk_plugins\Nuke\`.
2. Each artist opens Plugin Manager → Browse → picks `\\studio-fs\pipeline\kiosk_plugins\`.
3. Everyone sees the same plugin tabs. Updating one folder updates everyone on next restart.

### B) Synced local folder

If artists can't (or shouldn't) read directly from the network, sync the folder via your distribution tool of choice (rsync, Dropbox, Resilio, whatever). Point each Kiosk install at the local sync target.

Either way, Kiosk **never writes** to the custom plugins folder. Updates ride on whatever delivery mechanism you control.

---

## Plugin updates — bundled vs custom

Two tracks:

| Track             | Where it lives                        | How it updates                                                                 |
|-------------------|---------------------------------------|--------------------------------------------------------------------------------|
| Bundled plugins   | `%APPDATA%\Kiosk\Plugins\`            | Kiosk downloads a versioned zip from GCS, atomically swaps the folder. Bumped via `Plugins/version.json`. |
| Custom plugins    | `<custom_plugins_dir>\`               | **Never** touched by Kiosk. You manage the rollout.                            |

**The bundle update never overwrites your custom plugins.** They live in a completely separate directory tree. Custom plugins also override bundled ones with the same id — useful for studios that want to fork the shipped Maya plugin (set `id: "Maya"` in your custom manifest and it replaces the bundled one).

Your studio versioning of a custom plugin is up to you. The `version` field in your manifest is read but not currently used to enforce anything — feel free to use it.

---

## Per-user custom scripts

For one-off commands users can write without touching the plugin tree, use the **command stack editor's "New Script..." button**. It creates a stub at `%APPDATA%\Kiosk\<dcc>\commands\<name>.py`:

```python
# Auto-generated by Kiosk
def kiosk_my_local_thing(file_info):
    # write code here
    pass
```

The user can then iterate on it locally. When ready to share with the studio, hit **"Publish to Shared..."** which copies it into `<database_dir>\<dcc>\commands\`. In Studio mode that's a network share, so every artist sees the new command on their next refresh.

The command-stack editor tags each command in the left pane by its origin so users always know whether a command is bundled, common, shared, or local-only:

- `[builtin]` — from the plugin's bundled `commands_dir`
- `[common]`  — from `Plugins/Common/commands/`
- `[shared]`  — from the Studio shared commands folder
- `[local]`   — from this machine's appdata

---

## Troubleshooting & the validation contract

If a plugin doesn't appear, the validation pipeline rejected it. Discovery checks every plugin folder against this contract in order:

| #   | Check                                                                                          | Severity |
|-----|------------------------------------------------------------------------------------------------|----------|
| 1   | `manifest.json` exists at the plugin root                                                      | error    |
| 2   | `manifest.json` parses as valid JSON                                                           | error    |
| 3   | Required fields present and non-empty: `id`, `name`, `version`, `icon`, `commands_dir`         | error    |
| 4   | `id` has no whitespace or path separators                                                      | error    |
| 5   | `kiosk_min_version` ≤ current Kiosk version (if set)                                           | error    |
| 6   | `icon` path resolves and the file exists                                                       | error    |
| 7   | `commands_dir` resolves and is an existing directory                                           | error    |
| 8   | `adapter` (if set) imports cleanly and exposes callable `get_adapter()`                        | error    |
| 8a  | `launcher` (if set) resolves to an existing file                                               | error    |
| 8b  | If `adapter` is declared, `launcher` must also be declared (installable plugins need both)     | error    |
| 9   | `render_engines` is `list[str]` (if set)                                                       | warning  |
| 10  | `categories` entries are all valid Kiosk categories                                            | warning  |
| 11  | `default_command_stacks` is well-formed (per-category lists of `{command, active, args}` dicts) | warning  |
| 12  | No duplicate `id` within the same tier (bundled vs custom)                                     | warning  |

**Where to look for the failure:**

- `%APPDATA%\Kiosk\kiosk-<computername>.log` — every check failure logs under `kiosk.plugins`.
- Plugin Manager dialog — if any plugin failed to load, the footer shows `⚠ N plugins failed to load — view details`. Click it for the full list with paths and reasons.

**Errors skip the plugin entirely. Warnings load it with the bad field dropped.** A manifest with an invalid render_engines list still gets a tab, just no engine dropdown.

---

## ELI5 for juniors

Pretend you've never written a plugin before. Here's the whole thing without jargon.

### What you're building

Kiosk is a library browser. When an artist double-clicks an asset, Kiosk needs to tell Maya (or Blender, or Nuke) "hey, import this." The *how* — which Python functions get called, in what order, with what arguments — is what a plugin defines.

### The two pieces every plugin needs

1. **A `manifest.json` file.** Think of it as the plugin's ID card. It tells Kiosk: my name is X, my icon is at Y, my code lives in folder Z.
2. **At least one `kiosk_*.py` file in the commands folder.** Each one defines a function that does *one* thing — "import this asset", "create a material from these textures", "set the project to this folder".

That's it. Two pieces. If both exist and the manifest checks out, Kiosk shows your plugin as a tab in the Plugin Manager.

### Where to put your plugin folder

There are two places, and they have different rules:

- **`<install_dir>\Plugins\<YourPlugin>\`** — the shipped/bundled spot. Get wiped on plugin updates. Don't put custom work here.
- **`<custom_plugins_dir>\<YourPlugin>\`** — wherever your studio's "custom plugins folder" setting points (set it in Kiosk → Plugin Manager → Custom Plugins Folder). Kiosk never overwrites this. **This is where your stuff goes.**

### Two halves of a working plugin

A plugin has **two halves**, and you need both for the full round-trip to work:

1. **The plugin folder** (`manifest.json` + commands + icon). This is what makes your plugin show up as a tab in Kiosk's Plugin Manager.
2. **The DCC-side bootstrap** (a `.py` you install into Nuke/Maya/etc.). This is what actually launches Kiosk from inside the DCC and listens for exports.

Without half 2, the plugin appears in the UI but no asset will ever make it into the DCC because no one is listening. Most studios write the bootstrap once per DCC and install it via the artist's `init.py` / `userSetup.py` / addon system.

### A minimum-viable plugin folder (half 1)

```
Nuke/
├── manifest.json
├── source/
│   └── logo.png            <- any 128x128 PNG with a transparent background
└── plugin/
    └── commands/
        └── kiosk_hello.py
```

`manifest.json`:

```json
{
    "id": "Nuke",
    "name": "Nuke",
    "version": "0.1.0",
    "icon": "source/logo.png",
    "commands_dir": "plugin/commands"
}
```

`plugin/commands/kiosk_hello.py`:

```python
# Says hi (test command).
def kiosk_hello(file_info):
    print("hello from kiosk", file_info.get('active', {}).get('file_path'))
```

Save those files. Open Kiosk → Plugin Manager → set the Custom Plugins Folder to the folder that contains `Nuke/` → close → restart Kiosk → open Plugin Manager again. You should see a Nuke tab. **This proves discovery works** — but exporting from Kiosk into Nuke won't do anything until you also add the bootstrap below.

### A minimum-viable bootstrap (half 2)

Pop quiz: who actually calls `kiosk_hello(file_info)` when the artist exports something? Answer: a Python script you write that **runs inside Nuke**, watches a folder, and dispatches the call. Without that script there's nothing listening.

The full pattern is documented in the [Writing the DCC-side bootstrap](#writing-the-dcc-side-bootstrap-the-addon) section above with a complete Nuke example. The short version is: each DCC plugin ships a `kiosk_<dcc>_plugin.py` next to `commands/` that does four things:

1. Generates a random `session_id`.
2. Spawns Kiosk via `subprocess.Popen` with `--session-id`, `--parent-pid`, `--host`.
3. Polls `%APPDATA%\Kiosk\Sessions\<session_id>\export.json` on the DCC's main thread.
4. When the file shows up, reads it, runs the saved command stack, deletes it.

Reuse the helpers in `Plugins/Common/kiosk_plugin_core.py` — they're shared by every shipped DCC plugin and handle the platform-quirky bits already. You'll find `build_kiosk_launch_cmd`, `import_modules_from_folder`, `process_data`, and `discover_install_dir` there.

You then ship that bootstrap alongside your plugin and install it into the DCC via either:

- **An adapter** (`adapter.py` with `install()` / `remove()` methods that copy files into the DCC's user folder) — recommended for Maya, Blender, Houdini, Cinema4D.
- **A short README** with a copy-paste snippet for the artist's `init.py` — usually simpler for Nuke and any in-house tool.

### "What's `file_info`?"

It's a dictionary Kiosk hands your function. The most important key is `file_info['active']`, which is itself a dictionary describing the asset the user is exporting. The most important key inside that is `file_path` — the absolute path to the asset file.

You can also peek at `file_info['selection']` if multiple tiles were selected.

### "What's a 'command stack'?"

When an artist clicks "Export" on an asset, Kiosk doesn't run a single function — it runs a **list** of functions, in order. The user can configure which functions and in what order, per category (HDRIs, assets, textures). That ordered list is the "command stack."

You can ship default stacks in your manifest under `default_command_stacks` so new users have something to start with.

### "What's the 'four locations' thing?"

When your plugin's commands are loaded, Kiosk scans four folders, not just one. This means:

- Your shipped commands go in `commands_dir` (the one in your manifest).
- Studio-shared user-written commands go in a folder on the database share.
- "Common" commands (work for any DCC) go in `Plugins/Common/commands/`.
- Single-artist tweaks go in `%APPDATA%\Kiosk\<your_dcc>\commands\` on that artist's machine.

All four get merged so artists can extend any plugin with their own scripts without forking it.

### "Do I need an adapter?"

Only if your plugin needs to **install** files into the DCC's own configuration folder (like Maya's modules folder, or Blender's addons). If artists install your Nuke menu by hand (or you ship a `menu.py` they source themselves), you don't need an adapter — just omit the `adapter` field from the manifest.

### "Do I need ports?"

No. Kiosk and the DCC don't talk over the network. The DCC launches `kiosk.exe` with arguments, and they share a watchfolder for the actual job data. Forget about ports — they're a historical leftover and no longer part of the protocol.

### "How do I share my plugin with the rest of the studio?"

Put it on a network share, point everyone's Kiosk Custom Plugins Folder at it. Done. The plugin updates whenever you edit the files on the share. Each artist sees the change after restarting Kiosk.

### "What if I screw up the manifest?"

Kiosk will skip your plugin and log the reason. Open `%APPDATA%\Kiosk\kiosk-<your-pc-name>.log` and grep for `kiosk.plugins`. You'll see exactly what's missing or malformed. The Plugin Manager dialog also shows a "⚠ N plugins failed to load" link if any failed — click it for a list with reasons.

### Workflow for the first day

1. Open Kiosk → Plugin Manager. Note where your Custom Plugins Folder is pointing. If empty, set it to something like `C:\plugins\` for testing.
2. Copy the bundled `Plugins/Maya/` folder to your custom plugins folder, rename it to `MyTestPlugin/`, edit the `id` in the manifest to `MyTestPlugin`.
3. Restart Kiosk. The "MyTestPlugin" tab should appear — that confirms the **plugin folder** half works.
4. Open `plugin/commands/`, copy an existing `kiosk_*.py`, rename the function, change what it does. Restart Kiosk. Your new command appears in the command-stack editor's left pane.
5. To test the **full round-trip** (asset goes from Kiosk into the DCC), install the matching `kiosk_<dcc>_plugin.py` bootstrap into the DCC (copy/paste from `Plugins/Maya/plugin/kiosk_maya_plugin.py` or one of the others, change `DCC_NAME`). Wire a menu button to its `launch_kiosk_session()` function. Click the button inside the DCC — Kiosk opens; export a tile; the command runs inside the DCC.

After that, you're just iterating on Python files and restarting Kiosk (or re-importing the bootstrap inside the DCC) between changes. There is no compile step, no IDE setup, no environment.

### When in doubt

- Compare your folder against `Plugins/Blender/` — it's the cleanest reference for the full "real plugin" shape.
- Compare your `kiosk_*.py` against any of the simple commands in `Plugins/Common/commands/` — they show the `file_info` access pattern without DCC noise.
- Read the log. The log always tells you what's wrong.

That's the whole job.
