#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr const char* kAppId = "io.github.transui"; constexpr const char* kConfigGroup = "transui"; const SecretSchema kSecretSchema = { "io.github.transui.credentials", SECRET_SCHEMA_NONE, {{"scope", SECRET_SCHEMA_ATTRIBUTE_STRING}, {nullptr, SECRET_SCHEMA_ATTRIBUTE_STRING}}}; struct Config { std::string hosted_url; std::string local_url = "http://localhost:8080"; std::string prompt; bool hosted_first = true; bool clipboard = false; }; struct App { GtkApplication* gtk_app = nullptr; Config config; std::string hosted_key; std::string local_key; GtkWidget* settings = nullptr; GtkWidget* recorder = nullptr; GtkWidget* processing = nullptr; GtkWidget* elapsed_label = nullptr; guint elapsed_source = 0; guint elapsed_seconds = 0; GstElement* pipeline = nullptr; std::string recording_path; std::atomic cancelled{false}; }; struct SettingsFields { GtkWidget* hosted_url; GtkWidget* hosted_key; GtkWidget* local_url; GtkWidget* local_key; GtkWidget* prompt; GtkWidget* hosted_first; GtkWidget* destination; }; struct Job { App* app; std::string file; Config config; std::string hosted_key; std::string local_key; bool ok = false; std::string text; std::string error; }; std::string config_path() { return std::string(g_get_user_config_dir()) + "/transui/settings.ini"; } std::string endpoint_url(std::string url) { while (!url.empty() && url.back() == '/') url.pop_back(); if (url.find("/v1/audio/transcriptions") != std::string::npos) return url; return url + "/v1/audio/transcriptions"; } void show_error(GtkWindow* parent, const std::string& message) { GtkWidget* dialog = gtk_message_dialog_new(parent, GTK_DIALOG_MODAL, GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "%s", message.c_str()); gtk_dialog_run(GTK_DIALOG(dialog)); gtk_widget_destroy(dialog); } void show_info(GtkWindow* parent, const std::string& message) { GtkWidget* dialog = gtk_message_dialog_new(parent, GTK_DIALOG_MODAL, GTK_MESSAGE_INFO, GTK_BUTTONS_CLOSE, "%s", message.c_str()); gtk_dialog_run(GTK_DIALOG(dialog)); gtk_widget_destroy(dialog); } std::string secret_for(const char* scope) { GError* error = nullptr; gchar* value = secret_password_lookup_sync(&kSecretSchema, nullptr, &error, "scope", scope, nullptr); if (error) { g_error_free(error); return {}; } std::string result = value ? value : ""; secret_password_free(value); return result; } bool save_secret(const char* scope, const std::string& value, std::string* err) { GError* error = nullptr; gboolean ok = secret_password_store_sync(&kSecretSchema, SECRET_COLLECTION_DEFAULT, scope, value.c_str(), nullptr, &error, "scope", scope, nullptr); if (!ok && error) { *err = error->message; g_error_free(error); } return ok; } void load_config(App* app) { GKeyFile* file = g_key_file_new(); GError* error = nullptr; if (g_key_file_load_from_file(file, config_path().c_str(), G_KEY_FILE_NONE, &error)) { gchar* value = nullptr; value = g_key_file_get_string(file, kConfigGroup, "hosted_url", nullptr); if (value) { app->config.hosted_url = value; g_free(value); } value = g_key_file_get_string(file, kConfigGroup, "local_url", nullptr); if (value) { app->config.local_url = value; g_free(value); } value = g_key_file_get_string(file, kConfigGroup, "prompt", nullptr); if (value) { app->config.prompt = value; g_free(value); } if (g_key_file_has_key(file, kConfigGroup, "hosted_first", nullptr)) app->config.hosted_first = g_key_file_get_boolean(file, kConfigGroup, "hosted_first", nullptr); if (g_key_file_has_key(file, kConfigGroup, "clipboard", nullptr)) app->config.clipboard = g_key_file_get_boolean(file, kConfigGroup, "clipboard", nullptr); } if (error) g_error_free(error); g_key_file_unref(file); app->hosted_key = secret_for("hosted"); app->local_key = secret_for("local"); } bool save_config(App* app, const Config& config, const std::string& hosted_key, const std::string& local_key, std::string* err) { GKeyFile* file = g_key_file_new(); g_key_file_set_string(file, kConfigGroup, "hosted_url", config.hosted_url.c_str()); g_key_file_set_string(file, kConfigGroup, "local_url", config.local_url.c_str()); g_key_file_set_string(file, kConfigGroup, "prompt", config.prompt.c_str()); g_key_file_set_boolean(file, kConfigGroup, "hosted_first", config.hosted_first); g_key_file_set_boolean(file, kConfigGroup, "clipboard", config.clipboard); gsize length = 0; gchar* contents = g_key_file_to_data(file, &length, nullptr); gchar* folder = g_path_get_dirname(config_path().c_str()); g_mkdir_with_parents(folder, 0700); GError* error = nullptr; const bool saved = g_file_set_contents(config_path().c_str(), contents, length, &error); g_free(folder); g_free(contents); g_key_file_unref(file); if (!saved) { *err = error ? error->message : "Could not save settings"; if (error) g_error_free(error); return false; } if (!save_secret("hosted", hosted_key, err) || !save_secret("local", local_key, err)) return false; app->config = config; app->hosted_key = hosted_key; app->local_key = local_key; return true; } GtkWidget* labelled(GtkWidget* grid, const char* text, GtkWidget* control, int row) { GtkWidget* label = gtk_label_new(text); gtk_label_set_xalign(GTK_LABEL(label), 0); gtk_widget_set_valign(label, GTK_ALIGN_CENTER); gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1); gtk_widget_set_hexpand(control, true); gtk_grid_attach(GTK_GRID(grid), control, 1, row, 1, 1); return control; } void settings_closed(GtkWidget*, gpointer data) { static_cast(data)->settings = nullptr; } void save_clicked(GtkButton*, gpointer data) { auto* fields = static_cast(data); App* app = static_cast(g_object_get_data(G_OBJECT(fields->hosted_url), "app")); Config config; config.hosted_url = gtk_entry_get_text(GTK_ENTRY(fields->hosted_url)); config.local_url = gtk_entry_get_text(GTK_ENTRY(fields->local_url)); config.prompt = gtk_entry_get_text(GTK_ENTRY(fields->prompt)); config.hosted_first = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(fields->hosted_first)); config.clipboard = gtk_combo_box_get_active(GTK_COMBO_BOX(fields->destination)) == 1; std::string error; if (!save_config(app, config, gtk_entry_get_text(GTK_ENTRY(fields->hosted_key)), gtk_entry_get_text(GTK_ENTRY(fields->local_key)), &error)) { show_error(GTK_WINDOW(app->settings), error); return; } gtk_widget_destroy(app->settings); } void open_settings(App* app) { if (app->settings) { gtk_window_present(GTK_WINDOW(app->settings)); return; } GtkWidget* window = gtk_application_window_new(app->gtk_app); app->settings = window; gtk_window_set_title(GTK_WINDOW(window), "Transui settings"); gtk_window_set_default_size(GTK_WINDOW(window), 590, 0); gtk_window_set_resizable(GTK_WINDOW(window), false); GtkWidget* root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_container_add(GTK_CONTAINER(window), root); GtkWidget* header = gtk_header_bar_new(); gtk_header_bar_set_show_close_button(GTK_HEADER_BAR(header), true); gtk_header_bar_set_title(GTK_HEADER_BAR(header), "Transui"); gtk_window_set_titlebar(GTK_WINDOW(window), header); GtkWidget* grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(grid), 12); gtk_grid_set_column_spacing(GTK_GRID(grid), 18); gtk_widget_set_margin_start(grid, 28); gtk_widget_set_margin_end(grid, 28); gtk_widget_set_margin_top(grid, 26); gtk_widget_set_margin_bottom(grid, 18); gtk_box_pack_start(GTK_BOX(root), grid, true, true, 0); auto* fields = new SettingsFields(); fields->hosted_url = labelled(grid, "Hosted endpoint", gtk_entry_new(), 0); fields->hosted_key = labelled(grid, "Hosted API key", gtk_entry_new(), 1); fields->local_url = labelled(grid, "Local endpoint", gtk_entry_new(), 2); fields->local_key = labelled(grid, "Local API key", gtk_entry_new(), 3); fields->prompt = labelled(grid, "Prompt", gtk_entry_new(), 4); fields->hosted_first = gtk_check_button_new_with_label("Hosted first, local fallback"); gtk_grid_attach(GTK_GRID(grid), fields->hosted_first, 1, 5, 1, 1); fields->destination = gtk_combo_box_text_new(); gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Type at cursor"); gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Clipboard"); labelled(grid, "Output", fields->destination, 6); gtk_entry_set_visibility(GTK_ENTRY(fields->hosted_key), false); gtk_entry_set_visibility(GTK_ENTRY(fields->local_key), false); gtk_entry_set_text(GTK_ENTRY(fields->hosted_url), app->config.hosted_url.c_str()); gtk_entry_set_text(GTK_ENTRY(fields->hosted_key), app->hosted_key.c_str()); gtk_entry_set_text(GTK_ENTRY(fields->local_url), app->config.local_url.c_str()); gtk_entry_set_text(GTK_ENTRY(fields->local_key), app->local_key.c_str()); gtk_entry_set_text(GTK_ENTRY(fields->prompt), app->config.prompt.c_str()); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(fields->hosted_first), app->config.hosted_first); gtk_combo_box_set_active(GTK_COMBO_BOX(fields->destination), app->config.clipboard ? 1 : 0); g_object_set_data(G_OBJECT(fields->hosted_url), "app", app); GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_widget_set_margin_start(actions, 28); gtk_widget_set_margin_end(actions, 28); gtk_widget_set_margin_bottom(actions, 24); gtk_widget_set_halign(actions, GTK_ALIGN_END); GtkWidget* save = gtk_button_new_with_label("Save"); gtk_style_context_add_class(gtk_widget_get_style_context(save), "suggested-action"); gtk_box_pack_end(GTK_BOX(actions), save, false, false, 0); gtk_box_pack_start(GTK_BOX(root), actions, false, false, 0); g_signal_connect(save, "clicked", G_CALLBACK(save_clicked), fields); g_signal_connect_data(window, "destroy", G_CALLBACK(settings_closed), app, nullptr, G_CONNECT_AFTER); g_signal_connect_data(window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer p) { delete static_cast(p); }), fields, nullptr, G_CONNECT_AFTER); gtk_widget_show_all(window); } gboolean tick_elapsed(gpointer data) { auto* app = static_cast(data); app->elapsed_seconds++; const unsigned minutes = app->elapsed_seconds / 60; const unsigned seconds = app->elapsed_seconds % 60; gchar* text = g_strdup_printf("%02u:%02u", minutes, seconds); gtk_label_set_text(GTK_LABEL(app->elapsed_label), text); g_free(text); return G_SOURCE_CONTINUE; } void remove_recording(App* app) { if (!app->recording_path.empty()) { g_remove(app->recording_path.c_str()); app->recording_path.clear(); } } void close_recorder(App* app) { if (app->elapsed_source) { g_source_remove(app->elapsed_source); app->elapsed_source = 0; } if (app->pipeline) { gst_element_set_state(app->pipeline, GST_STATE_NULL); gst_object_unref(app->pipeline); app->pipeline = nullptr; } GtkWidget* window = app->recorder; app->recorder = nullptr; if (window) gtk_widget_destroy(window); } size_t curl_write(char* buffer, size_t size, size_t nmemb, void* data) { auto* output = static_cast(data); output->append(buffer, size * nmemb); return size * nmemb; } bool transcription_request(const std::string& url, const std::string& key, const std::string& path, const std::string& prompt, std::string* transcript, std::string* error) { CURL* curl = curl_easy_init(); if (!curl) { *error = "Could not initialize HTTP client"; return false; } std::string body; curl_mime* mime = curl_mime_init(curl); curl_mimepart* part = curl_mime_addpart(mime); curl_mime_name(part, "response_format"); curl_mime_data(part, "json", CURL_ZERO_TERMINATED); part = curl_mime_addpart(mime); curl_mime_name(part, "file"); curl_mime_filedata(part, path.c_str()); if (!prompt.empty()) { part = curl_mime_addpart(mime); curl_mime_name(part, "prompt"); curl_mime_data(part, prompt.c_str(), CURL_ZERO_TERMINATED); } struct curl_slist* headers = nullptr; if (!key.empty()) { headers = curl_slist_append(headers, ("Authorization: Bearer " + key).c_str()); } curl_easy_setopt(curl, CURLOPT_URL, endpoint_url(url).c_str()); curl_easy_setopt(curl, CURLOPT_MIMEPOST, mime); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_write); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 12L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 300L); CURLcode result = curl_easy_perform(curl); long status = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); curl_slist_free_all(headers); curl_mime_free(mime); curl_easy_cleanup(curl); if (result != CURLE_OK) { *error = curl_easy_strerror(result); return false; } if (status < 200 || status >= 300) { *error = "Server returned HTTP " + std::to_string(status); return false; } JsonParser* parser = json_parser_new(); GError* parse_error = nullptr; bool parsed = json_parser_load_from_data(parser, body.c_str(), body.length(), &parse_error); if (!parsed) { *error = "Invalid JSON response"; if (parse_error) g_error_free(parse_error); g_object_unref(parser); return false; } JsonNode* root = json_parser_get_root(parser); if (!JSON_NODE_HOLDS_OBJECT(root)) { *error = "Response does not contain a transcript"; g_object_unref(parser); return false; } JsonObject* object = json_node_get_object(root); if (!json_object_has_member(object, "text")) { *error = "Response does not contain a text field"; g_object_unref(parser); return false; } *transcript = json_object_get_string_member(object, "text"); g_object_unref(parser); return true; } void set_clipboard(const std::string& text) { GdkDisplay* display = gdk_display_get_default(); GtkClipboard* clipboard = gtk_clipboard_get_for_display(display, GDK_SELECTION_CLIPBOARD); gtk_clipboard_set_text(clipboard, text.c_str(), text.size()); gtk_clipboard_store(clipboard); } bool type_text(const std::string& text, std::string* error) { const bool wayland = g_getenv("WAYLAND_DISPLAY") && *g_getenv("WAYLAND_DISPLAY"); gchar* program = g_find_program_in_path(wayland ? "wtype" : "xdotool"); if (!program) { *error = wayland ? "wtype is not installed; copied to clipboard." : "xdotool is not installed; copied to clipboard."; return false; } std::vector argv; argv.push_back(program); if (!wayland) { argv.push_back(g_strdup("type")); argv.push_back(g_strdup("--delay")); argv.push_back(g_strdup("0")); argv.push_back(g_strdup("--")); } argv.push_back(g_strdup(text.c_str())); argv.push_back(nullptr); GError* spawn_error = nullptr; gboolean ok = g_spawn_async(nullptr, argv.data(), nullptr, G_SPAWN_SEARCH_PATH, nullptr, nullptr, nullptr, &spawn_error); for (size_t i = 0; i + 1 < argv.size(); ++i) g_free(argv[i]); if (!ok) { *error = spawn_error ? spawn_error->message : "Could not type text"; if (spawn_error) g_error_free(spawn_error); } return ok; } gboolean finish_job(gpointer data) { std::unique_ptr job(static_cast(data)); App* app = job->app; if (app->processing) { GtkWidget* window = app->processing; app->processing = nullptr; gtk_widget_destroy(window); } remove_recording(app); if (app->cancelled.load()) return G_SOURCE_REMOVE; if (!job->ok) { show_error(nullptr, job->error); return G_SOURCE_REMOVE; } if (job->config.clipboard) { set_clipboard(job->text); show_info(nullptr, "Transcript copied to clipboard."); return G_SOURCE_REMOVE; } std::string error; if (!type_text(job->text, &error)) { set_clipboard(job->text); show_error(nullptr, error); } return G_SOURCE_REMOVE; } gpointer job_thread(gpointer data) { auto* job = static_cast(data); std::string hosted_error; if (job->config.hosted_first && !job->config.hosted_url.empty()) { job->ok = transcription_request(job->config.hosted_url, job->hosted_key, job->file, job->config.prompt, &job->text, &hosted_error); if (job->ok) { g_main_context_invoke(nullptr, finish_job, job); return nullptr; } } job->ok = transcription_request(job->config.local_url, job->local_key, job->file, job->config.prompt, &job->text, &job->error); if (!job->ok && !hosted_error.empty()) job->error = "Hosted: " + hosted_error + "\nLocal: " + job->error; g_main_context_invoke(nullptr, finish_job, job); return nullptr; } void processing_closed(GtkWidget*, gpointer data) { auto* app = static_cast(data); app->processing = nullptr; } void cancel_processing(GtkButton* button, gpointer data) { auto* app = static_cast(data); app->cancelled.store(true); gtk_button_set_label(button, "Cancelling"); gtk_widget_set_sensitive(GTK_WIDGET(button), false); } void show_processing(App* app) { GtkWidget* window = gtk_application_window_new(app->gtk_app); app->processing = window; gtk_window_set_title(GTK_WINDOW(window), "Transui"); gtk_window_set_default_size(GTK_WINDOW(window), 310, 190); gtk_window_set_resizable(GTK_WINDOW(window), false); gtk_window_set_deletable(GTK_WINDOW(window), false); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); gtk_widget_set_halign(box, GTK_ALIGN_CENTER); gtk_widget_set_valign(box, GTK_ALIGN_CENTER); gtk_container_add(GTK_CONTAINER(window), box); GtkWidget* spinner = gtk_spinner_new(); gtk_widget_set_size_request(spinner, 36, 36); gtk_spinner_start(GTK_SPINNER(spinner)); gtk_box_pack_start(GTK_BOX(box), spinner, false, false, 0); GtkWidget* label = gtk_label_new("Transcribing"); gtk_style_context_add_class(gtk_widget_get_style_context(label), "state-title"); gtk_box_pack_start(GTK_BOX(box), label, false, false, 0); GtkWidget* cancel = gtk_button_new_with_label("Cancel"); gtk_box_pack_start(GTK_BOX(box), cancel, false, false, 8); g_signal_connect(cancel, "clicked", G_CALLBACK(cancel_processing), app); g_signal_connect(window, "destroy", G_CALLBACK(processing_closed), app); gtk_widget_show_all(window); } void stop_recording(GtkButton*, gpointer data) { auto* app = static_cast(data); if (!app->pipeline) return; if (app->elapsed_source) { g_source_remove(app->elapsed_source); app->elapsed_source = 0; } gst_element_send_event(app->pipeline, gst_event_new_eos()); GstBus* bus = gst_element_get_bus(app->pipeline); GstMessage* message = gst_bus_timed_pop_filtered(bus, 3 * GST_SECOND, static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); if (message) gst_message_unref(message); gst_object_unref(bus); gst_element_set_state(app->pipeline, GST_STATE_NULL); gst_object_unref(app->pipeline); app->pipeline = nullptr; if (app->recorder) gtk_widget_destroy(app->recorder); app->recorder = nullptr; app->cancelled.store(false); show_processing(app); auto* job = new Job{app, app->recording_path, app->config, app->hosted_key, app->local_key}; g_thread_unref(g_thread_new("transcription", job_thread, job)); } void cancel_recording(GtkButton*, gpointer data) { auto* app = static_cast(data); close_recorder(app); remove_recording(app); } void recorder_closed(GtkWidget*, gpointer data) { auto* app = static_cast(data); app->recorder = nullptr; if (app->elapsed_source) { g_source_remove(app->elapsed_source); app->elapsed_source = 0; } if (app->pipeline) { gst_element_set_state(app->pipeline, GST_STATE_NULL); gst_object_unref(app->pipeline); app->pipeline = nullptr; remove_recording(app); } } void start_recording(App* app) { if (app->recorder) { gtk_window_present(GTK_WINDOW(app->recorder)); return; } if (app->config.local_url.empty() && (!app->config.hosted_first || app->config.hosted_url.empty())) { open_settings(app); show_error(GTK_WINDOW(app->settings), "Set an endpoint first."); return; } gchar* template_path = g_build_filename(g_get_tmp_dir(), "transui-XXXXXX", nullptr); int fd = g_mkstemp(template_path); if (fd < 0) { show_error(nullptr, "Could not create a recording file."); g_free(template_path); return; } close(fd); app->recording_path = template_path; g_free(template_path); gchar* pipeline_text = g_strdup_printf("autoaudiosrc ! audioconvert ! audioresample ! wavenc ! filesink location=\"%s\"", app->recording_path.c_str()); GError* error = nullptr; app->pipeline = gst_parse_launch(pipeline_text, &error); g_free(pipeline_text); if (!app->pipeline) { show_error(nullptr, error ? error->message : "Could not start recording."); if (error) g_error_free(error); remove_recording(app); return; } if (gst_element_set_state(app->pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { show_error(nullptr, "Could not access an audio input."); close_recorder(app); remove_recording(app); return; } GtkWidget* window = gtk_application_window_new(app->gtk_app); app->recorder = window; gtk_window_set_title(GTK_WINDOW(window), "Transui"); gtk_window_set_default_size(GTK_WINDOW(window), 330, 250); gtk_window_set_resizable(GTK_WINDOW(window), false); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); gtk_widget_set_halign(box, GTK_ALIGN_CENTER); gtk_widget_set_valign(box, GTK_ALIGN_CENTER); gtk_container_add(GTK_CONTAINER(window), box); GtkWidget* dot = gtk_label_new("●"); gtk_style_context_add_class(gtk_widget_get_style_context(dot), "record-dot"); gtk_box_pack_start(GTK_BOX(box), dot, false, false, 0); GtkWidget* state = gtk_label_new("RECORDING"); gtk_style_context_add_class(gtk_widget_get_style_context(state), "record-state"); gtk_box_pack_start(GTK_BOX(box), state, false, false, 0); app->elapsed_label = gtk_label_new("00:00"); gtk_style_context_add_class(gtk_widget_get_style_context(app->elapsed_label), "elapsed"); gtk_box_pack_start(GTK_BOX(box), app->elapsed_label, false, false, 0); GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_box_pack_start(GTK_BOX(box), actions, false, false, 8); GtkWidget* cancel = gtk_button_new_with_label("Cancel"); GtkWidget* stop = gtk_button_new_with_label("Stop and transcribe"); gtk_style_context_add_class(gtk_widget_get_style_context(stop), "suggested-action"); gtk_box_pack_start(GTK_BOX(actions), cancel, false, false, 0); gtk_box_pack_start(GTK_BOX(actions), stop, false, false, 0); app->elapsed_seconds = 0; app->elapsed_source = g_timeout_add_seconds(1, tick_elapsed, app); g_signal_connect(cancel, "clicked", G_CALLBACK(cancel_recording), app); g_signal_connect(stop, "clicked", G_CALLBACK(stop_recording), app); g_signal_connect(window, "destroy", G_CALLBACK(recorder_closed), app); gtk_widget_show_all(window); } void install_css() { const char* css = R"CSS( window { background-color: #111827; color: #edf1f7; } entry, combobox button { background: #1e293b; color: #edf1f7; border: 1px solid #334155; border-radius: 5px; } label { color: #cbd5e1; } .record-dot { color: #fb7185; font-size: 30px; } .record-state { color: #fb7185; font-weight: 800; letter-spacing: 2px; font-size: 12px; } .elapsed { color: #f8fafc; font-family: monospace; font-size: 38px; font-weight: 700; } .state-title { color: #f8fafc; font-size: 18px; font-weight: 700; } button.suggested-action { background: #f97316; color: #111827; border-color: #f97316; font-weight: 700; } button.suggested-action:hover { background: #fb923c; } )CSS"; GtkCssProvider* provider = gtk_css_provider_new(); gtk_css_provider_load_from_data(provider, css, -1, nullptr); gtk_style_context_add_provider_for_screen(gdk_screen_get_default(), GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); g_object_unref(provider); } void activate(GtkApplication*, gpointer data) { start_recording(static_cast(data)); } } // namespace int main(int argc, char** argv) { const bool settings = argc == 2 && std::strcmp(argv[1], "settings") == 0; if (argc > 1 && !settings && std::strcmp(argv[1], "record") != 0) { std::fprintf(stderr, "Usage: transui [record|settings]\n"); return 2; } gst_init(&argc, &argv); curl_global_init(CURL_GLOBAL_DEFAULT); App app; load_config(&app); app.gtk_app = gtk_application_new(kAppId, G_APPLICATION_DEFAULT_FLAGS); g_signal_connect(app.gtk_app, "startup", G_CALLBACK(+[](GtkApplication*, gpointer) { install_css(); }), nullptr); if (settings) g_signal_connect_swapped(app.gtk_app, "activate", G_CALLBACK(open_settings), &app); else g_signal_connect(app.gtk_app, "activate", G_CALLBACK(activate), &app); const int result = g_application_run(G_APPLICATION(app.gtk_app), argc, argv); if (app.pipeline) close_recorder(&app); remove_recording(&app); g_object_unref(app.gtk_app); curl_global_cleanup(); return result; }