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