1 #include "obs-module.h"
2 #include "scripts.hpp"
3 #include "frontend-tools-config.h"
4 #include "../../properties-view.hpp"
5 #include "../../qt-wrappers.hpp"
6
7 #include <QFileDialog>
8 #include <QPlainTextEdit>
9 #include <QHBoxLayout>
10 #include <QVBoxLayout>
11 #include <QScrollBar>
12 #include <QPushButton>
13 #include <QFontDatabase>
14 #include <QFont>
15 #include <QDialogButtonBox>
16 #include <QResizeEvent>
17 #include <QAction>
18 #include <QMessageBox>
19 #include <QMenu>
20 #include <QUrl>
21 #include <QDesktopServices>
22
23 #include <obs.hpp>
24 #include <obs-module.h>
25 #include <obs-frontend-api.h>
26 #include <obs-scripting.h>
27
28 #include <util/config-file.h>
29 #include <util/platform.h>
30 #include <util/util.hpp>
31
32 #include <string>
33
34 #include "ui_scripts.h"
35
36 #if COMPILE_PYTHON && (defined(_WIN32) || defined(__APPLE__))
37 #define PYTHON_UI 1
38 #else
39 #define PYTHON_UI 0
40 #endif
41
42 #if ARCH_BITS == 64
43 #define ARCH_NAME "64bit"
44 #else
45 #define ARCH_NAME "32bit"
46 #endif
47
48 #define PYTHONPATH_LABEL_TEXT "PythonSettings.PythonInstallPath" ARCH_NAME
49
50 /* ----------------------------------------------------------------- */
51
52 using OBSScript = OBSObj<obs_script_t *, obs_script_destroy>;
53
54 struct ScriptData {
55 std::vector<OBSScript> scripts;
56
FindScriptScriptData57 inline obs_script_t *FindScript(const char *path)
58 {
59 for (OBSScript &script : scripts) {
60 const char *script_path = obs_script_get_path(script);
61 if (strcmp(script_path, path) == 0) {
62 return script;
63 }
64 }
65
66 return nullptr;
67 }
68
ScriptOpenedScriptData69 bool ScriptOpened(const char *path)
70 {
71 for (OBSScript &script : scripts) {
72 const char *script_path = obs_script_get_path(script);
73 if (strcmp(script_path, path) == 0) {
74 return true;
75 }
76 }
77
78 return false;
79 }
80 };
81
82 static ScriptData *scriptData = nullptr;
83 static ScriptsTool *scriptsWindow = nullptr;
84 static ScriptLogWindow *scriptLogWindow = nullptr;
85 static QPlainTextEdit *scriptLogWidget = nullptr;
86
87 /* ----------------------------------------------------------------- */
88
ScriptLogWindow()89 ScriptLogWindow::ScriptLogWindow() : QWidget(nullptr)
90 {
91 const QFont fixedFont =
92 QFontDatabase::systemFont(QFontDatabase::FixedFont);
93
94 QPlainTextEdit *edit = new QPlainTextEdit();
95 edit->setReadOnly(true);
96 edit->setFont(fixedFont);
97 edit->setWordWrapMode(QTextOption::NoWrap);
98
99 QHBoxLayout *buttonLayout = new QHBoxLayout();
100 QPushButton *clearButton = new QPushButton(tr("Clear"));
101 connect(clearButton, &QPushButton::clicked, this,
102 &ScriptLogWindow::ClearWindow);
103 QPushButton *closeButton = new QPushButton(tr("Close"));
104 connect(closeButton, &QPushButton::clicked, this, &QDialog::hide);
105
106 buttonLayout->addStretch();
107 buttonLayout->addWidget(clearButton);
108 buttonLayout->addWidget(closeButton);
109
110 QVBoxLayout *layout = new QVBoxLayout();
111 layout->addWidget(edit);
112 layout->addLayout(buttonLayout);
113
114 setLayout(layout);
115 scriptLogWidget = edit;
116
117 resize(600, 400);
118
119 config_t *global_config = obs_frontend_get_global_config();
120 const char *geom =
121 config_get_string(global_config, "ScriptLogWindow", "geometry");
122 if (geom != nullptr) {
123 QByteArray ba = QByteArray::fromBase64(QByteArray(geom));
124 restoreGeometry(ba);
125 }
126
127 setWindowTitle(obs_module_text("ScriptLogWindow"));
128
129 connect(edit->verticalScrollBar(), &QAbstractSlider::sliderMoved, this,
130 &ScriptLogWindow::ScrollChanged);
131 }
132
~ScriptLogWindow()133 ScriptLogWindow::~ScriptLogWindow()
134 {
135 config_t *global_config = obs_frontend_get_global_config();
136 config_set_string(global_config, "ScriptLogWindow", "geometry",
137 saveGeometry().toBase64().constData());
138 }
139
ScrollChanged(int val)140 void ScriptLogWindow::ScrollChanged(int val)
141 {
142 QScrollBar *scroll = scriptLogWidget->verticalScrollBar();
143 bottomScrolled = (val == scroll->maximum());
144 }
145
resizeEvent(QResizeEvent * event)146 void ScriptLogWindow::resizeEvent(QResizeEvent *event)
147 {
148 QWidget::resizeEvent(event);
149
150 if (bottomScrolled) {
151 QScrollBar *scroll = scriptLogWidget->verticalScrollBar();
152 scroll->setValue(scroll->maximum());
153 }
154 }
155
AddLogMsg(int log_level,QString msg)156 void ScriptLogWindow::AddLogMsg(int log_level, QString msg)
157 {
158 QScrollBar *scroll = scriptLogWidget->verticalScrollBar();
159 bottomScrolled = scroll->value() == scroll->maximum();
160
161 lines += QStringLiteral("\n");
162 lines += msg;
163 scriptLogWidget->setPlainText(lines);
164
165 if (bottomScrolled)
166 scroll->setValue(scroll->maximum());
167
168 if (log_level <= LOG_WARNING) {
169 show();
170 raise();
171 }
172 }
173
ClearWindow()174 void ScriptLogWindow::ClearWindow()
175 {
176 Clear();
177 scriptLogWidget->setPlainText(QString());
178 }
179
Clear()180 void ScriptLogWindow::Clear()
181 {
182 lines.clear();
183 }
184
185 /* ----------------------------------------------------------------- */
186
ScriptsTool()187 ScriptsTool::ScriptsTool() : QWidget(nullptr), ui(new Ui_ScriptsTool)
188 {
189 ui->setupUi(this);
190 RefreshLists();
191
192 #if PYTHON_UI
193 config_t *config = obs_frontend_get_global_config();
194 const char *path =
195 config_get_string(config, "Python", "Path" ARCH_NAME);
196 ui->pythonPath->setText(path);
197 ui->pythonPathLabel->setText(obs_module_text(PYTHONPATH_LABEL_TEXT));
198 #else
199 delete ui->pythonSettingsTab;
200 ui->pythonSettingsTab = nullptr;
201 ui->tabWidget->setStyleSheet("QTabWidget::pane {border: 0;}");
202 #endif
203
204 delete propertiesView;
205 propertiesView = new QWidget();
206 propertiesView->setSizePolicy(QSizePolicy::Expanding,
207 QSizePolicy::Expanding);
208 ui->propertiesLayout->addWidget(propertiesView);
209
210 config_t *global_config = obs_frontend_get_global_config();
211 int row =
212 config_get_int(global_config, "scripts-tool", "prevScriptRow");
213 ui->scripts->setCurrentRow(row);
214 }
215
~ScriptsTool()216 ScriptsTool::~ScriptsTool()
217 {
218 config_t *global_config = obs_frontend_get_global_config();
219 config_set_int(global_config, "scripts-tool", "prevScriptRow",
220 ui->scripts->currentRow());
221
222 delete ui;
223 }
224
RemoveScript(const char * path)225 void ScriptsTool::RemoveScript(const char *path)
226 {
227 for (size_t i = 0; i < scriptData->scripts.size(); i++) {
228 OBSScript &script = scriptData->scripts[i];
229
230 const char *script_path = obs_script_get_path(script);
231 if (strcmp(script_path, path) == 0) {
232 scriptData->scripts.erase(scriptData->scripts.begin() +
233 i);
234 break;
235 }
236 }
237 }
238
ReloadScript(const char * path)239 void ScriptsTool::ReloadScript(const char *path)
240 {
241 for (OBSScript &script : scriptData->scripts) {
242 const char *script_path = obs_script_get_path(script);
243 if (strcmp(script_path, path) == 0) {
244 obs_script_reload(script);
245
246 OBSData settings = obs_data_create();
247 obs_data_release(settings);
248
249 obs_properties_t *prop =
250 obs_script_get_properties(script);
251 obs_properties_apply_settings(prop, settings);
252 obs_properties_destroy(prop);
253
254 break;
255 }
256 }
257 }
258
RefreshLists()259 void ScriptsTool::RefreshLists()
260 {
261 ui->scripts->clear();
262
263 for (OBSScript &script : scriptData->scripts) {
264 const char *script_file = obs_script_get_file(script);
265 const char *script_path = obs_script_get_path(script);
266
267 QListWidgetItem *item = new QListWidgetItem(script_file);
268 item->setData(Qt::UserRole, QString(script_path));
269 ui->scripts->addItem(item);
270 }
271 }
272
SetScriptDefaults(const char * path)273 void ScriptsTool::SetScriptDefaults(const char *path)
274 {
275 for (OBSScript &script : scriptData->scripts) {
276 const char *script_path = obs_script_get_path(script);
277 if (strcmp(script_path, path) == 0) {
278 obs_data_t *settings = obs_script_get_settings(script);
279 obs_data_clear(settings);
280 obs_data_release(settings);
281
282 obs_script_update(script, nullptr);
283 on_reloadScripts_clicked();
284 break;
285 }
286 }
287 }
288
on_close_clicked()289 void ScriptsTool::on_close_clicked()
290 {
291 close();
292 }
293
on_addScripts_clicked()294 void ScriptsTool::on_addScripts_clicked()
295 {
296 const char **formats = obs_scripting_supported_formats();
297 const char **cur_format = formats;
298 QString extensions;
299 QString filter;
300
301 while (*cur_format) {
302 if (!extensions.isEmpty())
303 extensions += QStringLiteral(" ");
304
305 extensions += QStringLiteral("*.");
306 extensions += *cur_format;
307
308 cur_format++;
309 }
310
311 if (!extensions.isEmpty()) {
312 filter += obs_module_text("FileFilter.ScriptFiles");
313 filter += QStringLiteral(" (");
314 filter += extensions;
315 filter += QStringLiteral(")");
316 }
317
318 if (filter.isEmpty())
319 return;
320
321 static std::string lastBrowsedDir;
322
323 if (lastBrowsedDir.empty()) {
324 BPtr<char> baseScriptPath = obs_module_file("scripts");
325 lastBrowsedDir = baseScriptPath;
326 }
327
328 QStringList files = OpenFiles(this,
329 QT_UTF8(obs_module_text("AddScripts")),
330 QT_UTF8(lastBrowsedDir.c_str()), filter);
331 if (!files.count())
332 return;
333
334 for (const QString &file : files) {
335 lastBrowsedDir =
336 QFileInfo(file).absolutePath().toUtf8().constData();
337
338 QByteArray pathBytes = file.toUtf8();
339 const char *path = pathBytes.constData();
340
341 if (scriptData->ScriptOpened(path)) {
342 continue;
343 }
344
345 obs_script_t *script = obs_script_create(path, NULL);
346 if (script) {
347 const char *script_file = obs_script_get_file(script);
348
349 scriptData->scripts.emplace_back(script);
350
351 QListWidgetItem *item =
352 new QListWidgetItem(script_file);
353 item->setData(Qt::UserRole, QString(file));
354 ui->scripts->addItem(item);
355
356 OBSData settings = obs_data_create();
357 obs_data_release(settings);
358
359 obs_properties_t *prop =
360 obs_script_get_properties(script);
361 obs_properties_apply_settings(prop, settings);
362 obs_properties_destroy(prop);
363
364 ui->scripts->setCurrentItem(item);
365 }
366 }
367 }
368
on_removeScripts_clicked()369 void ScriptsTool::on_removeScripts_clicked()
370 {
371 QList<QListWidgetItem *> items = ui->scripts->selectedItems();
372
373 for (QListWidgetItem *item : items)
374 RemoveScript(item->data(Qt::UserRole)
375 .toString()
376 .toUtf8()
377 .constData());
378 RefreshLists();
379 }
380
on_reloadScripts_clicked()381 void ScriptsTool::on_reloadScripts_clicked()
382 {
383 QList<QListWidgetItem *> items = ui->scripts->selectedItems();
384 for (QListWidgetItem *item : items)
385 ReloadScript(item->data(Qt::UserRole)
386 .toString()
387 .toUtf8()
388 .constData());
389
390 on_scripts_currentRowChanged(ui->scripts->currentRow());
391 }
392
OpenScriptParentDirectory()393 void ScriptsTool::OpenScriptParentDirectory()
394 {
395 QList<QListWidgetItem *> items = ui->scripts->selectedItems();
396 for (QListWidgetItem *item : items) {
397 QDir dir(item->data(Qt::UserRole).toString());
398 dir.cdUp();
399 QDesktopServices::openUrl(
400 QUrl::fromLocalFile(dir.absolutePath()));
401 }
402 }
403
on_scripts_customContextMenuRequested(const QPoint & pos)404 void ScriptsTool::on_scripts_customContextMenuRequested(const QPoint &pos)
405 {
406
407 QListWidgetItem *item = ui->scripts->itemAt(pos);
408
409 QMenu popup(this);
410
411 obs_frontend_push_ui_translation(obs_module_get_string);
412
413 popup.addAction(tr("Add"), this, SLOT(on_addScripts_clicked()));
414
415 if (item) {
416 popup.addSeparator();
417 popup.addAction(obs_module_text("Reload"), this,
418 SLOT(on_reloadScripts_clicked()));
419 popup.addAction(obs_module_text("OpenFileLocation"), this,
420 SLOT(OpenScriptParentDirectory()));
421 popup.addSeparator();
422 popup.addAction(tr("Remove"), this,
423 SLOT(on_removeScripts_clicked()));
424 }
425 obs_frontend_pop_ui_translation();
426
427 popup.exec(QCursor::pos());
428 }
429
on_editScript_clicked()430 void ScriptsTool::on_editScript_clicked()
431 {
432 int row = ui->scripts->currentRow();
433 if (row == -1)
434 return;
435 QUrl url = QUrl::fromLocalFile(
436 ui->scripts->item(row)->data(Qt::UserRole).toString());
437 QDesktopServices::openUrl(url);
438 }
439
on_scriptLog_clicked()440 void ScriptsTool::on_scriptLog_clicked()
441 {
442 scriptLogWindow->show();
443 scriptLogWindow->raise();
444 }
445
on_pythonPathBrowse_clicked()446 void ScriptsTool::on_pythonPathBrowse_clicked()
447 {
448 QString curPath = ui->pythonPath->text();
449 QString newPath =
450 SelectDirectory(this, ui->pythonPathLabel->text(), curPath);
451
452 if (newPath.isEmpty())
453 return;
454
455 QByteArray array = newPath.toUtf8();
456 const char *path = array.constData();
457
458 config_t *config = obs_frontend_get_global_config();
459 config_set_string(config, "Python", "Path" ARCH_NAME, path);
460
461 ui->pythonPath->setText(newPath);
462
463 if (obs_scripting_python_loaded())
464 return;
465 if (!obs_scripting_load_python(path))
466 return;
467
468 for (OBSScript &script : scriptData->scripts) {
469 enum obs_script_lang lang = obs_script_get_lang(script);
470 if (lang == OBS_SCRIPT_LANG_PYTHON) {
471 obs_script_reload(script);
472 }
473 }
474
475 on_scripts_currentRowChanged(ui->scripts->currentRow());
476 }
477
on_scripts_currentRowChanged(int row)478 void ScriptsTool::on_scripts_currentRowChanged(int row)
479 {
480 ui->propertiesLayout->removeWidget(propertiesView);
481 delete propertiesView;
482
483 if (row == -1) {
484 propertiesView = new QWidget();
485 propertiesView->setSizePolicy(QSizePolicy::Expanding,
486 QSizePolicy::Expanding);
487 ui->propertiesLayout->addWidget(propertiesView);
488 ui->description->setText(QString());
489 return;
490 }
491
492 QByteArray array =
493 ui->scripts->item(row)->data(Qt::UserRole).toString().toUtf8();
494 const char *path = array.constData();
495
496 obs_script_t *script = scriptData->FindScript(path);
497 if (!script) {
498 propertiesView = nullptr;
499 return;
500 }
501
502 OBSData settings = obs_script_get_settings(script);
503 obs_data_release(settings);
504
505 propertiesView = new OBSPropertiesView(
506 settings, script,
507 (PropertiesReloadCallback)obs_script_get_properties, nullptr,
508 (PropertiesVisualUpdateCb)obs_script_update);
509 ui->propertiesLayout->addWidget(propertiesView);
510 ui->description->setText(obs_script_get_description(script));
511 }
512
on_defaults_clicked()513 void ScriptsTool::on_defaults_clicked()
514 {
515 QListWidgetItem *item = ui->scripts->currentItem();
516 if (!item)
517 return;
518
519 SetScriptDefaults(
520 item->data(Qt::UserRole).toString().toUtf8().constData());
521 }
522
on_description_linkActivated(const QString & link)523 void ScriptsTool::on_description_linkActivated(const QString &link)
524 {
525 QUrl url(link, QUrl::StrictMode);
526 if (url.isValid() && (url.scheme().compare("http") == 0 ||
527 url.scheme().compare("https") == 0)) {
528 QString msg(obs_module_text("ScriptDescriptionLink.Text"));
529 msg += "\n\n";
530 msg += QString(obs_module_text(
531 "ScriptDescriptionLink.Text.Url"))
532 .arg(link);
533
534 const char *open =
535 obs_module_text("ScriptDescriptionLink.OpenURL");
536
537 QMessageBox messageBox(this);
538 messageBox.setWindowTitle(open);
539 messageBox.setText(msg);
540
541 obs_frontend_push_ui_translation(obs_module_get_string);
542 QPushButton *yesButton =
543 messageBox.addButton(open, QMessageBox::YesRole);
544 QPushButton *noButton =
545 messageBox.addButton(tr("Cancel"), QMessageBox::NoRole);
546 obs_frontend_pop_ui_translation();
547 messageBox.setDefaultButton(yesButton);
548 messageBox.setEscapeButton(noButton);
549 messageBox.setIcon(QMessageBox::Question);
550 messageBox.exec();
551
552 if (messageBox.clickedButton() == yesButton)
553 QDesktopServices::openUrl(url);
554 }
555 }
556
557 /* ----------------------------------------------------------------- */
558
FreeScripts()559 extern "C" void FreeScripts()
560 {
561 obs_scripting_unload();
562 }
563
obs_event(enum obs_frontend_event event,void *)564 static void obs_event(enum obs_frontend_event event, void *)
565 {
566 if (event == OBS_FRONTEND_EVENT_EXIT) {
567 delete scriptData;
568 delete scriptsWindow;
569 delete scriptLogWindow;
570
571 scriptData = nullptr;
572 scriptsWindow = nullptr;
573 scriptLogWindow = nullptr;
574
575 } else if (event == OBS_FRONTEND_EVENT_SCENE_COLLECTION_CLEANUP) {
576 if (scriptLogWindow) {
577 scriptLogWindow->hide();
578 scriptLogWindow->Clear();
579 }
580
581 delete scriptData;
582 scriptData = new ScriptData;
583 }
584 }
585
load_script_data(obs_data_t * load_data,bool,void *)586 static void load_script_data(obs_data_t *load_data, bool, void *)
587 {
588 obs_data_array_t *array = obs_data_get_array(load_data, "scripts-tool");
589
590 delete scriptData;
591 scriptData = new ScriptData;
592
593 size_t size = obs_data_array_count(array);
594 for (size_t i = 0; i < size; i++) {
595 obs_data_t *obj = obs_data_array_item(array, i);
596 const char *path = obs_data_get_string(obj, "path");
597 obs_data_t *settings = obs_data_get_obj(obj, "settings");
598
599 obs_script_t *script = obs_script_create(path, settings);
600 if (script) {
601 scriptData->scripts.emplace_back(script);
602 }
603
604 obs_data_release(settings);
605 obs_data_release(obj);
606 }
607
608 if (scriptsWindow)
609 scriptsWindow->RefreshLists();
610
611 obs_data_array_release(array);
612 }
613
save_script_data(obs_data_t * save_data,bool saving,void *)614 static void save_script_data(obs_data_t *save_data, bool saving, void *)
615 {
616 if (!saving)
617 return;
618
619 obs_data_array_t *array = obs_data_array_create();
620
621 for (OBSScript &script : scriptData->scripts) {
622 const char *script_path = obs_script_get_path(script);
623 obs_data_t *settings = obs_script_save(script);
624
625 obs_data_t *obj = obs_data_create();
626 obs_data_set_string(obj, "path", script_path);
627 obs_data_set_obj(obj, "settings", settings);
628 obs_data_array_push_back(array, obj);
629 obs_data_release(obj);
630
631 obs_data_release(settings);
632 }
633
634 obs_data_set_array(save_data, "scripts-tool", array);
635 obs_data_array_release(array);
636 }
637
script_log(void *,obs_script_t * script,int log_level,const char * message)638 static void script_log(void *, obs_script_t *script, int log_level,
639 const char *message)
640 {
641 QString qmsg;
642
643 if (script) {
644 qmsg = QStringLiteral("[%1] %2").arg(
645 obs_script_get_file(script), message);
646 } else {
647 qmsg = QStringLiteral("[Unknown Script] %1").arg(message);
648 }
649
650 QMetaObject::invokeMethod(scriptLogWindow, "AddLogMsg",
651 Q_ARG(int, log_level), Q_ARG(QString, qmsg));
652 }
653
InitScripts()654 extern "C" void InitScripts()
655 {
656 scriptLogWindow = new ScriptLogWindow();
657
658 obs_scripting_load();
659 obs_scripting_set_log_callback(script_log, nullptr);
660
661 QAction *action = (QAction *)obs_frontend_add_tools_menu_qaction(
662 obs_module_text("Scripts"));
663
664 #if PYTHON_UI
665 config_t *config = obs_frontend_get_global_config();
666 const char *python_path =
667 config_get_string(config, "Python", "Path" ARCH_NAME);
668
669 if (!obs_scripting_python_loaded() && python_path && *python_path)
670 obs_scripting_load_python(python_path);
671 #endif
672
673 scriptData = new ScriptData;
674
675 auto cb = []() {
676 obs_frontend_push_ui_translation(obs_module_get_string);
677
678 if (!scriptsWindow) {
679 scriptsWindow = new ScriptsTool();
680 scriptsWindow->show();
681 } else {
682 scriptsWindow->show();
683 scriptsWindow->raise();
684 }
685
686 obs_frontend_pop_ui_translation();
687 };
688
689 obs_frontend_add_save_callback(save_script_data, nullptr);
690 obs_frontend_add_preload_callback(load_script_data, nullptr);
691 obs_frontend_add_event_callback(obs_event, nullptr);
692
693 action->connect(action, &QAction::triggered, cb);
694 }
695