@tool extends EditorPlugin #------------------------------- SETUP ------------------------------- # Utilities var Utils = preload("res://addons/godot_super-wakatime/utils.gd").new() var DecompressorUtils = preload("res://addons/godot_super-wakatime/decompressor.gd").new() # Hearbeat class const HeartBeat = preload("res://addons/godot_super-wakatime/heartbeat.gd") var last_heartbeat = HeartBeat.new() # Paths, Urls const PLUGIN_PATH: String = "res://addons/godot_super-wakatime" const ZIP_PATH: String = "%s/wakatime.zip" % PLUGIN_PATH const WAKATIME_URL_FMT: String = \ "https://github.com/wakatime/wakatime-cli/releases/download/v1.54.0/{wakatime_build}.zip" const DECOMPERSSOR_URL_FMT: String = \ "https://github.com/ouch-org/ouch/releases/download/0.3.1/{ouch_build}" # Names for menu const API_MENU_ITEM: String = "Wakatime API key" const CONFIG_MENU_ITEM: String = "Wakatime Config File" # Directories to grab wakatime from var wakatime_dir = null var wakatime_cli = null var decompressor_cli = null var ApiKeyPrompt: PackedScene = preload("res://addons/godot_super-wakatime/api_key_prompt.tscn") var Counter: PackedScene = preload("res://addons/godot_super-wakatime/counter.tscn") # Set platform var system_platform: String = Utils.set_platform()[0] var system_architecture: String = Utils.set_platform()[1] var debug: bool = false var last_scene_path: String = '' var last_file_path: String = '' var last_time: int = 0 var previous_state: String = '' const LOG_INTERVAL: int = 60000 var scene_mode: bool = false var key_get_tries: int = 0 var counter_instance: Node var current_time: String = "0 hrs, 0mins" # #------------------------------- DIRECT PLUGIN FUNCTIONS ------------------------------- func _ready() -> void: setup_plugin() set_process(true) func _exit_tree() -> void: _disable_plugin() set_process(false) func _physics_process(delta: float) -> void: """Process plugin changes over time""" # Every 1000 frames check for updates if Engine.get_physics_frames() % 1000 == 0: # Check for scene change var scene_root = get_editor_interface().get_edited_scene_root() if scene_root: var current_scene_path = _get_current_scene() # If currently used scene is different thatn 1000 frames ago, log activity if current_scene_path != last_scene_path: last_scene_path = current_scene_path handle_activity_scene(current_scene_path, false, true) else: # Check for scene updates var current_scene = EditorInterface.get_edited_scene_root() if current_scene: var state = generate_scene_state(current_scene) # If current state is different than the previous one, handle activity if state != previous_state: previous_state = state handle_activity_scene(current_scene_path, true) # Otherwise just keep scene the same else: previous_state = state else: last_scene_path = '' func generate_scene_state(node: Node) -> String: """Generate a scene state identifier""" var state = str(node.get_instance_id()) for child in node.get_children(): state += str(child.get_instance_id()) return str(state) func _input(event: InputEvent) -> void: """Handle all input events""" # Key events if event is InputEventKey: var file = get_current_file() if file: handle_activity(ProjectSettings.globalize_path(file.resource_path)) # Mouse button events elif event is InputEventMouse and event.is_pressed(): var file = _get_current_scene() if file != '' and file: handle_activity_scene(file) func setup_plugin() -> void: """Setup Wakatime plugin, download dependencies if needed, initialize menu""" Utils.plugin_print("Setting up %s" % get_user_agent()) check_dependencies() # Grab API key if needed var api_key = get_api_key() if api_key == null: request_api_key() await get_tree().process_frame # Add menu buttons add_tool_menu_item(API_MENU_ITEM, request_api_key) add_tool_menu_item(CONFIG_MENU_ITEM, open_config) counter_instance = Counter.instantiate() add_control_to_bottom_panel(counter_instance, current_time) # Connect code editor signals var script_editor: ScriptEditor = get_editor_interface().get_script_editor() script_editor.call_deferred("connect", "editor_script_changed", Callable(self, "_on_script_changed")) func _disable_plugin() -> void: """Cleanup after disabling plugin""" # Remove items from menu remove_tool_menu_item(API_MENU_ITEM) remove_tool_menu_item(CONFIG_MENU_ITEM) remove_control_from_bottom_panel(counter_instance) # Disconnect script editor tracking var script_editor: ScriptEditor = get_editor_interface().get_script_editor() if script_editor.is_connected("editor_script_changed", Callable(self, "_on_script_changed")): script_editor.disconnect("editor_script_changed", Callable(self, "_on_script_changed")) func _on_script_changed(file) -> void: """Handle changing scripts""" if file: last_file_path = ProjectSettings.globalize_path(file.resource_path) handle_activity(last_file_path) #func _unhandled_key_input(event: InputEvent) -> void: # """Handle key inputs""" # var file = get_current_file() # handle_activity(file) func _save_external_data() -> void: """Handle saving files""" var file = get_current_file() if file: handle_activity(ProjectSettings.globalize_path(file.resource_path), true) func _get_current_scene(): """Get currently used scene""" if EditorInterface.get_edited_scene_root(): var file = EditorInterface.get_edited_scene_root() if file: return ProjectSettings.globalize_path(file.scene_file_path) else: var file = get_current_file() if file: return ProjectSettings.globalize_path(file.resource_path) return null func _on_scene_modified(): """Send heartbeat when scene is modified""" var current_scene = get_tree().current_scene if current_scene: handle_activity_scene(_get_current_scene()) func get_current_file(): """Get currently used script file""" var file = get_editor_interface().get_script_editor().get_current_script() if file: last_file_path = ProjectSettings.globalize_path(file.resource_path) return get_editor_interface().get_script_editor().get_current_script() func handle_activity(file, is_write: bool = false) -> void: """Handle user's activity""" # If file that has activity in or wakatime cli doesn't exist, return if not file or not Utils.wakatime_cli_exists(get_waka_cli()): return # If user is saving file or has changed path, or enough time has passed for a heartbeat - send it #var filepath = ProjectSettings.globalize_path(file.resource_path) if is_write or file != last_heartbeat.file_path or enough_time_passed(): send_heartbeat(file, is_write) func handle_activity_scene(file, is_write: bool = false, changed_file: bool = false) -> void: """Handle activity in scenes""" if not file or not Utils.wakatime_cli_exists(get_waka_cli()): return if is_write or changed_file or enough_time_passed(): scene_mode = true send_heartbeat(file, is_write) func send_heartbeat(filepath: String, is_write: bool) -> void: """Send Wakatimde heartbeat for the specified file""" # Check Wakatime API key var api_key = get_api_key() if api_key == null: Utils.plugin_print("Failed to get Wakatime API key. Are you sure it's correct?") if (key_get_tries < 3): request_api_key() key_get_tries += 1 else: Utils.plugin_print("If this keep occuring, please create a file: ~/.wakatime.cfg\n initialize it with:\n[settings]\napi_key={your_key}") return # Make sure not to trigger additional heartbeats cause of events from scenes var file = filepath if scene_mode: file = last_file_path #print("\n-------SCENE MODE--------\n") # Create heartbeat var heartbeat = HeartBeat.new(file, Time.get_unix_time_from_system(), is_write) # Current text editor var text_editor = _find_active_script_editor() var cursor_pos = _get_cursor_pos(text_editor) # Append all informations as Wakatime CLI arguments var cmd: Array[Variant] = ["--entity", filepath, "--key", api_key] if is_write: cmd.append("--write") cmd.append_array(["--alternate-project", ProjectSettings.get("application/config/name")]) cmd.append_array(["--time", str(heartbeat.time)]) cmd.append_array(["--lineno", str(cursor_pos.line)]) cmd.append_array(["--cursorpos", str(cursor_pos.column)]) cmd.append_array(["--plugin", get_user_agent()]) cmd.append_array(["--alternate-language", "Scene"]) if scene_mode: cmd.append_array(["--category", "building"]) else: cmd.append(["--category", "coding"]) # Send heartbeat using Wakatime CLI var cmd_callable = Callable(self, "_handle_heartbeat").bind(cmd) scene_mode = false WorkerThreadPool.add_task(cmd_callable) last_heartbeat = heartbeat func _find_active_script_editor(): """Return currently used script editor""" # Get script editor var script_editor = get_editor_interface().get_script_editor() var current_editor = script_editor.get_current_editor() # Try to find code editor from it if current_editor: return _find_code_edit_recursive(script_editor.get_current_editor()) return null func _find_code_edit_recursive(node: Node) -> CodeEdit: """Find recursively code editor in a node""" # If node is already a code editor, return it if node is CodeEdit: return node # Try to find it in every child of a given node for child in node.get_children(): var editor = _find_code_edit_recursive(child) if editor: return editor return null func _get_cursor_pos(text_editor) -> Dictionary: """Get cursor editor from the given text editor""" if text_editor: return { "line": text_editor.get_caret_line() + 1, "column": text_editor.get_caret_column() + 1 } return { "line": 0, "column": 0 } func _handle_heartbeat(cmd_arguments) -> void: """Handle sending the heartbeat""" # Get Wakatime CLI and try to send a heartbeat if wakatime_cli == null: wakatime_cli = Utils.get_waka_cli() var output: Array[Variant] = [] var exit_code: int = OS.execute(wakatime_cli, cmd_arguments, output, true) update_today_time(wakatime_cli) # Inform about success or errors if user is in debug if debug: if exit_code == -1: Utils.plugin_print("Failed to send heartbeat: %s" % output) else: Utils.plugin_print("Heartbeat sent: %s" % output) func enough_time_passed(): """Check if enough time has passed for another heartbeat""" return Time.get_unix_time_from_system() - last_heartbeat.time >= HeartBeat.FILE_MODIFIED_DELAY func update_today_time(wakatime_cli) -> void: """Update today's time in menu""" var output: Array[Variant] = [] # Get today's time from Wakatime CLI var exit_code: int = OS.execute(wakatime_cli, ["--today"], output, true) # Convert it and combine different categories into if exit_code == 0: current_time = convert_time(output[0]) else: current_time = "Wakatime" #print(current_time) call_deferred("_update_panel_label", current_time, output[0]) func _update_panel_label(label: String, content: String): """Update bottom panel name that shows time""" # If counter exists and it has a label, update both the label and panel's name if counter_instance and counter_instance.get_node("HBoxContainer/Label"): counter_instance.get_node("HBoxContainer/Label").text = content # Workaround to rename panel remove_control_from_bottom_panel(counter_instance) add_control_to_bottom_panel(counter_instance, label) func convert_time(complex_time: String): """Convert time from complex format into basic one, combine times""" return complex_time """ # Split times into categories var time_categories = complex_time.split(', ') for category in time_categories: # Split time into parts, get first and third part (hours and minutes) var time_parts = category.split(' ') if time_parts.size() >= 3: hours += int(time_parts[0]) minutes += int(time_parts[2]) # Wrap minutes into hours if needed while minutes >= 60: minutes -= 60 hours += 1 return str(hours) + " hrs, " + str(minutes) + " mins" """ #------------------------------- FILE FUNCTIONS ------------------------------- func open_config() -> void: """Open wakatime config file""" OS.shell_open(Utils.config_filepath(system_platform, PLUGIN_PATH)) func get_waka_dir() -> String: """Search for and return wakatime directory""" if wakatime_dir == null: wakatime_dir = "%s/.wakatime" % Utils.home_directory(system_platform, PLUGIN_PATH) return wakatime_dir func get_waka_cli() -> String: """Get wakatime_cli file""" if wakatime_cli == null: var build = Utils.get_waka_build(system_platform, system_architecture) var ext: String = ".exe" if system_platform == "windows" else '' wakatime_cli = "%s/%s%s" % [get_waka_dir(), build, ext] return wakatime_cli func check_dependencies() -> void: """Make sure all dependencies exist""" if !Utils.wakatime_cli_exists(get_waka_cli()): download_wakatime() if !DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH): download_decompressor() func download_wakatime() -> void: """Download wakatime cli""" Utils.plugin_print("Downloading Wakatime CLI...") var url: String = WAKATIME_URL_FMT.format({"wakatime_build": Utils.get_waka_build(system_platform, system_architecture)}) # Try downloading wakatime var http = HTTPRequest.new() http.download_file = ZIP_PATH http.connect("request_completed", Callable(self, "_wakatime_download_completed")) add_child(http) # Handle errors var status = http.request(url) if status != OK: Utils.plugin_print_err("Failed to start downloading Wakatime [Error: %s]" % status) _disable_plugin() func download_decompressor() -> void: """Download ouch decompressor""" Utils.plugin_print("Downloading Ouch! decompression library...") var url: String = DECOMPERSSOR_URL_FMT.format({"ouch_build": Utils.get_ouch_build(system_platform)}) if system_platform == "windows": url += ".exe" # Try to download ouch var http = HTTPRequest.new() http.download_file = DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH) http.connect("request_completed", Callable(self, "_decompressor_download_finished")) add_child(http) # Handle errors var status = http.request(url) if status != OK: _disable_plugin() Utils.plugin_print_err("Failed to start downloading Ouch! library [Error: %s]" % status) func _wakatime_download_completed(result, status, headers, body) -> void: """Finish downloading wakatime, handle errors""" if result != HTTPRequest.RESULT_SUCCESS: Utils.plugin_print_err("Error while downloading Wakatime CLI") _disable_plugin() return Utils.plugin_print("Wakatime CLI has been installed succesfully! Located at %s" % ZIP_PATH) extract_files(ZIP_PATH, get_waka_dir()) func _decompressor_download_finished(result, status, headers, body) -> void: """Handle errors and finishi decompressor download""" # Error while downloading if result != HTTPRequest.RESULT_SUCCESS: Utils.plugin_print_err("Error while downloading Ouch! library") _disable_plugin() return # Error while saving if !DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH): Utils.plugin_print_err("Failed to save Ouch! library") _disable_plugin() return # Save decompressor path, give write permissions to it var decompressor: String = \ ProjectSettings.globalize_path(DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH)) if system_platform == "linux" or system_platform == "darwin": OS.execute("chmod", ["+x", decompressor], [], true) # Extract files, allowing usage of Ouch! Utils.plugin_print("Ouch! has been installed succesfully! Located at %s" % \ DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH)) extract_files(ZIP_PATH, get_waka_dir()) func extract_files(source: String, destination: String) -> void: """Extract downloaded Wakatime zip""" # If decompression library and wakatime zip folder don't exist, return if not DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH) and not Utils.wakatime_zip_exists(ZIP_PATH): return # Get paths as global Utils.plugin_print("Extracting Wakatime...") var decompressor: String if system_platform == "windows": decompressor = ProjectSettings.globalize_path( DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH)) else: decompressor = ProjectSettings.globalize_path("res://" + DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH)) var src: String = ProjectSettings.globalize_path(source) var dest: String = ProjectSettings.globalize_path(destination) # Execute Ouch! decompression command, catch errors var errors: Array[Variant] = [] var args: Array[String] = ["--yes", "decompress", src, "--dir", dest] var error: int = OS.execute(decompressor, args, errors, true) if error: Utils.plugin_print(errors) _disable_plugin() return # Results if Utils.wakatime_cli_exists(get_waka_cli()): Utils.plugin_print("Wakatime CLI installed (path: %s)" % get_waka_cli()) else: Utils.plugin_print_err("Installation of Wakatime failed") _disable_plugin() # Remove leftover files clean_files() func clean_files(): """Delete files that aren't needed anymore""" if Utils.wakatime_zip_exists(ZIP_PATH): delete_file(ZIP_PATH) if DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH): delete_file(ZIP_PATH) func delete_file(path: String) -> void: """Delete file at specified path""" var dir: DirAccess = DirAccess.open("res://") var status: int = dir.remove(path) if status != OK: Utils.plugin_print_err("Failed to clean unnecesary file at %s" % path) else: Utils.plugin_print("Clean unncecesary file at %s" % path) #------------------------------- API KEY FUNCTIONS ------------------------------- func get_api_key(): """Get wakatime api key""" var result = [] # Handle errors while getting the key var err = OS.execute(get_waka_cli(), ["--config-read", "api_key"], result) if err == -1: return null # Trim API key from whitespaces and return it var key = result[0].strip_edges() if key.is_empty(): return null return key func request_api_key() -> void: """Request Wakatime API key from the user""" # Prepare prompt var prompt = ApiKeyPrompt.instantiate() _set_api_key(prompt, get_api_key()) _register_api_key_signals(prompt) # Show prompt and hide it on request add_child(prompt) prompt.popup_centered() await prompt.popup_hide prompt.queue_free() func _set_api_key(prompt: PopupPanel, api_key) -> void: """Set API key from prompt""" # Safeguard against empty key if api_key == null: api_key = '' # Set correct text, to show API key var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit") edit_field.text = api_key func _register_api_key_signals(prompt: PopupPanel) -> void: """Connect all signals related to API key popup""" # Get all Nodes var show_button: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/ShowButton") var save_button: Node = prompt.get_node("VBoxContainer/HBoxContainerBottom/SubmitButton") # Connect them to press events show_button.connect("pressed", Callable(self, "_on_toggle_key_text").bind(prompt)) save_button.connect("pressed", Callable(self, "_on_save_key").bind(prompt)) prompt.connect("popup_hide", Callable(self, "_on_popup_hide").bind(prompt)) func _on_popup_hide(prompt: PopupPanel): """Close the popup window when user wants to hide it""" prompt.queue_free() func _on_toggle_key_text(prompt: PopupPanel) -> void: """Handle hiding and showing API key""" # Get nodes var show_button: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/ShowButton") var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit") # Set the correct text and hide field if needed edit_field.secret = not edit_field.secret show_button.text = "Show" if edit_field.secret else "Hide" func _on_save_key(prompt: PopupPanel) -> void: """Handle entering API key""" # Get text field node and api key that's entered var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit") var api_key = edit_field.text.strip_edges() # Try to set api key for wakatime and handle errors var err: int = OS.execute(get_waka_cli(), ["--config-write", "api-key=%s" % api_key]) if err == -1: Utils.plugin_print("Failed to save API key") prompt.visible = false #------------------------------- PLUGIN INFORMATIONS ------------------------------- func get_user_agent() -> String: """Get user agent identifier""" var os_name = OS.get_name().to_lower() return "godot/%s godot-wakatime/%s" % [ get_engine_version(), _get_plugin_version() ] func _get_plugin_name() -> String: """Get name of the plugin""" return "Godot_Super-Wakatime" func _get_plugin_version() -> String: """Get plugin version""" return "1.0.0" func _get_editor_name() -> String: """Get name of the editor""" return "Godot%s" % get_engine_version() func get_engine_version() -> String: """Get verison of currently used engine""" return "%s.%s.%s" % [Engine.get_version_info()["major"], Engine.get_version_info()["minor"], Engine.get_version_info()["patch"]]