Cosmic IDE uses typed extension points to let built-in features and third-party plugins contribute behavior without depending on app implementation classes. The architecture separates contracts, runtime loading, application integration, and user configuration so each part can evolve without turning the plugin API into a collection of hooks.
The first supported extension surfaces are:
- editor language providers;
- Language Server Protocol (LSP) server providers;
- editor formatter providers;
- editor theme providers;
- editor preview providers;
- project creation providers;
- project action providers.
Built-in providers use exactly the same registry and resolution rules as plugin contributions.
This is the stable, platform-neutral plugin contract. It owns:
CosmicPluginactivation and deactivation;PluginDescriptorand dependency metadata;PluginContext;- typed extension and service registries;
- registration disposal and owner-based cleanup;
ConfigurableExtension, the common identity and settings metadata contract.
Plugin artifacts should depend on this module and, only when they contribute IDE behavior, on
:ide-api. They must not compile against :app or :plugin-runtime.
This module defines IDE-facing extension contracts. It currently owns:
EditorLanguageProviderfor advanced editor integrations;LspServerProviderfor standard LSP-based language support;EditorFormatterProviderfor document and range formatting;EditorThemeProviderfor TextMate editor themes;- request, result, connection, and server-definition data types;
EditorExtensionPoints, the canonical extension point identifiers;- declarative project forms, progress events, terminal setup actions, and command execution;
ProjectExtensionPoints, the canonical project contribution identifiers.
An LSP plugin should normally use LspServerProvider. Direct EditorLanguageProvider
implementations are intended for integrations that cannot be represented by the LSP adapter.
The Android runtime owns discovery, manifest parsing, class loading, activation, unload, and cleanup. It also owns low-level runtime hooks. Hooks are an implementation mechanism, not a public extension model, and should not be used where a typed extension point can express the same behavior.
The app owns composition:
- creation of the global extension registry;
- registration of built-in extensions;
- loading installed plugins;
- persisted extension enablement policy;
- routing editor requests to enabled providers;
- the Sora Editor LSP adapter;
- custom user-defined LSP configuration and process startup;
- settings UI.
General utilities belong here. Plugin contracts and runtime lifecycle code do not.
An extension contribution has three identities:
| Identity | Meaning | Example |
|---|---|---|
| Extension point id | The contract being implemented | org.cosmicide.editor.lspServerProvider |
| Extension id | Stable identity of one contribution | org.cosmicide.editor.java |
| Owner plugin id | Lifecycle owner of the registration | org.cosmicide.core |
The extension id is used for persisted settings. It must remain stable across releases. Renaming a class or moving its package is harmless if its extension id does not change. Changing the id creates a new settings identity and loses the user's previous enablement override.
The owner plugin id is used for cleanup. Unloading a plugin removes all registrations owned by that plugin, regardless of extension point.
Every editor extension contract implements ConfigurableExtension:
interface ConfigurableExtension {
val id: String
val displayName: String
get() = id
val description: String
get() = ""
val enabledByDefault: Boolean
get() = true
val canDisable: Boolean
get() = true
}displayName and description are shown in Settings > Extensions. Providers should supply concise
user-facing values. enabledByDefault is consulted only when no user override exists.
Infrastructure adapters and terminal fallbacks should set canDisable to false. They remain in
the registry but are not shown as independent switches. For example, the generic LSP editor adapter
cannot be disabled because disabling individual LSP providers is the meaningful user operation.
Enablement does not unregister or unload an extension. It is a persisted resolution policy:
- all registrations remain available for inspection and lifecycle cleanup;
- the resolver reads the provider's current setting;
- disabled providers are removed before
supportsis called; - the remaining providers are evaluated by priority.
This distinction matters for plugin lifecycle. Toggling one contribution does not deactivate its owner plugin or disable unrelated contributions from that plugin.
Settings are stored under extension_enabled.<extension-id>. A missing preference means
enabledByDefault. Changes affect new routing and formatting requests immediately. Existing editor
sessions and already-running server processes are not forcefully terminated.
Registrations are synchronous and in-memory. The registry orders them by descending priority, then by owner plugin id for deterministic ties.
class MyPlugin : CosmicPlugin {
override fun activate(context: PluginContext) {
val registration = context.extensions.register(
point = EditorExtensionPoints.LSP_SERVER_PROVIDER,
extension = MyLspProvider(),
ownerPluginId = context.descriptor.id,
priority = 350
)
context.registerDisposable(registration)
}
}Always register the returned Disposable with PluginContext. The runtime also unregisters by
owner id during unload, but explicit disposal keeps resource ownership clear and handles partial
activation failures.
Priority is a selection mechanism, not a load order. A provider should use the lowest priority that expresses its precedence:
500: user-defined custom LSP provider;300: bundled language servers;200: generic LSP editor adapter;Int.MIN_VALUE: plain-text fallback.
Third-party providers may choose their own value. A provider with a higher priority wins only when
its supports method returns true for the current request.
Opening a file creates an EditorLanguageRequest. Routing proceeds as follows:
file opened
-> enabled EditorLanguageProvider registrations
-> provider.supports(request), in priority order
-> provider.configure(request)
-> stop at the first provider returning true
-> plain text fallback when none succeeds
Provider exceptions are logged and treated as a failed attempt, allowing the next matching provider
to run. supports should be fast, deterministic, and free of side effects. Expensive startup
belongs
in configure or in the connection factory used by an LSP definition.
All bundled and custom language servers implement LspServerProvider. Java, Kotlin, and Scala own
their process launchers and server definitions in their respective language provider objects. There
is no second JDT-specific launcher in the generic LSP package.
The generic LspEditorLanguageProvider performs this flow:
EditorLanguageRequest
-> enabled LspServerProvider registrations
-> first provider supporting the file
-> LspServerDefinition
-> session-owned Sora LSP adapter
-> LspServerConnection
-> server process stdin/stdout
The session-owned adapter is responsible for editor mutability during connection, initialization
timeouts, TextMate wrapper selection, capability overrides, configuration dispatch, inlay hints,
signature help, and optional protocol tracing. Plugins do not depend on Sora's LspEditor classes.
class RustLspProvider : LspServerProvider {
override val id = "com.example.rust-analyzer"
override val displayName = "Rust language support"
override val description = "Rust editing powered by rust-analyzer"
override val priority = 350
override fun supports(request: LspServerRequest): Boolean {
return request.extension.equals("rs", ignoreCase = true)
}
override fun createDefinition(request: LspServerRequest): LspServerDefinition {
return LspServerDefinition(
id = id,
fileExtensions = setOf("rs"),
displayName = "rust-analyzer",
grammarScopeName = "source.rust",
connectionFactory = RustConnectionFactory(),
initializationTimeoutMillis = 120_000
)
}
}LspServerConnectionFactory creates a connection for a request. A connection must:
- start lazily when
start()is called; - expose server stdin as
outputStream; - expose server stdout as
inputStream; - report whether it is closed;
- stop and release resources from
close().
The app shares one connection for each definition id and project. All open files whose extensions
belong to that definition attach to the same server process. Each tab maintains its own
EditorTabSession and CodeEditor instance, which connects to the shared server connection. The
adapter keeps the connection alive until its final editor disconnects.
Server diagnostics must go to stderr. Writing logs to stdout corrupts LSP framing.
LspServerDefinition validates non-empty ids, file extensions, display names, and positive
timeouts.
Optional initialization settings are deliberately data-oriented so plugins do not need app classes.
Settings > Extensions contains a built-in Custom language servers extension. Users can add a server without building a plugin by supplying:
- a display name;
- one or more file extensions, without leading dots;
- shell starter code that launches an LSP server using standard input and output;
- optionally, a direct TextMate grammar URL, Android document URI, file URI, or absolute path.
Example:
rust-analyzerStarter code runs through bash -c with the project root as its working directory. The process
receives
Cosmic IDE's toolchain environment and these additional variables:
| Variable | Value |
|---|---|
COSMIC_PROJECT_ROOT |
Absolute path of the open project root |
COSMIC_FILE |
Absolute path of the file that triggered startup |
BASH_ENV |
Cosmic's non-interactive Bash environment file |
APP_FILES_DIR |
Cosmic's app-private Arch runtime root |
The server must speak LSP over stdin/stdout and must remain attached to the shell process. For a
multi-step script, use exec for the final server command so closing the editor connection also
terminates the server cleanly:
export RUST_LOG=warn
exec rust-analyzerCustom entries have their own enabled switch. The Custom language servers provider also has a global switch. Both must be enabled for an entry to match. Entries are persisted as JSON in application preferences and are read on each routing request, so add, edit, delete, and enable operations do not require an app restart.
The custom provider has priority 500. A custom entry for java, for example, takes precedence
over
the bundled Java provider while that entry is enabled. Disable the entry to restore bundled routing.
Only one custom entry can be enabled for a file extension. Saving or enabling another entry with an
overlapping normalized extension disables its peers. This rule applies to custom entries; the normal
priority router chooses the single runtime winner among custom, bundled, and plugin providers.
Linked grammars do not change LSP semantics; they provide TextMate syntax highlighting and editing
pairs around the LSP-backed editor. HTTPS grammar content is limited to 5 MB, cached by full URL,
refreshed after seven days, and replaced only after successful parsing. When refresh fails, the last
valid stale cache is used. content:// permissions selected through the Android picker are
retained;
local sources are read directly on subsequent editor configuration.
Starter code is executable user configuration. It has the same filesystem and process permissions as Cosmic IDE. Remote plugin repositories must never populate or execute custom starter code without an explicit trust and confirmation flow.
Formatters are independent contributions. They do not need an LSP server and do not start one.
class RustfmtProvider : EditorFormatterProvider {
override val id = "com.example.rustfmt"
override val displayName = "rustfmt"
override val description = "Formats Rust source files"
override val priority = 200
override fun supports(request: EditorFormatterRequest): Boolean {
return request.file.extension == "rs"
}
override fun format(request: EditorFormatterRequest): EditorFormatterResult {
return EditorFormatterResult(text = formatRust(request.text))
}
}The formatter router filters disabled providers, calls matching providers by priority, logs
failures,
and uses the first successful result. A result can replace the whole document or a specific
TextRange.
An LSP plugin may also register a formatter provider, but that provider should be a separate class and registration. Keeping formatting separate prevents a local formatter from accidentally coupling its lifecycle to a language server process.
Preview providers contribute either a code/preview mode or a preview-only editor surface. They use
Android View instances so plugins do not need the Compose compiler.
class PdfPreviewProvider : EditorPreviewProvider {
override val id = "com.example.pdf-preview"
override val displayName = "PDF preview"
override val presentation = EditorPreviewPresentation.PREVIEW_ONLY
override val priority = 200
override fun supports(request: EditorPreviewMatchRequest): Boolean {
return request.file.extension.equals("pdf", ignoreCase = true)
}
override fun createView(request: EditorPreviewRenderRequest): View {
return PdfPreviewView(request.context)
}
override fun updateView(view: View, request: EditorPreviewRenderRequest) {
(view as PdfPreviewView).open(request.file)
}
}Register it at EditorExtensionPoints.PREVIEW_PROVIDER. Higher-priority providers win. For
CODE_AND_PREVIEW, content contains the current editor text; for PREVIEW_ONLY, it is null and
Cosmic IDE never loads or saves the file through the text editor. Providers should release WebViews,
decoders, or other owned resources in releaseView.
Theme providers contribute TextMate themes to the Sora editor. Register them at
EditorExtensionPoints.THEME_PROVIDER. Each provider builds a ThemeModel whose model name must
exactly match the name field inside the TextMate theme JSON. Cosmic IDE keys themes by that name:
a mismatch means the theme loads under one name but is selected under another.
class SolarizedDarkThemeProvider : EditorThemeProvider {
override val id = "com.example.theme.solarized-dark"
override val displayName = "Solarized Dark"
override val description = "A dark Solarized color scheme"
override val isDark = true
override fun createTheme(): ThemeModel = ThemeModel(
IThemeSource.fromString(IThemeSource.ContentType.JSON, themeJson),
"Solarized Dark"
)
}isDark tells Cosmic IDE which side of the automatic (match system) choice the theme belongs to.
The editor applies the theme selected in Settings > Editor > Theme; the automatic option keeps the
bundled darcula/Quiet Light pair. Themes are looked up by their model name, so it must stay stable.
Project UI extensions are declarative. ProjectCreationProvider.fields and
ProjectAction.fields contain text, password, boolean, or choice PluginFormField values. Cosmic
renders
the form, validates required values, owns coroutine cancellation, caps visible output, and displays
determinate progress when the provider reports a normalized value. Providers receive a string map
and an OperationReporter; they never depend on Compose classes.
Register project contributions like any other extension:
class ExamplePlugin : CosmicPlugin {
override fun activate(context: PluginContext) {
val commands = context.services.require(IdeServices.COMMAND_EXECUTION)
context.registerDisposable(
context.extensions.register(
point = ProjectExtensionPoints.CREATION_PROVIDER,
extension = ExampleProjectCreator(commands),
ownerPluginId = context.descriptor.id,
priority = 200
)
)
}
}A creator receives the canonical projects directory in ProjectCreationRequest. It must validate
that its destination remains inside that directory, must not overwrite an existing project, and
returns ProjectCreationResult only after usable project content exists. An action provider's
actions(project) should be fast and side-effect free; return an empty list when it does not apply.
Execution belongs in the suspending create or execute method.
Finite tools run through CommandExecutionService. Pass the executable and arguments separately;
do not concatenate user values into a shell command. Output callbacks may arrive from a background
thread. Check CommandResult.exitCode and treat non-zero exit as failure. Commands use Cosmic's
selected JDK, glibc runtime, and app-private Arch tool paths.
Long-lived tools such as language servers use IdeServices.TOOL_PROCESS. Its
ToolProcessService.start method returns a live Process with the same environment and forces
APP_FILES_DIR to Cosmic's app-private Arch runtime root. LSP plugins must request separate stderr,
drain it away from the protocol stream, and close all three streams when the connection stops.
Plugin environment installation must use CosmicPlugin.setupActions. Cosmic turns each
PluginSetupAction into a PTY-backed terminal handoff from the marketplace. Setup command strings
are a trusted-code surface, so plugins must not interpolate untrusted form values into them.
The bundled GitPlugin is the reference implementation. It contributes clone plus project-scoped
Git operations, reports progress parsed from git --progress, requests installation through
pacman -S --needed git, disables Git's hidden credential prompt for captured commands, and deletes
only a newly created partial clone when clone fails.
ProjectCommandProvider is the editor-facing command surface. Its commands(project) method is
side-effect free and returns a tree of ProjectCommand values for matching projects. A leaf
provides shell command text and can be classified as sync, build, run, or other. A branch leaves
the command text blank and provides children, which the editor renders as a nested submenu.
The editor gives a contributed run command precedence over the Gradle run fallback and opens all
contributed commands in bottom PTY tabs. Command text is intentionally shell code and is passed as
an exact argument to bash -lc; providers must never place untrusted values into it.
The fixed Sync tab has an additional ownership rule: gradlew takes precedence for Gradle sync. If
the wrapper is absent, the first enabled SYNC command replaces Gradle in that tab. In both cases,
sync is executed as a command in a PTY terminal.
The bundled CustomProjectTypePlugin is a user-configurable implementation. It contributes one
dynamic project creator plus a command provider backed by application preferences. Configuration
can associate existing projects through relative marker paths, while created projects persist the
type id in .cosmic/project-type.
Installed language plugins should also register ProjectExtensionPoints.TYPE_PROVIDER.
ProjectTypeProvider.supports recognizes a project root, while languageName and fileExtension
create a serializable Language.Custom value. Cosmic consults enabled type providers before its
built-in Java/Kotlin/Scala directory heuristics whenever it scans the Projects screen.
Installed plugins live in their own directory under the app plugin root and include a manifest:
{
"id": "com.example.cosmic.rust",
"name": "Rust Support",
"version": "1.0.0",
"entryClass": "com.example.rust.RustPlugin",
"classPath": ["plugin.apk"],
"capabilities": ["editor.lsp", "editor.formatter"],
"enabledByDefault": true
}Manifest enabledByDefault controls initial plugin activation. It is separate from
ConfigurableExtension.enabledByDefault, which controls an individual contribution after the plugin
has activated.
Plugin activation is atomic with respect to owned registrations. If activation fails, the runtime
disposes collected resources and unregisters the owner. Unload calls deactivate, disposes plugin
resources, and unregisters all contributions owned by the plugin id.
Settings > Extensions > Plugins downloads the configured HTTPS JSON index. The index is either an
array or an object containing a plugins array. Every installable entry must declare:
{
"id": "com.example.cosmic.rust",
"name": "Rust Support",
"version": "1.0.0",
"description": "Rust language support powered by rust-analyzer.",
"detailedDescription": "## Rust support\n\nDetailed **Markdown** content.",
"downloadUrl": "https://example.com/rust-support-1.0.0.zip",
"sha256": "64 lowercase hexadecimal characters"
}description is plain text for marketplace cards, while detailedDescription supports Markdown
in the full-screen details sheet. Older indexes using shortDescription, or only description,
remain compatible. author and the HTTPS source link are optional display metadata. The
downloaded ZIP is capped at 25 MB and its SHA-256 must match before extraction. Extraction rejects
traversal paths, more than 256 entries, and more than 50 MB of expanded data. The package is staged
under the plugin root, its manifest id/version and loadable artifact are verified, and its APK, DEX,
or JAR artifacts are made read-only before Android's dynamic class loader sees them. Only then is it
swapped into place. An update preserves the previous directory and restores/reactivates it if the
new plugin fails.
Plugins declare environment setup commands on their main CosmicPlugin implementation through
setupActions. These actions belong to the plugin lifecycle, not to project creation or project
actions. Cosmic never runs them in the background: after a fresh install it shows the exact
commands and requires a choice before opening an interactive terminal. The marketplace retains a
manual Run setup action in the plugin details sheet.
- A failing plugin activation leaves no active registrations from that owner.
- A checksum, extraction, manifest, or activation failure does not replace a working installed plugin.
- A provider throwing from
supports,configure, orformatis logged; routing continues where the caller can safely try another provider. - An LSP process that cannot start causes connection failure and leaves the editor adapter responsible for reporting and recovery.
- Malformed custom LSP JSON is ignored and logged instead of crashing settings or editor startup.
- Invalid custom names, file extensions, and empty starter code are rejected before persistence.
- Disabling a provider affects future requests. It does not kill existing process connections.
- Treat extension ids as permanent persisted API.
- Depend only on
:plugin-apiand the extension contracts needed from:ide-api. - Do not cast registry, request, or service objects to app implementation types.
- Keep
supportsside-effect free. - Start processes and allocate resources lazily.
- Close every process, stream, listener, and registration through plugin lifecycle disposal.
- Use additive manifest and API evolution; older hosts may not understand new capabilities.
- Keep server logs off stdout when using LSP stdio transport.
Extension tests should cover:
- stable id and metadata;
- supported and unsupported file extensions;
- priority conflicts with another matching provider;
- default enablement and persisted overrides;
- disabled providers never receiving
supportscalls; - process startup failure and connection close;
- whole-document and range formatter results;
- plugin activation rollback and unload cleanup;
- malformed custom configuration persistence;
- custom LSP precedence over bundled providers.
The app compilation task used for integration verification is:
./gradlew :app:compileDevDebugKotlinCosmic IDE uses a modular monolith. Separate extension-host processes would provide stronger isolation, but require IPC, process supervision, API serialization, compatibility negotiation, and a permission model. Those costs are not justified until the typed in-process API stabilizes.
Typed extension points were chosen over arbitrary method hooks. Hooks remain in :plugin-runtime
for app-owned compatibility work, but are not a supported plugin contract because they couple
plugins
to implementation details and cannot provide reliable compatibility or cleanup.
Enablement is a resolution policy instead of registry mutation. This preserves plugin ownership, makes settings reversible, avoids reactivation for a single contribution, and keeps registry inspection truthful.
Bundled language servers implement the same LspServerProvider contract as plugins and custom user
configuration. This prevents language-specific editor wiring from diverging and gives precedence,
enablement, failure handling, and connection lifecycle one implementation path.
- Add plugin signing and publisher trust on top of the current HTTPS plus SHA-256 repository model.
- Add explicit rollback controls to the marketplace UI.
- Add explicit plugin-level persisted enable/disable and reload controls in addition to contribution switches.
- Add dependency and host API compatibility checks before plugin activation.
- Introduce process-group ownership so custom shell scripts with child processes are always stopped.
- Add typed extension points for general commands, diagnostics, custom terminal panels, and settings pages. Project creation/actions and terminal setup requests are now typed.
- Consider an out-of-process extension host when the API and permission model are mature enough to serialize safely.