diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9703b9fd..31601fa6 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -204,6 +204,8 @@ target_sources(
lib/utils/file-selection.hpp
lib/utils/filter-combo-box.cpp
lib/utils/filter-combo-box.hpp
+ lib/utils/first-run-wizard.cpp
+ lib/utils/first-run-wizard.hpp
lib/utils/help-icon.hpp
lib/utils/help-icon.cpp
lib/utils/item-selection-helpers.cpp
diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini
index a5759590..f1eb744f 100644
--- a/data/locale/de-DE.ini
+++ b/data/locale/de-DE.ini
@@ -835,8 +835,6 @@ AdvSceneSwitcher.status.inactive="Inaktiv"
AdvSceneSwitcher.running="Plugin läuft"
AdvSceneSwitcher.stopped="Plugin gestoppt"
-AdvSceneSwitcher.firstBootMessage="
Dies scheint das erste Mal zu sein, dass der Erweiterte Szenenwechsler gestartet wurde.
Bitte schaue ins Wiki für eine Liste von Anleitungen und Beispielen.
Nicht zögern und Fragen im Thread des Plugins im OBS-Forum stellen!!
"
-
AdvSceneSwitcher.deprecatedTabWarning="Die Entwicklung für dieses Tab wurde gestoppt!\nBitte stattdessen zur Verwendung des Makros-Tab übergehen.\nDieser Hinweis kann im Allgemein-Tab deaktiviert werden."
AdvSceneSwitcher.unit.milliseconds="Millisekunden"
diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini
index fd467760..3bf5dc71 100644
--- a/data/locale/en-US.ini
+++ b/data/locale/en-US.ini
@@ -2438,8 +2438,6 @@ AdvSceneSwitcher.status.inactive="Inactive"
AdvSceneSwitcher.running="Plugin running"
AdvSceneSwitcher.stopped="Plugin stopped"
-AdvSceneSwitcher.firstBootMessage="This seems to be the first time the Advanced Scene Switcher was started.
Please have a look at the Wiki for a list of guides and examples.
Do not hesitate to ask questions in the plugin's thread on the OBS forums!
"
-
AdvSceneSwitcher.deprecatedTabWarning="Development for this tab stopped!\nPlease consider transitioning to using Macros instead.\nThis hint can be disabled on the General tab."
AdvSceneSwitcher.unit.milliseconds="milliseconds"
@@ -2479,6 +2477,37 @@ AdvSceneSwitcher.day.sunday="Sunday"
AdvSceneSwitcher.day.none="No day"
AdvSceneSwitcher.day.any="Any day"
+# First run wizard
+FirstRunWizard.windowTitle="Advanced Scene Switcher - Setup Wizard"
+
+FirstRunWizard.welcome.title="Welcome to Advanced Scene Switcher"
+FirstRunWizard.welcome.subtitle="Let's create your first automation in a few easy steps."
+FirstRunWizard.welcome.body="Advanced Scene Switcher lets you build Macros - rules of the form:
When [condition] -> perform [action]
This wizard creates a macro that switches to a chosen OBS scene whenever a specific window comes into focus.
You can skip at any time. Re-open this wizard later from the General tab inside the Advanced Scene Switcher dialog.
"
+
+FirstRunWizard.scene.title="Choose a Target Scene"
+FirstRunWizard.scene.subtitle="Which OBS scene should become active when your chosen window comes into focus?"
+FirstRunWizard.scene.label="Switch to scene:"
+
+FirstRunWizard.window.title="Choose a Trigger Window"
+FirstRunWizard.window.subtitle="Enter the title of the window that should trigger the scene switch. Partial matches are fine - \"Firefox\" will match any Firefox window."
+FirstRunWizard.window.label="Window title contains:"
+FirstRunWizard.window.placeholder="e.g. \"Firefox\", \"Visual Studio Code\""
+FirstRunWizard.window.autoDetect="Auto-detect focused window"
+FirstRunWizard.window.autoDetectTooltip="Click, then switch to the target window. The title will be captured automatically after 3 seconds."
+FirstRunWizard.window.autoDetectCountdown="Detecting in %1 s - focus your window now..."
+FirstRunWizard.window.hint="Tip: a short partial title is more robust than the full title, which often changes when you switch tabs."
+
+FirstRunWizard.review.title="Review Your Macro"
+FirstRunWizard.review.subtitle="Click Back to make changes, or Finish to create the macro."
+FirstRunWizard.review.summary="Macro name: Window -> %1
Condition: Focused window title contains \"%2\" (case-insensitive)
Action: Switch to scene \"%1\""
+FirstRunWizard.review.errorTitle="Macro Creation Failed"
+FirstRunWizard.review.errorBody="The macro could not be created automatically.\n\nYou can create it manually on the Macros tab:\n Condition: Window -> contains \"%1\"\n Action: Switch scene -> \"%2\""
+
+FirstRunWizard.done.title="You're all set!"
+FirstRunWizard.done.subtitle="Your first macro has been created and is now active."
+FirstRunWizard.done.body="You can view and edit it on the Macros tab of the Advanced Scene Switcher dialog.
To learn more:
"
+
+FirstRunWizard.openButton="Open Setup Wizard..."
# This secion is copied from the OBS locale files
# OBS commonly shared locale
diff --git a/data/locale/es-ES.ini b/data/locale/es-ES.ini
index ca486fbe..b133eb9e 100644
--- a/data/locale/es-ES.ini
+++ b/data/locale/es-ES.ini
@@ -681,8 +681,6 @@ AdvSceneSwitcher.status.inactive="Inactivo"
AdvSceneSwitcher.running="Iniciar complemento"
AdvSceneSwitcher.stopped="Complemento de detención"
-AdvSceneSwitcher.firstBootMessage="Esta parece ser la primera vez que se inicia el conmutador de escena avanzado.
Por favor, eche un vistazo a Wiki para obtener una lista de guías y ejemplos.
No dude en hacer preguntas en el complemento hilo en los foros de OBS!
"
-
AdvSceneSwitcher.deprecatedTabWarning="¡Se detuvo el desarrollo de esta pestaña!\nConsidere cambiar a macros en su lugar.\nEsta sugerencia se puede desactivar en la pestaña General".
AdvSceneSwitcher.unit.milliseconds="milisegundos"
diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini
index eff1b69b..8832a067 100644
--- a/data/locale/fr-FR.ini
+++ b/data/locale/fr-FR.ini
@@ -1123,8 +1123,6 @@ AdvSceneSwitcher.status.inactive="Inactif"
AdvSceneSwitcher.running="Plugin en cours d'exécution"
AdvSceneSwitcher.stopped="Plugin arrêté"
-AdvSceneSwitcher.firstBootMessage="Il semble que ce soit la première fois que l'Advanced Scene Switcher est démarré.
Veuillez consulter le Wiki pour obtenir une liste de guides et d'exemples.
N'hésitez pas à poser des questions dans le fil de discussion du plugin sur les forums OBS !
"
-
AdvSceneSwitcher.deprecatedTabWarning="Le développement de cet onglet est terminé !\nVeuillez envisager de passer à l'utilisation des Macros à la place.\nCette astuce peut être désactivée dans l'onglet Général."
AdvSceneSwitcher.unit.milliseconds="millisecondes"
diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini
index fc579cce..c3a0f0a0 100644
--- a/data/locale/ja-JP.ini
+++ b/data/locale/ja-JP.ini
@@ -2252,8 +2252,6 @@ AdvSceneSwitcher.status.inactive="停止中"
AdvSceneSwitcher.running="プラグイン実行中"
AdvSceneSwitcher.stopped="プラグイン停止しました"
-AdvSceneSwitcher.firstBootMessage="高機能シーンスイッチャーが初めて起動したようです。
ガイドと使用例については Wiki をご覧ください。
質問は OBSフォーラムのプラグインのスレッド で遠慮なくどうぞ!
"
-
AdvSceneSwitcher.deprecatedTabWarning="このタブの開発は停止しました!\n代わりにマクロの使用に移行することを検討してください。\nこのヒントは [全般] タブで無効にすることができます。"
AdvSceneSwitcher.unit.milliseconds="ミリ秒"
diff --git a/data/locale/pt-BR.ini b/data/locale/pt-BR.ini
index c5fcd6db..3fb2a40c 100644
--- a/data/locale/pt-BR.ini
+++ b/data/locale/pt-BR.ini
@@ -1872,8 +1872,6 @@ AdvSceneSwitcher.status.inactive="Inativo"
AdvSceneSwitcher.running="Plugin em execução"
AdvSceneSwitcher.stopped="Plugin parado"
-AdvSceneSwitcher.firstBootMessage="Parece que esta é a primeira vez que o Advanced Scene Switcher está sendo iniciado.
Por favor, consulte o Wiki para uma lista de guias e exemplos.
Não hesite em fazer perguntas no tópico do plugin nos fóruns do OBS!
"
-
AdvSceneSwitcher.deprecatedTabWarning="Desenvolvimento para esta aba interrompido!\nPor favor, considere a transição para o uso de Macros em vez disso.\nEsta dica pode ser desativada na aba Geral."
AdvSceneSwitcher.unit.milliseconds="milissegundos"
diff --git a/data/locale/tr-TR.ini b/data/locale/tr-TR.ini
index 0ddd9ea6..b048c8e2 100644
--- a/data/locale/tr-TR.ini
+++ b/data/locale/tr-TR.ini
@@ -587,8 +587,6 @@ AdvSceneSwitcher.status.inactive="İnaktif"
AdvSceneSwitcher.running="Eklenti çalışıyor"
AdvSceneSwitcher.stopped="Eklenti durdu"
-AdvSceneSwitcher.firstBootMessage="Gelişmiş Sahne Değiştirici ilk kez başlatılıyor gibi görünüyor.
Lütfen Wiki için kılavuzlar ve örnekler listesi.
Yapmayın. eklentinin konu!
"
-
AdvSceneSwitcher.unit.milliseconds="millisaniye"
AdvSceneSwitcher.unit.seconds="saniye"
AdvSceneSwitcher.unit.minutes="dakika"
diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini
index ac7d2060..9eedcd08 100644
--- a/data/locale/zh-CN.ini
+++ b/data/locale/zh-CN.ini
@@ -2116,8 +2116,6 @@ AdvSceneSwitcher.status.inactive="已停止"
AdvSceneSwitcher.running="插件正在运行"
AdvSceneSwitcher.stopped="插件已停止"
-AdvSceneSwitcher.firstBootMessage="这似乎是您第一次启动高级场景切换器.
请看一下 Wiki 查看指南和示例列表.
如果有问题,在在OBS论坛插件帖子内提问 thread
"
-
AdvSceneSwitcher.deprecatedTabWarning="此选项卡的开发已停止!请考虑转换为使用宏来代替。\n可以在“常规”选项卡上禁用此提示."
AdvSceneSwitcher.unit.milliseconds="毫秒"
diff --git a/forms/advanced-scene-switcher.ui b/forms/advanced-scene-switcher.ui
index 53e81146..f1a92514 100644
--- a/forms/advanced-scene-switcher.ui
+++ b/forms/advanced-scene-switcher.ui
@@ -68,7 +68,7 @@
0
0
962
- 1160
+ 1190
@@ -565,6 +565,13 @@
+ -
+
+
+ FirstRunWizard.openButton
+
+
+
diff --git a/lib/advanced-scene-switcher.hpp b/lib/advanced-scene-switcher.hpp
index f3bdaff9..19b88343 100644
--- a/lib/advanced-scene-switcher.hpp
+++ b/lib/advanced-scene-switcher.hpp
@@ -70,6 +70,7 @@ public slots:
void on_priorityUp_clicked();
void on_priorityDown_clicked();
void on_threadPriority_currentTextChanged(const QString &text);
+ void on_openSetupWizard_clicked();
/* --- End of legacy tab section --- */
diff --git a/lib/general.cpp b/lib/general.cpp
index f72c3674..327e2e43 100644
--- a/lib/general.cpp
+++ b/lib/general.cpp
@@ -1,19 +1,18 @@
#include "advanced-scene-switcher.hpp"
#include "file-selection.hpp"
#include "filter-combo-box.hpp"
+#include "first-run-wizard.hpp"
#include "layout-helpers.hpp"
#include "macro.hpp"
#include "macro-search.hpp"
#include "macro-settings.hpp"
#include "path-helpers.hpp"
-#include "selection-helpers.hpp"
#include "source-helpers.hpp"
#include "splitter-helpers.hpp"
#include "status-control.hpp"
#include "switcher-data.hpp"
#include "tab-helpers.hpp"
#include "ui-helpers.hpp"
-#include "utility.hpp"
#include "variable.hpp"
#include "version.h"
@@ -361,14 +360,46 @@ void AdvSceneSwitcher::RestoreWindowGeo()
}
}
+static void renameMacroIfNecessary(const std::shared_ptr ¯o)
+{
+ if (!GetMacroByName(macro->Name().c_str())) {
+ return;
+ }
+
+ auto name = macro->Name();
+ int i = 2;
+ while (GetMacroByName((name + " " + std::to_string(i)).c_str())) {
+ i++;
+ }
+
+ macro->SetName(name + " " + std::to_string(i));
+}
+
void AdvSceneSwitcher::CheckFirstTimeSetup()
{
- if (switcher->firstBoot && !switcher->disableHints) {
- switcher->firstBoot = false;
- DisplayMessage(
- obs_module_text("AdvSceneSwitcher.firstBootMessage"));
- switcher->Start();
+ if (!IsFirstRun() || !GetTopLevelMacros().empty()) {
+ return;
}
+
+ auto macro = FirstRunWizard::ShowWizard(this);
+ if (macro) {
+ renameMacroIfNecessary(macro);
+ QTimer::singleShot(0, this,
+ [this, macro]() { ui->macros->Add(macro); });
+ }
+ switcher->Start();
+}
+
+void AdvSceneSwitcher::on_openSetupWizard_clicked()
+{
+ auto macro = FirstRunWizard::ShowWizard(this);
+ if (!macro) {
+ return;
+ }
+
+ renameMacroIfNecessary(macro);
+ ui->macros->Add(macro);
+ ui->tabWidget->setCurrentWidget(ui->macroTab);
}
void AdvSceneSwitcher::on_tabWidget_currentChanged(int)
diff --git a/lib/switcher-data.cpp b/lib/switcher-data.cpp
index f3d4f166..d5caecab 100644
--- a/lib/switcher-data.cpp
+++ b/lib/switcher-data.cpp
@@ -152,7 +152,7 @@ bool SwitcherData::VersionChanged(obs_data_t *obj, std::string currentVersion)
if (!obs_data_has_user_value(obj, "version")) {
return false;
}
- switcher->firstBoot = false;
+
std::string previousVersion = obs_data_get_string(obj, "version");
return previousVersion != currentVersion;
}
diff --git a/lib/switcher-data.hpp b/lib/switcher-data.hpp
index 9a0b17d8..84018dea 100644
--- a/lib/switcher-data.hpp
+++ b/lib/switcher-data.hpp
@@ -94,7 +94,6 @@ public:
bool stop = false;
std::condition_variable cv;
- bool firstBoot = true;
bool transitionActive = false;
bool sceneCollectionStop = false;
bool obsIsShuttingDown = false;
diff --git a/lib/utils/first-run-wizard.cpp b/lib/utils/first-run-wizard.cpp
new file mode 100644
index 00000000..af40ae79
--- /dev/null
+++ b/lib/utils/first-run-wizard.cpp
@@ -0,0 +1,465 @@
+#include "first-run-wizard.hpp"
+
+#include "log-helper.hpp"
+#include "macro.hpp"
+#include "macro-action-factory.hpp"
+#include "macro-condition-factory.hpp"
+#include "macro-settings.hpp"
+#include "platform-funcs.hpp"
+#include "selection-helpers.hpp"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+namespace advss {
+
+static constexpr char kConditionIdWindow[] = "window";
+static constexpr char kActionIdSceneSwitch[] = "scene_switch";
+
+// ---------------------------------------------------------------------------
+// OBS global config helpers
+// ---------------------------------------------------------------------------
+static constexpr char kConfigSection[] = "AdvancedSceneSwitcher";
+static constexpr char kFirstRunKey[] = "firstRun";
+
+bool IsFirstRun()
+{
+#if LIBOBS_API_VER >= MAKE_SEMANTIC_VERSION(31, 0, 0)
+ config_t *cfg = obs_frontend_get_user_config();
+#else
+ config_t *cfg = obs_frontend_get_global_config();
+#endif
+ if (!config_has_user_value(cfg, kConfigSection, kFirstRunKey)) {
+ return true;
+ }
+ return config_get_bool(cfg, kConfigSection, kFirstRunKey);
+}
+
+static void WriteFirstRun(bool value)
+{
+#if LIBOBS_API_VER >= MAKE_SEMANTIC_VERSION(31, 0, 0)
+ config_t *cfg = obs_frontend_get_user_config();
+#else
+ config_t *cfg = obs_frontend_get_global_config();
+#endif
+ config_set_bool(cfg, kConfigSection, kFirstRunKey, value);
+ config_save_safe(cfg, "tmp", nullptr);
+}
+
+static QString DetectFocusedWindow()
+{
+ std::string title;
+ GetCurrentWindowTitle(title);
+ return QString::fromStdString(title);
+}
+
+// ===========================================================================
+// WelcomePage
+// ===========================================================================
+
+WelcomePage::WelcomePage(QWidget *parent) : QWizardPage(parent)
+{
+ setTitle(obs_module_text("FirstRunWizard.welcome.title"));
+ setSubTitle(obs_module_text("FirstRunWizard.welcome.subtitle"));
+
+ auto body = new QLabel(obs_module_text("FirstRunWizard.welcome.body"),
+ this);
+ body->setWordWrap(true);
+ body->setTextFormat(Qt::RichText);
+
+ auto layout = new QVBoxLayout(this);
+ layout->addWidget(body);
+ layout->addStretch();
+}
+
+// ===========================================================================
+// SceneSelectionPage
+// ===========================================================================
+
+SceneSelectionPage::SceneSelectionPage(QWidget *parent) : QWizardPage(parent)
+{
+ setTitle(obs_module_text("FirstRunWizard.scene.title"));
+ setSubTitle(obs_module_text("FirstRunWizard.scene.subtitle"));
+
+ auto label =
+ new QLabel(obs_module_text("FirstRunWizard.scene.label"), this);
+ _sceneCombo = new QComboBox(this);
+ _sceneCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+
+ // registerField with * suffix means the field is mandatory for Next
+ registerField("targetScene*", _sceneCombo, "currentText",
+ SIGNAL(currentTextChanged(QString)));
+
+ connect(_sceneCombo, &QComboBox::currentTextChanged, this,
+ &QWizardPage::completeChanged);
+
+ auto row = new QHBoxLayout;
+ row->addWidget(label);
+ row->addWidget(_sceneCombo, 1);
+
+ auto layout = new QVBoxLayout(this);
+ layout->addLayout(row);
+ layout->addStretch();
+}
+
+void SceneSelectionPage::initializePage()
+{
+ _sceneCombo->clear();
+ for (const QString &name : GetSceneNames())
+ _sceneCombo->addItem(name);
+}
+
+bool SceneSelectionPage::isComplete() const
+{
+ return _sceneCombo->count() > 0 &&
+ !_sceneCombo->currentText().isEmpty();
+}
+
+// ===========================================================================
+// WindowConditionPage
+// ===========================================================================
+
+WindowConditionPage::WindowConditionPage(QWidget *parent)
+ : QWizardPage(parent),
+ _detectTimer(new QTimer(this))
+{
+ setTitle(obs_module_text("FirstRunWizard.window.title"));
+ setSubTitle(obs_module_text("FirstRunWizard.window.subtitle"));
+
+ auto label = new QLabel(obs_module_text("FirstRunWizard.window.label"),
+ this);
+ _windowEdit = new QLineEdit(this);
+ _windowEdit->setPlaceholderText(
+ obs_module_text("FirstRunWizard.window.placeholder"));
+
+ _autoDetect = new QPushButton(
+ obs_module_text("FirstRunWizard.window.autoDetect"), this);
+ _autoDetect->setToolTip(
+ obs_module_text("FirstRunWizard.window.autoDetectTooltip"));
+
+ registerField("windowTitle*", _windowEdit);
+
+ connect(_windowEdit, &QLineEdit::textChanged, this,
+ &QWizardPage::completeChanged);
+ connect(_autoDetect, &QPushButton::clicked, this,
+ &WindowConditionPage::onAutoDetectClicked);
+ connect(_detectTimer, &QTimer::timeout, this,
+ &WindowConditionPage::onCountdownTick);
+
+ auto row = new QHBoxLayout;
+ row->addWidget(label);
+ row->addWidget(_windowEdit, 1);
+
+ auto hint =
+ new QLabel(obs_module_text("FirstRunWizard.window.hint"), this);
+ hint->setTextFormat(Qt::RichText);
+ hint->setWordWrap(true);
+
+ auto layout = new QVBoxLayout(this);
+ layout->addLayout(row);
+ layout->addWidget(_autoDetect, 0, Qt::AlignLeft);
+ layout->addWidget(hint);
+ layout->addStretch();
+}
+
+void WindowConditionPage::initializePage()
+{
+ if (_windowEdit->text().isEmpty()) {
+ QString detected = DetectFocusedWindow();
+ if (!detected.isEmpty()) {
+ _windowEdit->setText(detected);
+ }
+ }
+}
+
+bool WindowConditionPage::isComplete() const
+{
+ return !_windowEdit->text().trimmed().isEmpty();
+}
+
+void WindowConditionPage::onAutoDetectClicked()
+{
+ _countdown = 3;
+ _autoDetect->setEnabled(false);
+ _autoDetect->setText(
+ QString(obs_module_text(
+ "FirstRunWizard.window.autoDetectCountdown"))
+ .arg(_countdown));
+ _detectTimer->start(1000);
+}
+
+void WindowConditionPage::onCountdownTick()
+{
+ --_countdown;
+ if (_countdown > 0) {
+ _autoDetect->setText(
+ QString(obs_module_text(
+ "FirstRunWizard.window.autoDetectCountdown"))
+ .arg(_countdown));
+ return;
+ }
+
+ _detectTimer->stop();
+ QString title = DetectFocusedWindow();
+ if (!title.isEmpty()) {
+ _windowEdit->setText(title);
+ }
+
+ _autoDetect->setEnabled(true);
+ _autoDetect->setText(
+ obs_module_text("FirstRunWizard.window.autoDetect"));
+}
+
+// ===========================================================================
+// ReviewPage
+// ===========================================================================
+
+ReviewPage::ReviewPage(QWidget *parent, std::shared_ptr ¯o)
+ : QWizardPage(parent),
+ _macro(macro)
+{
+ setTitle(obs_module_text("FirstRunWizard.review.title"));
+ setSubTitle(obs_module_text("FirstRunWizard.review.subtitle"));
+
+ _summary = new QLabel(this);
+ _summary->setWordWrap(true);
+ _summary->setTextFormat(Qt::RichText);
+ _summary->setFrameShape(QFrame::StyledPanel);
+ _summary->setContentsMargins(12, 12, 12, 12);
+
+ auto layout = new QVBoxLayout(this);
+ layout->addWidget(_summary);
+ layout->addStretch();
+}
+
+void ReviewPage::initializePage()
+{
+ const QString scene = field("targetScene").toString();
+ const QString window = field("windowTitle").toString();
+
+ _summary->setText(
+ QString(obs_module_text("FirstRunWizard.review.summary"))
+ .arg(scene.toHtmlEscaped(), window.toHtmlEscaped()));
+}
+
+static QString escapeForRegex(const QString &input)
+{
+ return QRegularExpression::escape(input);
+}
+
+bool ReviewPage::validatePage()
+{
+ const QString scene = field("targetScene").toString();
+ const QString window = escapeForRegex(field("windowTitle").toString());
+ const std::string name = ("Window -> " + scene).toStdString();
+
+ // Build condition data blob
+ // ---------------------------------------------------------------
+ // Condition blob — mirrors MacroConditionWindow::Save() output:
+ //
+ // {
+ // "segmentSettings": { "enabled": true, "version": 1 },
+ // "id": "window",
+ // "checkTitle": true,
+ // "window": "",
+ // "windowRegexConfig": {
+ // "enable": true, // use regex-style partial matching
+ // "partial": true, // match anywhere in the title
+ // "options": 3 // case-insensitive (QRegularExpression flags)
+ // },
+ // "focus": true, // only trigger when window is focused
+ // "version": 1
+ // }
+ // ---------------------------------------------------------------
+ OBSDataAutoRelease condSegment = obs_data_create();
+ obs_data_set_bool(condSegment, "enabled", true);
+ obs_data_set_int(condSegment, "version", 1);
+
+ OBSDataAutoRelease condRegex = obs_data_create();
+ obs_data_set_bool(condRegex, "enable", true);
+ obs_data_set_bool(condRegex, "partial", true);
+ obs_data_set_int(condRegex, "options", 3); // CaseInsensitiveOption
+
+ OBSDataAutoRelease condData = obs_data_create();
+ obs_data_set_obj(condData, "segmentSettings", condSegment);
+ obs_data_set_string(condData, "id", "window");
+ obs_data_set_bool(condData, "checkTitle", true);
+ obs_data_set_string(condData, "window", window.toUtf8().constData());
+ obs_data_set_obj(condData, "windowRegexConfig", condRegex);
+ obs_data_set_bool(condData, "focus", true);
+ obs_data_set_int(condData, "version", 1);
+
+ // Build action data blob
+ // ---------------------------------------------------------------
+ // Action blob — mirrors MacroActionSwitchScene::Save() output:
+ //
+ // {
+ // "segmentSettings": { "enabled": true, "version": 1 },
+ // "id": "scene_switch",
+ // "action": 0, // 0 = switch scene
+ // "sceneSelection": {
+ // "type": 0, // 0 = scene by name
+ // "name": "",
+ // "canvasSelection": "Main"
+ // },
+ // "transitionType": 1, // 1 = use scene's default transition
+ // "blockUntilTransitionDone": false,
+ // "sceneType": 0
+ // }
+ // ---------------------------------------------------------------
+ OBSDataAutoRelease actionSegment = obs_data_create();
+ obs_data_set_bool(actionSegment, "enabled", true);
+ obs_data_set_int(actionSegment, "version", 1);
+
+ OBSDataAutoRelease sceneSelection = obs_data_create();
+ obs_data_set_int(sceneSelection, "type", 0);
+ obs_data_set_string(sceneSelection, "name", scene.toUtf8().constData());
+ obs_data_set_string(sceneSelection, "canvasSelection", "Main");
+
+ OBSDataAutoRelease actionData = obs_data_create();
+ obs_data_set_obj(actionData, "segmentSettings", actionSegment);
+ obs_data_set_string(actionData, "id", "scene_switch");
+ obs_data_set_int(actionData, "action", 0);
+ obs_data_set_obj(actionData, "sceneSelection", sceneSelection);
+ obs_data_set_int(actionData, "transitionType", 1);
+ obs_data_set_bool(actionData, "blockUntilTransitionDone", false);
+ obs_data_set_int(actionData, "sceneType", 0);
+
+ if (!FirstRunWizard::CreateMacro(_macro, name, kConditionIdWindow,
+ condData, kActionIdSceneSwitch,
+ actionData)) {
+ QMessageBox::warning(
+ this,
+ obs_module_text("FirstRunWizard.review.errorTitle"),
+ QString(obs_module_text(
+ "FirstRunWizard.review.errorBody"))
+ .arg(window, scene));
+ _macro.reset();
+ // Still advance so the user is not stuck.
+ }
+ return true;
+}
+
+// ===========================================================================
+// DonePage
+// ===========================================================================
+
+DonePage::DonePage(QWidget *parent) : QWizardPage(parent)
+{
+ setTitle(obs_module_text("FirstRunWizard.done.title"));
+ setSubTitle(obs_module_text("FirstRunWizard.done.subtitle"));
+
+ auto body =
+ new QLabel(obs_module_text("FirstRunWizard.done.body"), this);
+ body->setWordWrap(true);
+ body->setTextFormat(Qt::RichText);
+ body->setOpenExternalLinks(true);
+
+ auto layout = new QVBoxLayout(this);
+ layout->addWidget(body);
+ layout->addStretch();
+}
+
+// ===========================================================================
+// FirstRunWizard
+// ===========================================================================
+
+FirstRunWizard::FirstRunWizard(QWidget *parent) : QWizard(parent)
+{
+ setWindowTitle(obs_module_text("FirstRunWizard.windowTitle"));
+ setWizardStyle(QWizard::ModernStyle);
+ setMinimumSize(540, 420);
+
+ setPage(PAGE_WELCOME, new WelcomePage(this));
+ setPage(PAGE_SCENE, new SceneSelectionPage(this));
+ setPage(PAGE_WINDOW, new WindowConditionPage(this));
+ setPage(PAGE_REVIEW, new ReviewPage(this, _macro));
+ setPage(PAGE_DONE, new DonePage(this));
+
+ setStartId(PAGE_WELCOME);
+ setOption(QWizard::NoBackButtonOnLastPage, true);
+ setOption(QWizard::NoCancelButtonOnLastPage, true);
+
+ // Mark done on both Accept (Finish) and Reject (Cancel / close)
+ connect(this, &QWizard::accepted, this,
+ &FirstRunWizard::markFirstRunComplete);
+ connect(this, &QWizard::rejected, this,
+ &FirstRunWizard::markFirstRunComplete);
+}
+
+void FirstRunWizard::markFirstRunComplete()
+{
+ WriteFirstRun(false);
+}
+
+// static
+std::shared_ptr FirstRunWizard::ShowWizard(QWidget *parent)
+{
+ auto wizard = new FirstRunWizard(parent);
+ wizard->exec();
+ wizard->deleteLater();
+ return wizard->_macro;
+}
+
+// static
+bool FirstRunWizard::CreateMacro(std::shared_ptr ¯o,
+ const std::string ¯oName,
+ const std::string &conditionId,
+ obs_data_t *conditionData,
+ const std::string &actionId,
+ obs_data_t *actionData)
+{
+ // 1. Create and register the Macro
+ macro = std::make_shared(macroName, GetGlobalMacroSettings());
+ if (!macro) {
+ blog(LOG_WARNING, "FirstRunWizard: Macro allocation failed");
+ return false;
+ }
+
+ // 2. Instantiate condition via factory, then hydrate via Load()
+ auto condition =
+ MacroConditionFactory::Create(conditionId, macro.get());
+ if (!condition) {
+ blog(LOG_WARNING,
+ "FirstRunWizard: condition factory returned null "
+ "for id '%s' — is the base plugin loaded?",
+ conditionId.c_str());
+ return false;
+ }
+ if (!condition->Load(conditionData)) {
+ blog(LOG_WARNING,
+ "FirstRunWizard: condition Load() failed for id '%s'",
+ conditionId.c_str());
+ return false;
+ }
+ macro->Conditions().emplace_back(condition);
+
+ // 3. Instantiate action via factory, then hydrate via Load()
+ auto action = MacroActionFactory::Create(actionId, macro.get());
+ if (!action) {
+ blog(LOG_WARNING,
+ "FirstRunWizard: action factory returned null "
+ "for id '%s' — is the base plugin loaded?",
+ actionId.c_str());
+ return false;
+ }
+ if (!action->Load(actionData)) {
+ blog(LOG_WARNING,
+ "FirstRunWizard: action Load() failed for id '%s'",
+ actionId.c_str());
+ return false;
+ }
+ macro->Actions().emplace_back(action);
+
+ blog(LOG_INFO, "FirstRunWizard: created macro '%s'", macroName.c_str());
+ return true;
+}
+
+} // namespace advss
diff --git a/lib/utils/first-run-wizard.hpp b/lib/utils/first-run-wizard.hpp
new file mode 100644
index 00000000..d39d9b7a
--- /dev/null
+++ b/lib/utils/first-run-wizard.hpp
@@ -0,0 +1,128 @@
+#pragma once
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+namespace advss {
+
+class Macro;
+
+bool IsFirstRun();
+
+// ---------------------------------------------------------------------------
+// Page IDs
+// ---------------------------------------------------------------------------
+enum WizardPageId {
+ PAGE_WELCOME = 0,
+ PAGE_SCENE,
+ PAGE_WINDOW,
+ PAGE_REVIEW,
+ PAGE_DONE,
+};
+
+// ---------------------------------------------------------------------------
+// WelcomePage
+// ---------------------------------------------------------------------------
+class WelcomePage : public QWizardPage {
+ Q_OBJECT
+public:
+ explicit WelcomePage(QWidget *parent = nullptr);
+ int nextId() const override { return PAGE_SCENE; }
+};
+
+// ---------------------------------------------------------------------------
+// SceneSelectionPage
+// Registers wizard field "targetScene" (QString).
+// ---------------------------------------------------------------------------
+class SceneSelectionPage : public QWizardPage {
+ Q_OBJECT
+public:
+ explicit SceneSelectionPage(QWidget *parent = nullptr);
+ void initializePage() override;
+ bool isComplete() const override;
+ int nextId() const override { return PAGE_WINDOW; }
+
+private:
+ QComboBox *_sceneCombo;
+};
+
+// ---------------------------------------------------------------------------
+// WindowConditionPage
+// Registers wizard field "windowTitle" (QString).
+// Auto-detect button samples the focused window after a countdown.
+// ---------------------------------------------------------------------------
+class WindowConditionPage : public QWizardPage {
+ Q_OBJECT
+public:
+ explicit WindowConditionPage(QWidget *parent = nullptr);
+ void initializePage() override;
+ bool isComplete() const override;
+ int nextId() const override { return PAGE_REVIEW; }
+
+private slots:
+ void onAutoDetectClicked();
+ void onCountdownTick();
+
+private:
+ QLineEdit *_windowEdit;
+ QPushButton *_autoDetect;
+ QTimer *_detectTimer;
+ int _countdown = 3;
+};
+
+// ---------------------------------------------------------------------------
+// ReviewPage
+// Displays a summary and calls FirstRunWizard::CreateMacro() on Finish.
+// ---------------------------------------------------------------------------
+class ReviewPage : public QWizardPage {
+ Q_OBJECT
+public:
+ explicit ReviewPage(QWidget *parent, std::shared_ptr ¯o);
+ void initializePage() override;
+ bool validatePage() override;
+ int nextId() const override { return PAGE_DONE; }
+
+private:
+ QLabel *_summary;
+ std::shared_ptr &_macro;
+};
+
+// ---------------------------------------------------------------------------
+// DonePage
+// ---------------------------------------------------------------------------
+class DonePage : public QWizardPage {
+ Q_OBJECT
+public:
+ explicit DonePage(QWidget *parent = nullptr);
+ int nextId() const override { return -1; }
+};
+
+// ---------------------------------------------------------------------------
+// FirstRunWizard
+// ---------------------------------------------------------------------------
+class FirstRunWizard : public QWizard {
+ Q_OBJECT
+public:
+ explicit FirstRunWizard(QWidget *parent = nullptr);
+ static std::shared_ptr ShowWizard(QWidget *parent);
+ static bool
+ CreateMacro(std::shared_ptr ¯o, const std::string ¯oName,
+ const std::string &conditionId, obs_data_t *conditionData,
+ const std::string &actionId, obs_data_t *actionData);
+
+private:
+ void markFirstRunComplete();
+
+ std::shared_ptr _macro;
+};
+
+} // namespace advss