Eklenti yazmak
Bu sayfa, Open Play’e yeni bir eklenti (plugin) yazmak isteyenler içindir: eklentinin parçalarını, küçük çalışan bir örneği, sürüm yükseltirken izlenecek güvenli sırayı ve gerçek olaylardan çıkan yaygın hataları gösterir. Eklentiyi kullanmak için eklentilere genel bakış, eklentileri çağırma ve bağlamalar sayfalarına bakın.
Bu sayfa kimin için
Uyarı
Barındırılan (hosted) stüdyolar bugün eklenti kodu yükleyemez. Eklentiler Open Play sunucusunun içine derlenen Rust kodudur ve güvenilir kod sayılır. Sandbox’lı çalıştırma, kaynak ölçümü ve imzalı dağıtım henüz yoktur. Bu rehber, Open Play’i kendi sunucusunda çalıştıran (self-hosted) ekipler ve Open Play’e katkı verenler içindir.
Kendi sunucunuzda kod derleyemiyorsanız oyun mantığınızı bugün şu yollarla bağlarsınız: panelde oyun kuralları, webhook’lar ya da kendi sunucunuz + sunucu anahtarı (uç nokta başvurusu).
Eklentinin anatomisi
Bir eklenti, tek bir Rust crate’idir (örneğin crx-plugin-arena, crx-plugin-work-items, crx-plugin-package-ride). Parçalar şunlardır:
| Parça | Ne işe yarar | Nerede tanımlanır |
|---|---|---|
| Manifest | Eklentinin kimliği (PluginId), sürümü (PluginVersion), host API major’ı, sağladığı ve gerektirdiği eklentiler, izinleri (PluginPermission), yapılandırma şeması, mesajları, migration özeti ve gerçek zamanlı modülleri | OpenPlayPlugin::manifest |
| Tipli mesajlar | Komut/sorgu girdisi ve çıktısı; her biri tek yönlü (CommandInput, CommandOutput, QueryInput, QueryOutput, gerçek zamanlı girdi/durum, olay), katı serde tipi | PluginMessage uygulayan struct’lar |
| Komut | Durumu değiştiren, kalıcı ve tekrar oynatması güvenli işlem. Karar fonksiyonu saftır: I/O, saat, rastgelelik yoktur | TypedCommand::decide, OpenPlayPlugin::register |
| Sorgu | Salt-okunur okuma; yalnız girdi/çıktı kodekleri kaydedilir | OpenPlayPlugin::register_queries |
| Artifact (şema belgesi) | İstemci SDK’larının ve bağlamaların okuduğu plugin.schema.json: şemalar, işlem tablosu, modüller, schemaSha256 | OpenPlayPlugin::artifact, PluginArtifact::from_manifest |
| Gerçek zamanlı modül (isteğe bağlı) | Sunucu-otoriter canlı girdi/durum indirgeyicisi (reducer) | OpenPlayPlugin::register_realtime, TypedRealtimeModule |
| Dispatcher | Komutun veritabanına gerçekten yazıldığı, sorgunun okunduğu yer; sunucudaki ortak işlem sınırını (PluginUnitOfWork) kullanır | PluginCommandDispatcher, PluginQueryDispatcher, PluginCommandResultLookup |
| Panel yüzeyi (isteğe bağlı) | Panelde bildirimsel yönetim ekranları, özel bileşenler, oyun kuralı kataloğu, leaderboard bildirimi | admin_surface, ui_components, game_rule_catalog, leaderboards |
Sorumluluk bölümü önemlidir: sunucu (host) idempotency’yi, iyimser kilidi (optimistic lock), tek veritabanı işlemini, olay (event) ve outbox yazımını, makbuzu (receipt) ve yetki kontrolünü yapar. Eklenti yalnızca mesajları, saf kararı ve yazılacak kayıtların planını (PluginMutationPlan) sağlar. Kalıcı sonuç (ödeme, XP, eşya, ödül) her zaman komut yolundan geçer; gerçek zamanlı durum yalnız hız içindir ve ekonomik etki üretemez.
Küçük örnek: “hello counter”
Aşağıdaki örnek, en küçük gerçek eklentilerin (crx-plugin-arena, crx-plugin-scene) kalıbını izler: bir komut (increment) ve bir sorgu (getCounter). Kod parçaları depodaki docs/developer/examples/rust/ dosyalarından birebir alınmıştır ve crx-plugin-arena crate’inin bir testinde gerçekten derlenip kayıt defterine kaydedilir; bu yüzden sayfadaki kod eskiyemez.
Üç parça aynı modülün içindedir (birinde yapılan use, diğerlerinde de görünür).
1. Mesajlar
Her mesaj kimliği eklenti kimliğiyle başlar (hello-counter. ön eki; aksi hâlde kayıt reddedilir). int64 değerler için PluginI64 kullanın: tel üzerinde ondalık metin olarak gider (sayılar ve kodlama). deny_unknown_fields bilinmeyen alanı reddeder.
#![allow(unused)]
fn main() {
// Mesajlar: her biri tek bir yönü olan, katı (deny_unknown_fields) serde tipleridir.
use crx_plugin::{MessageDirection, PluginI64, PluginMessage};
use serde::{Deserialize, Serialize};
pub const PLUGIN_ID: &str = "hello-counter";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct IncrementInput {
pub by: i32,
}
impl PluginMessage for IncrementInput {
const MESSAGE_ID: &'static str = "hello-counter.increment.input";
const SCHEMA_VERSION: u32 = 1;
const DIRECTION: MessageDirection = MessageDirection::CommandInput;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct IncrementOutput {
pub value: PluginI64, // int64 tel üzerinde ondalık metindir
}
impl PluginMessage for IncrementOutput {
const MESSAGE_ID: &'static str = "hello-counter.increment.output";
const SCHEMA_VERSION: u32 = 1;
const DIRECTION: MessageDirection = MessageDirection::CommandOutput;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GetInput {}
impl PluginMessage for GetInput {
const MESSAGE_ID: &'static str = "hello-counter.get.input";
const SCHEMA_VERSION: u32 = 1;
const DIRECTION: MessageDirection = MessageDirection::QueryInput;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GetOutput {
pub value: PluginI64,
}
impl PluginMessage for GetOutput {
const MESSAGE_ID: &'static str = "hello-counter.get.output";
const SCHEMA_VERSION: u32 = 1;
const DIRECTION: MessageDirection = MessageDirection::QueryOutput;
}
}
2. Eklenti: manifest, komut, sorgu
Komutun karar fonksiyonu decide eşzamansız değildir ve I/O yapmaz; yalnızca girdiyi doğrular ve sonucu üretir. Zaman ve tohum gerekiyorsa dispatcher bunları host’tan alır ve olaya yazar (eval_now_ms, eval_seed); decide içinde SystemTime veya rand kullanılmaz.
#![allow(unused)]
fn main() {
// Eklenti: manifest + komut/sorgu kaydı. Kalıcı yazım burada değil, dispatcher'ın mutasyon planındadır.
use crx_application::{
plugin_platform::{OpenPlayPlugin, PluginRegistryError},
plugin_runtime::{PluginRegistrar, PluginRuntimeError, TypedCommand},
};
use crx_plugin::{ManifestError, PluginArtifact, PluginId, PluginManifest, PluginPermission, PluginVersion};
use serde_json::json;
pub struct IncrementCommand;
impl TypedCommand for IncrementCommand {
type Input = IncrementInput;
type Output = IncrementOutput;
// Saf ve eşzamansız olmayan karar: yalnız doğrular, I/O yapmaz.
fn decide(&self, input: &Self::Input) -> Result<Self::Output, PluginRuntimeError> {
if !(1..=100).contains(&input.by) {
return Err(PluginRuntimeError::HandlerRejected);
}
Ok(IncrementOutput { value: PluginI64::new(i64::from(input.by)) })
}
}
pub struct HelloCounterPlugin;
fn plugin_id() -> PluginId {
PluginId::try_new(PLUGIN_ID).expect("static plugin id")
}
impl OpenPlayPlugin for HelloCounterPlugin {
fn manifest(&self) -> Result<PluginManifest, ManifestError> {
PluginManifest::new(
plugin_id(),
PluginVersion::new(1, 0, 0),
1, // host API major
vec![plugin_id()], // provides
vec![], // requires
vec![PluginPermission::Outbox],
json!({"type":"object","additionalProperties":false,"properties":{}}),
vec![
IncrementInput::descriptor().expect("descriptor"),
IncrementOutput::descriptor().expect("descriptor"),
GetInput::descriptor().expect("descriptor"),
GetOutput::descriptor().expect("descriptor"),
],
"0".repeat(64), // migration checksum (64 onaltılık karakter)
vec![], // realtime modülleri
)
}
fn register(&self, registrar: &mut PluginRegistrar) -> Result<(), PluginRuntimeError> {
registrar.register_command(IncrementCommand)?;
Ok(())
}
fn register_queries(&self, registrar: &mut PluginRegistrar) -> Result<(), PluginRuntimeError> {
registrar.register_query::<GetInput, GetOutput>()?;
Ok(())
}
fn artifact(&self) -> Result<Option<PluginArtifact>, PluginRegistryError> {
Ok(Some(plugin_artifact()?))
}
}
}
3. Artifact
Artifact, manifestteki her mesajı bir işleme veya modüle bağlamak zorundadır; fazla ya da eksik mesaj kayıtta hata verir. Komutlarda idempotency_required: true, pozitif bir makbuz saklama süresi ve Read olmayan bir retry politikası zorunludur; sorgularda tersi geçerlidir.
#![allow(unused)]
fn main() {
// Artifact: SDK'nın ve bağlamaların okuduğu şema belgesi; manifestle birebir eşleşmek zorundadır.
use std::collections::BTreeMap;
use crx_plugin::{
MessageId, PluginAccess, PluginArtifactSpec, PluginHttpMethod, PluginJournalPolicy,
PluginOperationDefinition, PluginOperationKind, PluginRetryPolicy,
};
fn operation(
name: &str,
kind: PluginOperationKind,
request: (&str, &str), // (şema adı, mesaj kimliği)
response: (&str, &str),
) -> PluginOperationDefinition {
let is_command = kind == PluginOperationKind::Command;
let segment = if is_command { "commands" } else { "queries" };
PluginOperationDefinition {
name: name.to_owned(),
kind,
path: format!("/v1/plugins/{PLUGIN_ID}/{segment}/{}", request.1),
method: if is_command { PluginHttpMethod::Post } else { PluginHttpMethod::Get },
request_schema: request.0.to_owned(),
response_schema: response.0.to_owned(),
schema_version: 1,
retry: if is_command { PluginRetryPolicy::SameKeyCommand } else { PluginRetryPolicy::Read },
journal: if is_command { PluginJournalPolicy::Full } else { PluginJournalPolicy::None },
resource: None,
result_path: is_command
.then(|| format!("/v1/plugins/{PLUGIN_ID}/commands/{}/result", request.1)),
success: Some(200),
access: PluginAccess::Player,
request_message_type: MessageId::try_new(request.1).expect("message id"),
response_message_type: MessageId::try_new(response.1).expect("message id"),
receipt_retention_seconds: is_command.then_some(86_400),
idempotency_required: is_command,
deprecated: None,
}
}
pub fn plugin_artifact() -> Result<PluginArtifact, PluginRegistryError> {
let value = json!({
"type":"object","additionalProperties":false,
"properties":{"value":{"type":"integer","format":"int64"}},
"required":["value"]
});
let schemas = BTreeMap::from([
("IncrementInput".to_owned(), json!({
"type":"object","additionalProperties":false,
"properties":{"by":{"type":"integer","format":"int32","minimum":1,"maximum":100}},
"required":["by"]
})),
("IncrementOutput".to_owned(), value.clone()),
("GetInput".to_owned(), json!({"type":"object","additionalProperties":false,"properties":{}})),
("GetOutput".to_owned(), value),
]);
let spec = PluginArtifactSpec {
schemas,
commands: BTreeMap::from([("increment".to_owned(), operation(
"increment",
PluginOperationKind::Command,
("IncrementInput", IncrementInput::MESSAGE_ID),
("IncrementOutput", IncrementOutput::MESSAGE_ID),
))]),
queries: BTreeMap::from([("getCounter".to_owned(), operation(
"getCounter",
PluginOperationKind::Query,
("GetInput", GetInput::MESSAGE_ID),
("GetOutput", GetOutput::MESSAGE_ID),
))]),
modules: BTreeMap::new(),
};
let manifest = HelloCounterPlugin.manifest().map_err(PluginRegistryError::Manifest)?;
Ok(PluginArtifact::from_manifest(&manifest, spec)?)
}
}
Örneğin yapmadığı şey: kalıcı yazım
IncrementCommand::decide yalnızca girdiyi doğrular. Sayacı gerçekten artıran kod, dispatcher’dadır. Dispatcher şunları yapar (gerçek örnek: crx-plugin-work-items, prepare_create ve prepare_claim):
PluginRegistry::prepare_commandile çağrıyı yetkilendirir, girdiyi katı çözer ve parmak izini (fingerprint) çıkarır.- Hedef kaydı okur (
PluginRecordQuery), iyimser kilit için sürümünü kontrol eder. - Bir
PluginMutationPlankurar: yazılacak kayıtlar (PluginRecordWrite, beklenen sürüm ve indeksleriyle) ve eklenecek olaylar (PluginEventAppend). - Planı
PluginUnitOfWorkile tek veritabanı işleminde uygular; sonuç ancak commit’ten (ya da aynı anahtarın makbuzundan) sonra döner.
Eklentiler kayıtlarını genel plugin_records (ve indeks, olay, makbuz) tablolarına yazar; eklenti başına özel SQL tablosu çoğunlukla gerekmez. Özel SQL gerekirse eklemeli bir migration ile ve eklentinin kendi migration alanında açılır.
Not
Bugün sunucuda komut yönlendirmesi eklenti başına dispatcher’larla yapılır ve bileşim kökünde (
bin/crx-api) bağlanır. Komutu olan yeni bir eklenti, kendi dispatcher’ını ve bu bağlamayı da gerektirir; yalnız manifest +registeryetmez. Yalnız realtime modülü olan eklentilerde (crx-plugin-arena,crx-plugin-scene) bu adım yoktur.
Sunucuya kaydetmek
Eklenti crate’i, bin/crx-api (ve zamanlayıcı/outbox işi varsa bin/crx-worker) bağımlılıklarına eklenir ve açılışta kayıt defterine verilir:
#![allow(unused)]
fn main() {
let mut registry = PluginRegistryBuilder::new();
registry.register_plugin(HelloCounterPlugin)?;
let registry = registry.freeze()?;
}
freeze kayıt defterini tüketip değiştirilemez hâle getirir. Tekrarlanan eklenti/mesaj/modül, eksik ya da uyumsuz bağımlılık, desteklenmeyen host API major’ı, karışık kodek ve ad alanı ihlali açılışı durdurur; hata çalışma zamanında değil, derlemeden hemen sonra görünür.
Artifact, sabitleme (pin) ve release lock
schemaSha256, artifact’taki şemaların kanonik JSON özetidir. Şemalar değişirse özet değişir; yalnız sürüm metni değişirse değişmez. İstemciler bu özeti bağlamalarına sabitler (bağlamalar).
Depoda üç yer aynı kaynaktan, aynı değişiklikte güncellenir:
artifacts/plugins/<eklenti>.plugin.schema.json: Rust’tan dışa aktarılır (PluginArtifact::to_pretty_json; örnek:crx-plugin-arenaiçindekiexport_arena_artifact).artifacts/plugins/release-lock.json: eklenti başına artifact yolu, üretilmiş bağlama yolu, tam sürüm,schemaSha256, protokol major’ı, kodek profili ve modül kimlikleri.- Üretilmiş istemci bağlaması: elle düzenlenmez, üreticiyle yenilenir.
Bir test, “depodaki JSON’un Rust’ın ürettiğinin tam kopyası olduğunu” doğrular (checked_in_artifacts_are_exact_rust_exports, crx-plugin-arena). Pin uyuşmazlığı yayını durdurur; eski özeti kabul eden bir uyumluluk katmanı eklenmez. Yeniden üretin.
Oyun istemcileri için bağlama üretmek
Oyun geliştirici, eklentinin artifact’ını alıp kendi bağlamasını üretir ve özeti bilinçli olarak sabitler:
npx crx plugins generate \
--input hello-counter.plugin.schema.json \
--schema-sha256 <artifact içindeki schemaSha256> \
--output src/generated/hello-counter.ts
CI’da --check ile dosyanın güncelliği doğrulanır. Ayrıntı ve Unity sınırı: bağlamalar. Unity için hazır bağlama bugün yalnız package-ride eklentisi içindir.
Sürümleme ve güvenli yayın
Ortamın eklenti yetkisi (grant) tam sürüm ile eşleşmelidir. Sunucuda yeni bir eklenti sürümü çalışırken ortamın yetkisi hâlâ eskisini gösteriyorsa, o ortamda eklenti ve gerçek zamanlı çağrıları reddedilir (403 forbidden; şema ucu 404). Bu, gerçekte yaşanmış bir olaydır: sürüm artırıp yetkiyi yeni sürüme geçirmeyi unutmak.
Sürüm yükseltme sırası:
- Kapılar: eklentinin testleri, artifact tam eşleşme testi ve
cargo xtask checkyeşil olmalı; migration varsa up + down çalışmalı. - Drain (boşaltma): ortamın eklenti yetkisini
Enabled→Drainingyapın. Yeni komut ve girdi hemen reddedilir. En az drain asgarisi kadar bekleyin: manifestteki en uzun gerçek zamanlı durum saklama süresi + 15 sn; gerçek zamanlı modülü olmayan eklentide 15 sn. - İkili: yeni sunucu imajını (varsa migration ile) dağıtın; migration’lar eklemeli (additive) olmalı.
- Yetkiyi yeni sürüme geçirin: panelde Sürüme geç (Eklentiler). Atlanırsa yeni ikili eklenti çağrılarını reddeder.
- İstemci: yeni bağlama ve (gerekirse) SDK ile oyun istemcisini yayınlayın.
- Doğrulama: komut sonucu, makbuz tekrar oynatma ve reddedilen çağrı sayılarını izleyin.
Geri alma kuralları: eklenti yeni sürümde henüz yazı üretmediyse Draining → önceki uyumlu ikili ve yetki. Yazı üretildikten sonra kör ikili geri alma yapılmaz: yazmaları ReadOnly ya da Disabled ile durdurun, makbuz/olay kayıtlarıyla durumu doğrulayın ve ileri düzeltme (forward-fix) sürümü çıkarın.
Ortam başına açmak
Eklenti, sunucuya derlenmiş olması yetmez; her ortamda ayrı bir yetki satırı gerekir. Panelde Eklentiler ekranından eklentiyi oyuna bağlarsınız; durumlar Enabled, ReadOnly, Draining, Disabled’dır (durumların anlamı: eklentilere genel bakış). Eklentinin izni başka bir ürün modülüne bağlıysa (örneğin leaderboard izni Competition modülüne), o modül kapalıyken geçiş 409 plugin_requires_module verir. Kanarya için önce ayrı bir test ortamında ReadOnly, sonra Enabled yapıp gözlemleyin.
Panel entegrasyonu
Eklentinin panelde ekran sunması isteğe bağlıdır ve eklentiye özel panel kodu gerektirmez:
admin_surface: bildirimsel yönetim yüzeyi. Yalnızservererişimli operatör işlemlerine bağlanır ve kayıtta manifest ve artifact’a karşı doğrulanır. Yüzey artifact’ın parçası değildir: oyunculara ve SDK’ya sunulmaz,schemaSha256’yı, sürümü ve yetkiyi değiştirmez.ui_components: eklentinininclude_str!ile derlemeye gömdüğü tek dosyalık HTML bileşenleri (izole çerçevede çalışır). Bildirimdeki özet, varlıktan hesaplanır; uyuşmazlık kayıtta reddedilir.game_rule_catalog: panelden ayarlanabilir oyun kuralı değerleri (oyun kuralları).leaderboards: eklentinin core leaderboard’a puan yazacağı tabloların bildirimi (PluginPermission::Leaderboardgerekir).
Yüzey kaydı için eklentinin bir artifact’ı olmalıdır; artifact’sız yüzey kayıtta reddedilir.
Testler
En az şunları ekleyin:
- Manifest ↔ artifact tam eşleşmesi ve şema özeti kayması (
PluginArtifact::validate_against). - Depodaki artifact JSON’u ve
release-lock.jsonsatırı Rust çıktısıyla aynı mı. int64kodeği, sınırlı girdi (uzunluk, aralık) ve reddedilen sınır değerleri.- Tek dondurulmuş kayıt defterinde birden çok eklenti (
PluginRegistryBuilder; örnek:crx-plugin-arena/tests/two_game_contract.rs). - Kalıcı komutlarda gerçek PostgreSQL ile işlem testi: aynı komutun tekrarında tek uygulama, sürüm çakışması, makbuz (örnek:
crx-infra-postgres/tests/plugin_uow_d1_integration.rs).
Kapanışta cargo xtask check ve izole bağımlılıklı tam kapı çalıştırılır; yalnız --lib koşusu tam kanıt sayılmaz.
Kontrol listesi
- Eklenti kimliği küçük harf ve kısa çizgiyle; her mesaj
eklenti.ön ekli. - Tüm girdi/çıktı tipleri
deny_unknown_fields; sınırlar şemada açık;int64içinPluginI64. - Karar fonksiyonu saf (I/O, saat, rastgelelik yok); kalıcı sonuç dispatcher’ın planında.
- Komutlar idempotent ve makbuz süreleri tanımlı; sorgular salt-okunur ve erişimi (
player,server,public) açık. - Artifact, depodaki JSON,
release-lock.jsonve üretilmiş bağlama aynı değişiklikte güncellendi. - Kayıt
bin/crx-api(gerekirsebin/crx-worker) içinde yapıldı; açılışfreezeile geçiyor. - Yayın sırası uygulandı: drain, ikili, yetkiyi yeni sürüme geçirme, istemci.
- Ortam yetkisi açık ve durumu doğru.
Sık yapılan hatalar
| Hata | Belirti | Doğrusu |
|---|---|---|
| Sürüm artırıldı, yetki geçirilmedi | Eklenti ve gerçek zamanlı çağrılar 403 forbidden, şema ucu 404 | Drain, ikili, sonra Sürüme geç |
| Şema değişti, artifact/pin yenilenmedi | Artifact tam eşleşme testi kırmızı; istemcide schemaSha256 uyuşmazlığı | Artifact’ı Rust’tan yeniden üretin; release lock ve bağlamayı aynı değişiklikte güncelleyin |
| Belirsiz sonuçlu komut yeni anahtarla tekrar gönderildi | Aynı işlem iki kez uygulanır | Aynı Idempotency-Key ve aynı gövde; emin değilseniz önce sonuç sorgusu (çağırma) |
int64 JSON sayısı olarak tanımlandı | Büyük değerlerde kayıp ya da kodek hatası | PluginI64; tel üzerinde ondalık metin |
decide içinde saat, rastgelelik veya ağ | Tekrar oynatma farklı sonuç verir | Zaman ve tohum host bağlamından gelir; kalıcı etki dispatcher planında |
| Ekonomik sonuç gerçek zamanlı reducer’da | Redis kaybında sonuç kaybolur | Kalıcı sonuç komut yoluna |
| Artifact’ta olmayan ya da fazla mesaj | Açılış freeze/kayıt hatası | Manifest ve artifact aynı Rust kaynağından türesin |
| Komut için istemci API anahtarı | 401 | Oyuncu oturumu ya da sunucu anahtarı |
| Ayrı dispatcher bağlanmadan komut eklendi | Komut yönlendirilemez | Dispatcher yazın ve bileşim kökünde bağlayın |
Bilinen sınırlar
- Üçüncü taraf eklenti yüklemek, sandbox’lı çalıştırma ve imzalı dağıtım yoktur.
- Depo dışında (out-of-tree) eklenti yazmak için ayrı bir SDK yoktur; eklenti, Open Play depo ağacındaki bir crate’tir.
- Yeni eklenti iskeleti (scaffold) üreten bir komut yoktur; kalıp, mevcut küçük eklentilerden kopyalanır.
- Komutu olan eklentiler için dispatcher bağlaması elle yapılır.
Sıradaki adım
- Eklentiyi çağırmak için eklentileri çağırma.
- İstemci bağlaması ve şema sürümü sabitleme için bağlamalar.
- Ortamda açıp sürüm değiştirmek için panel: Eklentiler.