From 25eb9e725a7be42da0fa56fe8afd51ff30198f02 Mon Sep 17 00:00:00 2001 From: derek Date: Wed, 9 Apr 2025 11:19:02 -0500 Subject: [PATCH 1/4] added firebase and rudimentary leaderboard support --- MainMenu.tscn | 6 +- UI/authentication.tscn | 66 ++ addons/godot-firebase/.env | 24 + addons/godot-firebase/LICENSE | 21 + addons/godot-firebase/README.md | 3 + addons/godot-firebase/Utilities.gd | 346 +++++++++ addons/godot-firebase/Utilities.gd.uid | 1 + addons/godot-firebase/auth/auth.gd | 693 ++++++++++++++++++ addons/godot-firebase/auth/auth.gd.uid | 1 + addons/godot-firebase/auth/auth_provider.gd | 32 + .../godot-firebase/auth/auth_provider.gd.uid | 1 + .../godot-firebase/auth/providers/facebook.gd | 21 + .../auth/providers/facebook.gd.uid | 1 + .../godot-firebase/auth/providers/github.gd | 14 + .../auth/providers/github.gd.uid | 1 + .../godot-firebase/auth/providers/google.gd | 13 + .../auth/providers/google.gd.uid | 1 + .../godot-firebase/auth/providers/twitter.gd | 39 + .../auth/providers/twitter.gd.uid | 1 + addons/godot-firebase/auth/user_data.gd | 44 ++ addons/godot-firebase/auth/user_data.gd.uid | 1 + addons/godot-firebase/database/database.gd | 51 ++ .../godot-firebase/database/database.gd.uid | 1 + .../godot-firebase/database/database_store.gd | 109 +++ .../database/database_store.gd.uid | 1 + .../database/firebase_database_reference.tscn | 17 + .../firebase_once_database_reference.tscn | 16 + .../godot-firebase/database/once_reference.gd | 124 ++++ .../database/once_reference.gd.uid | 1 + addons/godot-firebase/database/reference.gd | 176 +++++ .../godot-firebase/database/reference.gd.uid | 1 + addons/godot-firebase/database/resource.gd | 16 + .../godot-firebase/database/resource.gd.uid | 1 + .../dynamiclinks/dynamiclinks.gd | 109 +++ .../dynamiclinks/dynamiclinks.gd.uid | 1 + addons/godot-firebase/firebase/firebase.gd | 144 ++++ .../godot-firebase/firebase/firebase.gd.uid | 1 + addons/godot-firebase/firebase/firebase.tscn | 36 + .../firestore/field_transform.gd | 22 + .../firestore/field_transform.gd.uid | 1 + .../firestore/field_transform_array.gd | 35 + .../firestore/field_transform_array.gd.uid | 1 + .../field_transforms/decrement_transform.gd | 19 + .../decrement_transform.gd.uid | 1 + .../field_transforms/increment_transform.gd | 19 + .../increment_transform.gd.uid | 1 + .../field_transforms/max_transform.gd | 19 + .../field_transforms/max_transform.gd.uid | 1 + .../field_transforms/min_transform.gd | 19 + .../field_transforms/min_transform.gd.uid | 1 + .../server_timestamp_transform.gd | 10 + .../server_timestamp_transform.gd.uid | 1 + addons/godot-firebase/firestore/firestore.gd | 243 ++++++ .../godot-firebase/firestore/firestore.gd.uid | 1 + .../firestore/firestore_collection.gd | 178 +++++ .../firestore/firestore_collection.gd.uid | 1 + .../firestore/firestore_document.gd | 185 +++++ .../firestore/firestore_document.gd.uid | 1 + .../firestore/firestore_listener.gd | 47 ++ .../firestore/firestore_listener.gd.uid | 1 + .../firestore/firestore_listener.tscn | 6 + .../firestore/firestore_query.gd | 255 +++++++ .../firestore/firestore_query.gd.uid | 1 + .../firestore/firestore_task.gd | 188 +++++ .../firestore/firestore_task.gd.uid | 1 + .../firestore/firestore_transform.gd | 3 + .../firestore/firestore_transform.gd.uid | 1 + .../godot-firebase/functions/function_task.gd | 59 ++ .../functions/function_task.gd.uid | 1 + addons/godot-firebase/functions/functions.gd | 219 ++++++ .../godot-firebase/functions/functions.gd.uid | 1 + addons/godot-firebase/icon.svg | 1 + addons/godot-firebase/icon.svg.import | 37 + addons/godot-firebase/plugin.cfg | 7 + addons/godot-firebase/plugin.gd | 8 + addons/godot-firebase/plugin.gd.uid | 1 + .../queues/queueable_http_request.gd | 30 + .../queues/queueable_http_request.gd.uid | 1 + .../queues/queueable_http_request.tscn | 6 + .../remote_config/firebase_remote_config.gd | 36 + .../firebase_remote_config.gd.uid | 1 + .../remote_config/firebase_remote_config.tscn | 7 + .../remote_config/remote_config.gd | 14 + .../remote_config/remote_config.gd.uid | 1 + addons/godot-firebase/storage/storage.gd | 362 +++++++++ addons/godot-firebase/storage/storage.gd.uid | 1 + .../storage/storage_reference.gd | 159 ++++ .../storage/storage_reference.gd.uid | 1 + addons/godot-firebase/storage/storage_task.gd | 74 ++ .../storage/storage_task.gd.uid | 1 + addons/http-sse-client/HTTPSSEClient.gd | 129 ++++ addons/http-sse-client/HTTPSSEClient.gd.uid | 1 + addons/http-sse-client/HTTPSSEClient.tscn | 6 + addons/http-sse-client/LICENSE | 21 + addons/http-sse-client/README.md | 16 + .../http-sse-client/httpsseclient_plugin.gd | 8 + .../httpsseclient_plugin.gd.uid | 1 + addons/http-sse-client/icon.png | Bin 0 -> 137 bytes addons/http-sse-client/icon.png.import | 34 + addons/http-sse-client/plugin.cfg | 7 + export_presets.cfg | 4 +- .../godot_svc.debug.xcframework/Info.plist | 41 ++ .../godot_svc-device.release_debug.a | Bin 0 -> 82832 bytes .../godot_svc-simulator.release_debug.a | Bin 0 -> 82048 bytes ios_plugins/godot_svc/bin 3.x/godot_svc.gdip | 17 + .../godot_svc.release.xcframework/Info.plist | 41 ++ .../godot_svc-device.release.a | Bin 0 -> 73680 bytes .../godot_svc-simulator.release.a | Bin 0 -> 73064 bytes ios_plugins/godot_svc/src/godot_svc.gdip | 17 + ios_plugins/godot_svc/src/godot_svc.h | 30 + ios_plugins/godot_svc/src/godot_svc.mm | 41 ++ .../godot_svc/src/godot_svc_delegate.mm | 36 + .../godot_svc/src/godot_svc_module.cpp | 23 + ios_plugins/godot_svc/src/godot_svc_module.h | 6 + project.godot | 4 +- scripts/authentication.gd | 51 ++ scripts/authentication.gd.uid | 1 + scripts/leaderboard.gd | 27 + scripts/leaderboard.gd.uid | 1 + scripts/save_load.gd | 1 + tube_top.tscn | 2 +- 121 files changed, 4987 insertions(+), 4 deletions(-) create mode 100644 UI/authentication.tscn create mode 100644 addons/godot-firebase/.env create mode 100644 addons/godot-firebase/LICENSE create mode 100644 addons/godot-firebase/README.md create mode 100644 addons/godot-firebase/Utilities.gd create mode 100644 addons/godot-firebase/Utilities.gd.uid create mode 100644 addons/godot-firebase/auth/auth.gd create mode 100644 addons/godot-firebase/auth/auth.gd.uid create mode 100644 addons/godot-firebase/auth/auth_provider.gd create mode 100644 addons/godot-firebase/auth/auth_provider.gd.uid create mode 100644 addons/godot-firebase/auth/providers/facebook.gd create mode 100644 addons/godot-firebase/auth/providers/facebook.gd.uid create mode 100644 addons/godot-firebase/auth/providers/github.gd create mode 100644 addons/godot-firebase/auth/providers/github.gd.uid create mode 100644 addons/godot-firebase/auth/providers/google.gd create mode 100644 addons/godot-firebase/auth/providers/google.gd.uid create mode 100644 addons/godot-firebase/auth/providers/twitter.gd create mode 100644 addons/godot-firebase/auth/providers/twitter.gd.uid create mode 100644 addons/godot-firebase/auth/user_data.gd create mode 100644 addons/godot-firebase/auth/user_data.gd.uid create mode 100644 addons/godot-firebase/database/database.gd create mode 100644 addons/godot-firebase/database/database.gd.uid create mode 100644 addons/godot-firebase/database/database_store.gd create mode 100644 addons/godot-firebase/database/database_store.gd.uid create mode 100644 addons/godot-firebase/database/firebase_database_reference.tscn create mode 100644 addons/godot-firebase/database/firebase_once_database_reference.tscn create mode 100644 addons/godot-firebase/database/once_reference.gd create mode 100644 addons/godot-firebase/database/once_reference.gd.uid create mode 100644 addons/godot-firebase/database/reference.gd create mode 100644 addons/godot-firebase/database/reference.gd.uid create mode 100644 addons/godot-firebase/database/resource.gd create mode 100644 addons/godot-firebase/database/resource.gd.uid create mode 100644 addons/godot-firebase/dynamiclinks/dynamiclinks.gd create mode 100644 addons/godot-firebase/dynamiclinks/dynamiclinks.gd.uid create mode 100644 addons/godot-firebase/firebase/firebase.gd create mode 100644 addons/godot-firebase/firebase/firebase.gd.uid create mode 100644 addons/godot-firebase/firebase/firebase.tscn create mode 100644 addons/godot-firebase/firestore/field_transform.gd create mode 100644 addons/godot-firebase/firestore/field_transform.gd.uid create mode 100644 addons/godot-firebase/firestore/field_transform_array.gd create mode 100644 addons/godot-firebase/firestore/field_transform_array.gd.uid create mode 100644 addons/godot-firebase/firestore/field_transforms/decrement_transform.gd create mode 100644 addons/godot-firebase/firestore/field_transforms/decrement_transform.gd.uid create mode 100644 addons/godot-firebase/firestore/field_transforms/increment_transform.gd create mode 100644 addons/godot-firebase/firestore/field_transforms/increment_transform.gd.uid create mode 100644 addons/godot-firebase/firestore/field_transforms/max_transform.gd create mode 100644 addons/godot-firebase/firestore/field_transforms/max_transform.gd.uid create mode 100644 addons/godot-firebase/firestore/field_transforms/min_transform.gd create mode 100644 addons/godot-firebase/firestore/field_transforms/min_transform.gd.uid create mode 100644 addons/godot-firebase/firestore/field_transforms/server_timestamp_transform.gd create mode 100644 addons/godot-firebase/firestore/field_transforms/server_timestamp_transform.gd.uid create mode 100644 addons/godot-firebase/firestore/firestore.gd create mode 100644 addons/godot-firebase/firestore/firestore.gd.uid create mode 100644 addons/godot-firebase/firestore/firestore_collection.gd create mode 100644 addons/godot-firebase/firestore/firestore_collection.gd.uid create mode 100644 addons/godot-firebase/firestore/firestore_document.gd create mode 100644 addons/godot-firebase/firestore/firestore_document.gd.uid create mode 100644 addons/godot-firebase/firestore/firestore_listener.gd create mode 100644 addons/godot-firebase/firestore/firestore_listener.gd.uid create mode 100644 addons/godot-firebase/firestore/firestore_listener.tscn create mode 100644 addons/godot-firebase/firestore/firestore_query.gd create mode 100644 addons/godot-firebase/firestore/firestore_query.gd.uid create mode 100644 addons/godot-firebase/firestore/firestore_task.gd create mode 100644 addons/godot-firebase/firestore/firestore_task.gd.uid create mode 100644 addons/godot-firebase/firestore/firestore_transform.gd create mode 100644 addons/godot-firebase/firestore/firestore_transform.gd.uid create mode 100644 addons/godot-firebase/functions/function_task.gd create mode 100644 addons/godot-firebase/functions/function_task.gd.uid create mode 100644 addons/godot-firebase/functions/functions.gd create mode 100644 addons/godot-firebase/functions/functions.gd.uid create mode 100644 addons/godot-firebase/icon.svg create mode 100644 addons/godot-firebase/icon.svg.import create mode 100644 addons/godot-firebase/plugin.cfg create mode 100644 addons/godot-firebase/plugin.gd create mode 100644 addons/godot-firebase/plugin.gd.uid create mode 100644 addons/godot-firebase/queues/queueable_http_request.gd create mode 100644 addons/godot-firebase/queues/queueable_http_request.gd.uid create mode 100644 addons/godot-firebase/queues/queueable_http_request.tscn create mode 100644 addons/godot-firebase/remote_config/firebase_remote_config.gd create mode 100644 addons/godot-firebase/remote_config/firebase_remote_config.gd.uid create mode 100644 addons/godot-firebase/remote_config/firebase_remote_config.tscn create mode 100644 addons/godot-firebase/remote_config/remote_config.gd create mode 100644 addons/godot-firebase/remote_config/remote_config.gd.uid create mode 100644 addons/godot-firebase/storage/storage.gd create mode 100644 addons/godot-firebase/storage/storage.gd.uid create mode 100644 addons/godot-firebase/storage/storage_reference.gd create mode 100644 addons/godot-firebase/storage/storage_reference.gd.uid create mode 100644 addons/godot-firebase/storage/storage_task.gd create mode 100644 addons/godot-firebase/storage/storage_task.gd.uid create mode 100644 addons/http-sse-client/HTTPSSEClient.gd create mode 100644 addons/http-sse-client/HTTPSSEClient.gd.uid create mode 100644 addons/http-sse-client/HTTPSSEClient.tscn create mode 100644 addons/http-sse-client/LICENSE create mode 100644 addons/http-sse-client/README.md create mode 100644 addons/http-sse-client/httpsseclient_plugin.gd create mode 100644 addons/http-sse-client/httpsseclient_plugin.gd.uid create mode 100644 addons/http-sse-client/icon.png create mode 100644 addons/http-sse-client/icon.png.import create mode 100644 addons/http-sse-client/plugin.cfg create mode 100644 ios_plugins/godot_svc/bin 3.x/godot_svc.debug.xcframework/Info.plist create mode 100644 ios_plugins/godot_svc/bin 3.x/godot_svc.debug.xcframework/ios-arm64_armv7/godot_svc-device.release_debug.a create mode 100644 ios_plugins/godot_svc/bin 3.x/godot_svc.debug.xcframework/ios-arm64_x86_64-simulator/godot_svc-simulator.release_debug.a create mode 100644 ios_plugins/godot_svc/bin 3.x/godot_svc.gdip create mode 100644 ios_plugins/godot_svc/bin 3.x/godot_svc.release.xcframework/Info.plist create mode 100644 ios_plugins/godot_svc/bin 3.x/godot_svc.release.xcframework/ios-arm64_armv7/godot_svc-device.release.a create mode 100644 ios_plugins/godot_svc/bin 3.x/godot_svc.release.xcframework/ios-arm64_x86_64-simulator/godot_svc-simulator.release.a create mode 100644 ios_plugins/godot_svc/src/godot_svc.gdip create mode 100644 ios_plugins/godot_svc/src/godot_svc.h create mode 100644 ios_plugins/godot_svc/src/godot_svc.mm create mode 100644 ios_plugins/godot_svc/src/godot_svc_delegate.mm create mode 100644 ios_plugins/godot_svc/src/godot_svc_module.cpp create mode 100644 ios_plugins/godot_svc/src/godot_svc_module.h create mode 100644 scripts/authentication.gd create mode 100644 scripts/authentication.gd.uid create mode 100644 scripts/leaderboard.gd create mode 100644 scripts/leaderboard.gd.uid diff --git a/MainMenu.tscn b/MainMenu.tscn index 7761d89..209cdd6 100644 --- a/MainMenu.tscn +++ b/MainMenu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=19 format=3 uid="uid://sa1d1rftyn87"] +[gd_scene load_steps=20 format=3 uid="uid://sa1d1rftyn87"] [ext_resource type="Script" uid="uid://civx1w876wd7n" path="res://scripts/main_menu.gd" id="1_haaol"] [ext_resource type="PackedScene" uid="uid://dpootbr7qgac1" path="res://Tools/playlist_generator.tscn" id="2_2rg1o"] @@ -16,6 +16,7 @@ [ext_resource type="Texture2D" uid="uid://bt6utik8unkxa" path="res://assets/Textures/ObjectTextures/money.png" id="7_ia0hc"] [ext_resource type="Resource" uid="uid://cl5m2v6fntob7" path="res://LevelResources/EnemyWorkingScene.tres" id="7_m04lp"] [ext_resource type="Resource" uid="uid://c0116tnaxhe2r" path="res://LevelResources/Highwire.tres" id="8_1thib"] +[ext_resource type="PackedScene" uid="uid://b6nt3p1kntjod" path="res://UI/authentication.tscn" id="17_m04lp"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_a5tps"] shader = ExtResource("2_hyw7c") @@ -165,6 +166,9 @@ placeholder_text = "Leaderboard Name" layout_mode = 2 icon = ExtResource("7_ia0hc") +[node name="Authentication" parent="." instance=ExtResource("17_m04lp")] +layout_mode = 1 + [connection signal="item_selected" from="MarginContainer/VBoxContainer/HBoxContainer/OptionButton" to="." method="_on_option_button_item_selected"] [connection signal="pressed" from="MarginContainer/VBoxContainer/HBoxContainer/Add Leaderboard" to="." method="_on_add_leaderboard_pressed"] [connection signal="pressed" from="MarginContainer/VBoxContainer/Continue" to="." method="_on_continue_pressed"] diff --git a/UI/authentication.tscn b/UI/authentication.tscn new file mode 100644 index 0000000..b6fe273 --- /dev/null +++ b/UI/authentication.tscn @@ -0,0 +1,66 @@ +[gd_scene load_steps=3 format=3 uid="uid://b6nt3p1kntjod"] + +[ext_resource type="Script" uid="uid://cbmnj1dfgfwp5" path="res://scripts/authentication.gd" id="1_bgnbp"] +[ext_resource type="Theme" uid="uid://clek42ofxr45f" path="res://DefaultTheme.tres" id="2_hbvo2"] + +[node name="Authentication" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_bgnbp") + +[node name="ColorRect" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0, 0, 0, 1) + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -441.0 +offset_top = -351.5 +offset_right = 441.0 +offset_bottom = 351.5 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("2_hbvo2") +theme_override_constants/separation = 34 +alignment = 1 + +[node name="StateLabel" type="Label" parent="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Not Logged In" + +[node name="email_edit" type="LineEdit" parent="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +placeholder_text = "Email" + +[node name="password_edit" type="LineEdit" parent="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +placeholder_text = "Password" +secret = true + +[node name="log_in" type="Button" parent="VBoxContainer"] +layout_mode = 2 +text = "Log In" + +[node name="sign_up" type="Button" parent="VBoxContainer"] +layout_mode = 2 +text = "Sign Up" + +[connection signal="pressed" from="VBoxContainer/log_in" to="." method="_on_log_in_pressed"] +[connection signal="pressed" from="VBoxContainer/sign_up" to="." method="_on_sign_up_pressed"] diff --git a/addons/godot-firebase/.env b/addons/godot-firebase/.env new file mode 100644 index 0000000..914b453 --- /dev/null +++ b/addons/godot-firebase/.env @@ -0,0 +1,24 @@ +[firebase/environment_variables] + +"apiKey"="AIzaSyB9_oTvQOZp1XaqErfmrl2A0K1bm9G2_S4", +"authDomain"="godotfps.firebaseapp.com", +"databaseURL"="", +"projectId"="godotfps", +"storageBucket"="godotfps.firebasestorage.app", +"messagingSenderId"="703030364789", +"appId"="1:703030364789:web:dab5431813950942a2c63c", +"measurementId"="G-XD7BKEVQL5" +"clientId"="" +"clientSecret"="" +"domainUriPrefix"="" +"functionsGeoZone"="" +"cacheLocation"="" + +[firebase/emulators/ports] + +authentication="" +firestore="" +realtimeDatabase="" +functions="" +storage="" +dynamicLinks="" diff --git a/addons/godot-firebase/LICENSE b/addons/godot-firebase/LICENSE new file mode 100644 index 0000000..9354ed8 --- /dev/null +++ b/addons/godot-firebase/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Kyle Szklenski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/addons/godot-firebase/README.md b/addons/godot-firebase/README.md new file mode 100644 index 0000000..c298341 --- /dev/null +++ b/addons/godot-firebase/README.md @@ -0,0 +1,3 @@ +# Godot Firebase + +A Google Firebase SDK written in GDScript for use in Godot Engine projects. For more information about usage, support, and contribution, check out the [GitHub Repository](https://github.com/WolfgangSenff/GodotFirebase) and the [Wiki](https://github.com/WolfgangSenff/GodotFirebase/wiki). diff --git a/addons/godot-firebase/Utilities.gd b/addons/godot-firebase/Utilities.gd new file mode 100644 index 0000000..d9ae3b5 --- /dev/null +++ b/addons/godot-firebase/Utilities.gd @@ -0,0 +1,346 @@ +extends Node +class_name Utilities + +static func get_json_data(value): + if value is PackedByteArray: + value = value.get_string_from_utf8() + var json = JSON.new() + var json_parse_result = json.parse(value) + if json_parse_result == OK: + return json.data + + return null + + +# Pass a dictionary { 'key' : 'value' } to format it in a APIs usable .fields +# Field Path3D using the "dot" (`.`) notation are supported: +# ex. { "PATH.TO.SUBKEY" : "VALUE" } ==> { "PATH" : { "TO" : { "SUBKEY" : "VALUE" } } } +static func dict2fields(dict : Dictionary) -> Dictionary: + var fields = {} + var var_type : String = "" + for field in dict.keys(): + var field_value = dict[field] + if field is String and "." in field: + var keys: Array = field.split(".") + field = keys.pop_front() + keys.reverse() + for key in keys: + field_value = { key : field_value } + + match typeof(field_value): + TYPE_NIL: var_type = "nullValue" + TYPE_BOOL: var_type = "booleanValue" + TYPE_INT: var_type = "integerValue" + TYPE_FLOAT: var_type = "doubleValue" + TYPE_STRING: var_type = "stringValue" + TYPE_DICTIONARY: + if is_field_timestamp(field_value): + var_type = "timestampValue" + field_value = dict2timestamp(field_value) + else: + var_type = "mapValue" + field_value = dict2fields(field_value) + TYPE_ARRAY: + var_type = "arrayValue" + field_value = {"values": array2fields(field_value)} + + if fields.has(field) and fields[field].has("mapValue") and field_value.has("fields"): + for key in field_value["fields"].keys(): + fields[field]["mapValue"]["fields"][key] = field_value["fields"][key] + else: + fields[field] = { var_type : field_value } + + return {'fields' : fields} + + +class FirebaseTypeConverter extends RefCounted: + var converters = { + "nullValue": _to_null, + "booleanValue": _to_bool, + "integerValue": _to_int, + "doubleValue": _to_float + } + + func convert_value(type, value): + if converters.has(type): + return converters[type].call(value) + + return value + + func _to_null(value): + return null + + func _to_bool(value): + return bool(value) + + func _to_int(value): + return int(value) + + func _to_float(value): + return float(value) + +static func from_firebase_type(value): + if value == null: + return null + + if value.has("mapValue"): + value = fields2dict(value.values()[0]) + elif value.has("arrayValue"): + value = fields2array(value.values()[0]) + elif value.has("timestampValue"): + value = Time.get_datetime_dict_from_datetime_string(value.values()[0], false) + else: + var converter = FirebaseTypeConverter.new() + value = converter.convert_value(value.keys()[0], value.values()[0]) + + return value + + +static func to_firebase_type(value : Variant) -> Dictionary: + var var_type : String = "" + + match typeof(value): + TYPE_NIL: var_type = "nullValue" + TYPE_BOOL: var_type = "booleanValue" + TYPE_INT: var_type = "integerValue" + TYPE_FLOAT: var_type = "doubleValue" + TYPE_STRING: var_type = "stringValue" + TYPE_DICTIONARY: + if is_field_timestamp(value): + var_type = "timestampValue" + value = dict2timestamp(value) + else: + var_type = "mapValue" + value = dict2fields(value) + TYPE_ARRAY: + var_type = "arrayValue" + value = {"values": array2fields(value)} + + return { var_type : value } + +# Pass the .fields inside a Firestore Document to print out the Dictionary { 'key' : 'value' } +static func fields2dict(doc) -> Dictionary: + var dict = {} + if doc.has("fields"): + var fields = doc["fields"] + + for field in fields.keys(): + if fields[field].has("mapValue"): + dict[field] = (fields2dict(fields[field].mapValue)) + elif fields[field].has("timestampValue"): + dict[field] = timestamp2dict(fields[field].timestampValue) + elif fields[field].has("arrayValue"): + dict[field] = fields2array(fields[field].arrayValue) + elif fields[field].has("integerValue"): + dict[field] = fields[field].values()[0] as int + elif fields[field].has("doubleValue"): + dict[field] = fields[field].values()[0] as float + elif fields[field].has("booleanValue"): + dict[field] = fields[field].values()[0] as bool + elif fields[field].has("nullValue"): + dict[field] = null + else: + dict[field] = fields[field].values()[0] + return dict + +# Pass an Array to parse it to a Firebase arrayValue +static func array2fields(array : Array) -> Array: + var fields : Array = [] + var var_type : String = "" + for field in array: + match typeof(field): + TYPE_DICTIONARY: + if is_field_timestamp(field): + var_type = "timestampValue" + field = dict2timestamp(field) + else: + var_type = "mapValue" + field = dict2fields(field) + TYPE_NIL: var_type = "nullValue" + TYPE_BOOL: var_type = "booleanValue" + TYPE_INT: var_type = "integerValue" + TYPE_FLOAT: var_type = "doubleValue" + TYPE_STRING: var_type = "stringValue" + TYPE_ARRAY: var_type = "arrayValue" + _: var_type = "FieldTransform" + fields.append({ var_type : field }) + return fields + +# Pass a Firebase arrayValue Dictionary to convert it back to an Array +static func fields2array(array : Dictionary) -> Array: + var fields : Array = [] + if array.has("values"): + for field in array.values: + var item + match field.keys()[0]: + "mapValue": + item = fields2dict(field.mapValue) + "arrayValue": + item = fields2array(field.arrayValue) + "integerValue": + item = field.values()[0] as int + "doubleValue": + item = field.values()[0] as float + "booleanValue": + item = field.values()[0] as bool + "timestampValue": + item = timestamp2dict(field.timestampValue) + "nullValue": + item = null + _: + item = field.values()[0] + fields.append(item) + return fields + +# Converts a gdscript Dictionary (most likely obtained with Time.get_datetime_dict_from_system()) to a Firebase Timestamp +static func dict2timestamp(dict : Dictionary) -> String: + #dict.erase('weekday') + #dict.erase('dst') + #var dict_values : Array = dict.values() + var time = Time.get_datetime_string_from_datetime_dict(dict, false) + return time + #return "%04d-%02d-%02dT%02d:%02d:%02d.00Z" % dict_values + +# Converts a Firebase Timestamp back to a gdscript Dictionary +static func timestamp2dict(timestamp : String) -> Dictionary: + return Time.get_datetime_dict_from_datetime_string(timestamp, false) + #var datetime : Dictionary = {year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0} + #var dict : PackedStringArray = timestamp.split("T")[0].split("-") + #dict.append_array(timestamp.split("T")[1].split(":")) + #for value in dict.size(): + #datetime[datetime.keys()[value]] = int(dict[value]) + #return datetime + +static func is_field_timestamp(field : Dictionary) -> bool: + return field.has_all(['year','month','day','hour','minute','second']) + + +# HTTPRequeust seems to have an issue in Web exports where the body returns empty +# This appears to be caused by the gzip compression being unsupported, so we +# disable it when web export is detected. +static func fix_http_request(http_request): + if is_web(): + http_request.accept_gzip = false + +static func is_web() -> bool: + return OS.get_name() in ["HTML5", "Web"] + + +class MultiSignal extends RefCounted: + signal completed(with_signal) + signal all_completed() + + var _has_signaled := false + var _early_exit := false + + var signal_count := 0 + + func _init(sigs : Array[Signal], early_exit := true, should_oneshot := true) -> void: + _early_exit = early_exit + for sig in sigs: + add_signal(sig, should_oneshot) + + func add_signal(sig : Signal, should_oneshot) -> void: + signal_count += 1 + sig.connect( + func(): + if not _has_signaled and _early_exit: + completed.emit(sig) + _has_signaled = true + elif not _early_exit: + completed.emit(sig) + signal_count -= 1 + if signal_count <= 0: # Not sure how it could be less than + all_completed.emit() + , CONNECT_ONE_SHOT if should_oneshot else CONNECT_REFERENCE_COUNTED + ) + +class SignalReducer extends RefCounted: # No need for a node, as this deals strictly with signals, which can be on any object. + signal completed + + var awaiters : Array[Signal] = [] + + var reducers = { + 0 : func(): completed.emit(), + 1 : func(p): completed.emit(), + 2 : func(p1, p2): completed.emit(), + 3 : func(p1, p2, p3): completed.emit(), + 4 : func(p1, p2, p3, p4): completed.emit() + } + + func add_signal(sig : Signal, param_count : int = 0) -> void: + assert(param_count < 5, "Too many parameters to reduce, just add more!") + sig.connect(reducers[param_count], CONNECT_ONE_SHOT) # May wish to not just one-shot, but instead track all of them firing + +class SignalReducerWithResult extends RefCounted: # No need for a node, as this deals strictly with signals, which can be on any object. + signal completed(result) + + var awaiters : Array[Signal] = [] + + var reducers = { + 0 : func(): completed.emit(), + 1 : func(p): completed.emit({1 : p}), + 2 : func(p1, p2): completed.emit({ 1 : p1, 2 : p2 }), + 3 : func(p1, p2, p3): completed.emit({ 1 : p1, 2 : p2, 3 : p3 }), + 4 : func(p1, p2, p3, p4): completed.emit({ 1 : p1, 2 : p2, 3 : p3, 4 : p4 }) + } + + func add_signal(sig : Signal, param_count : int = 0) -> void: + assert(param_count < 5, "Too many parameters to reduce, just add more!") + sig.connect(reducers[param_count], CONNECT_ONE_SHOT) # May wish to not just one-shot, but instead track all of them firing + +class ObservableDictionary extends RefCounted: + signal keys_changed() + + var _internal : Dictionary + var is_notifying := true + + func _init(copy : Dictionary = {}) -> void: + _internal = copy + + func add(key : Variant, value : Variant) -> void: + _internal[key] = value + if is_notifying: + keys_changed.emit() + + func update(key : Variant, value : Variant) -> void: + _internal[key] = value + if is_notifying: + keys_changed.emit() + + func has(key : Variant) -> bool: + return _internal.has(key) + + func keys(): + return _internal.keys() + + func values(): + return _internal.values() + + func erase(key : Variant) -> bool: + var result = _internal.erase(key) + if is_notifying: + keys_changed.emit() + + return result + + func get_value(key : Variant) -> Variant: + return _internal[key] + + func _get(property: StringName) -> Variant: + if _internal.has(property): + return _internal[property] + + return false + + func _set(property: StringName, value: Variant) -> bool: + update(property, value) + return true + +class AwaitDetachable extends Node2D: + var awaiter : Signal + + func _init(freeable_node, await_signal : Signal) -> void: + awaiter = await_signal + add_child(freeable_node) + awaiter.connect(queue_free) diff --git a/addons/godot-firebase/Utilities.gd.uid b/addons/godot-firebase/Utilities.gd.uid new file mode 100644 index 0000000..c9afad7 --- /dev/null +++ b/addons/godot-firebase/Utilities.gd.uid @@ -0,0 +1 @@ +uid://cggx7ysauq6p0 diff --git a/addons/godot-firebase/auth/auth.gd b/addons/godot-firebase/auth/auth.gd new file mode 100644 index 0000000..394c53f --- /dev/null +++ b/addons/godot-firebase/auth/auth.gd @@ -0,0 +1,693 @@ +## @meta-authors TODO +## @meta-version 2.5 +## The authentication API for Firebase. +## Documentation TODO. +@tool +class_name FirebaseAuth +extends HTTPRequest + +const _API_VERSION : String = "v1" +const _INAPP_PLUGIN : String = "GodotSvc" + +# Emitted for each Auth request issued. +# `result_code` -> Either `1` if auth succeeded or `error_code` if unsuccessful auth request +# `result_content` -> Either `auth_result` if auth succeeded or `error_message` if unsuccessful auth request +signal auth_request(result_code, result_content) + +signal signup_succeeded(auth_result) +signal login_succeeded(auth_result) +signal login_failed(code, message) +signal signup_failed(code, message) +signal userdata_received(userdata) +signal token_exchanged(successful) +signal token_refresh_succeeded(auth_result) +signal logged_out() + +const RESPONSE_SIGNUP : String = "identitytoolkit#SignupNewUserResponse" +const RESPONSE_SIGNIN : String = "identitytoolkit#VerifyPasswordResponse" +const RESPONSE_ASSERTION : String = "identitytoolkit#VerifyAssertionResponse" +const RESPONSE_USERDATA : String = "identitytoolkit#GetAccountInfoResponse" +const RESPONSE_CUSTOM_TOKEN : String = "identitytoolkit#VerifyCustomTokenResponse" + +var _base_url : String = "" +var _refresh_request_base_url = "" +var _signup_request_url : String = "accounts:signUp?key=%s" +var _signin_with_oauth_request_url : String = "accounts:signInWithIdp?key=%s" +var _signin_request_url : String = "accounts:signInWithPassword?key=%s" +var _signin_custom_token_url : String = "accounts:signInWithCustomToken?key=%s" +var _userdata_request_url : String = "accounts:lookup?key=%s" +var _oobcode_request_url : String = "accounts:sendOobCode?key=%s" +var _delete_account_request_url : String = "accounts:delete?key=%s" +var _update_account_request_url : String = "accounts:update?key=%s" + +var _refresh_request_url : String = "/v1/token?key=%s" +var _google_auth_request_url : String = "https://accounts.google.com/o/oauth2/v2/auth?" + +var _config : Dictionary = {} +var auth : Dictionary = {} +var _needs_refresh : bool = false +var is_busy : bool = false +var has_child : bool = false +var is_oauth_login: bool = false + + +var tcp_server : TCPServer = TCPServer.new() +var tcp_timer : Timer = Timer.new() +var tcp_timeout : float = 0.5 + +var _headers : PackedStringArray = [ + "Content-Type: application/json", + "Accept: application/json", +] + +var requesting : int = -1 + +enum Requests { + NONE = -1, + EXCHANGE_TOKEN, + LOGIN_WITH_OAUTH +} + +var auth_request_type : int = -1 + +enum Auth_Type { + NONE = -1, + LOGIN_EP, + LOGIN_ANON, + LOGIN_CT, + LOGIN_OAUTH, + SIGNUP_EP +} + +var _login_request_body : Dictionary = { + "email":"", + "password":"", + "returnSecureToken": true, +} + +var _oauth_login_request_body : Dictionary = { + "postBody":"", + "requestUri":"", + "returnIdpCredential":false, + "returnSecureToken":true +} + +var _anonymous_login_request_body : Dictionary = { + "returnSecureToken":true +} + +var _refresh_request_body : Dictionary = { + "grant_type":"refresh_token", + "refresh_token":"", +} + +var _custom_token_body : Dictionary = { + "token":"", + "returnSecureToken":true +} + +var _password_reset_body : Dictionary = { + "requestType":"password_reset", + "email":"", +} + + +var _change_email_body : Dictionary = { + "idToken":"", + "email":"", + "returnSecureToken": true, +} + + +var _change_password_body : Dictionary = { + "idToken":"", + "password":"", + "returnSecureToken": true, +} + + +var _account_verification_body : Dictionary = { + "requestType":"verify_email", + "idToken":"", +} + + +var _update_profile_body : Dictionary = { + "idToken":"", + "displayName":"", + "photoUrl":"", + "deleteAttribute":"", + "returnSecureToken":true +} + +var link_account_body : Dictionary = { + "idToken":"", + "email":"", + "password":"", + "returnSecureToken":true +} + +var _local_port : int = 8060 +var _local_uri : String = "http://localhost:%s/"%_local_port +var _local_provider : AuthProvider = AuthProvider.new() + +func _ready() -> void: + tcp_timer.wait_time = tcp_timeout + tcp_timer.timeout.connect(_tcp_stream_timer) + + Utilities.fix_http_request(self) + if Utilities.is_web(): + _local_uri += "tmp_js_export.html" + + +# Sets the configuration needed for the plugin to talk to Firebase +# These settings come from the Firebase.gd script automatically +func _set_config(config_json : Dictionary) -> void: + _config = config_json + _signup_request_url %= _config.apiKey + _signin_request_url %= _config.apiKey + _signin_custom_token_url %= _config.apiKey + _signin_with_oauth_request_url %= _config.apiKey + _userdata_request_url %= _config.apiKey + _refresh_request_url %= _config.apiKey + _oobcode_request_url %= _config.apiKey + _delete_account_request_url %= _config.apiKey + _update_account_request_url %= _config.apiKey + + request_completed.connect(_on_FirebaseAuth_request_completed) + _check_emulating() + + +func _check_emulating() -> void : + ## Check emulating + if not Firebase.emulating: + _base_url = "https://identitytoolkit.googleapis.com/{version}/".format({ version = _API_VERSION }) + _refresh_request_base_url = "https://securetoken.googleapis.com" + else: + var port : String = _config.emulators.ports.authentication + if port == "": + Firebase._printerr("You are in 'emulated' mode, but the port for Authentication has not been configured.") + else: + _base_url = "http://localhost:{port}/identitytoolkit.googleapis.com/{version}/".format({ version = _API_VERSION ,port = port }) + _refresh_request_base_url = "http://localhost:{port}/securetoken.googleapis.com".format({port = port}) + + +# Function is used to check if the auth script is ready to process a request. Returns true if it is not currently processing +# If false it will print an error +func _is_ready() -> bool: + if is_busy: + Firebase._printerr("Firebase Auth is currently busy and cannot process this request") + return false + else: + return true + +# Function cleans the URI and replaces spaces with %20 +# As of right now we only replace spaces +# We may need to decide to use the uri_encode() String function +func _clean_url(_url): + _url = _url.replace(' ','%20') + return _url + +# Synchronous call to check if any user is already logged in. +func is_logged_in() -> bool: + return auth != null and auth.has("idtoken") + + +# Called with Firebase.Auth.signup_with_email_and_password(email, password) +# You must pass in the email and password to this function for it to work correctly +func signup_with_email_and_password(email : String, password : String) -> void: + if _is_ready(): + is_busy = true + _login_request_body.email = email + _login_request_body.password = password + auth_request_type = Auth_Type.SIGNUP_EP + var err = request(_base_url + _signup_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_login_request_body)) + _login_request_body.email = "" + _login_request_body.password = "" + if err != OK: + is_busy = false + Firebase._printerr("Error signing up with password and email: %s" % err) + + +# Called with Firebase.Auth.anonymous_login() +# A successful request is indicated by a 200 OK HTTP status code. +# The response contains the Firebase ID token and refresh token associated with the anonymous user. +# The 'mail' field will be empty since no email is linked to an anonymous user +func login_anonymous() -> void: + if _is_ready(): + is_busy = true + auth_request_type = Auth_Type.LOGIN_ANON + var err = request(_base_url + _signup_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_anonymous_login_request_body)) + if err != OK: + is_busy = false + Firebase._printerr("Error logging in as anonymous: %s" % err) + +# Called with Firebase.Auth.login_with_email_and_password(email, password) +# You must pass in the email and password to this function for it to work correctly +# If the login fails it will return an error code through the function _on_FirebaseAuth_request_completed +func login_with_email_and_password(email : String, password : String) -> void: + if _is_ready(): + is_busy = true + _login_request_body.email = email + _login_request_body.password = password + auth_request_type = Auth_Type.LOGIN_EP + var err = request(_base_url + _signin_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_login_request_body)) + _login_request_body.email = "" + _login_request_body.password = "" + if err != OK: + is_busy = false + Firebase._printerr("Error logging in with password and email: %s" % err) + +# Login with a custom valid token +# The token needs to be generated using an external service/function +func login_with_custom_token(token : String) -> void: + if _is_ready(): + is_busy = true + _custom_token_body.token = token + auth_request_type = Auth_Type.LOGIN_CT + var err = request(_base_url + _signin_custom_token_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_custom_token_body)) + if err != OK: + is_busy = false + Firebase._printerr("Error logging in with custom token: %s" % err) + +# Open a web page in browser redirecting to Google oAuth2 page for the current project +# Once given user's authorization, a token will be generated. +# NOTE** the generated token will be automatically captured and a login request will be made if the token is correct +func get_auth_localhost(provider: AuthProvider = get_GoogleProvider(), port : int = _local_port): + get_auth_with_redirect(provider) + await get_tree().create_timer(0.5).timeout + if has_child == false: + add_child(tcp_timer) + has_child = true + tcp_timer.start() + tcp_server.listen(port, "*") + + +func get_auth_with_redirect(provider: AuthProvider) -> void: + var url_endpoint: String = provider.redirect_uri + for key in provider.params.keys(): + url_endpoint+=key+"="+provider.params[key]+"&" + url_endpoint += provider.params.redirect_type+"="+_local_uri + url_endpoint = _clean_url(url_endpoint) + if Utilities.is_web() and OS.has_feature("JavaScript"): + JavaScriptBridge.eval('window.location.replace("' + url_endpoint + '")') + elif Engine.has_singleton(_INAPP_PLUGIN) and OS.get_name() == "iOS": + #in app for ios if the iOS plugin exists + set_local_provider(provider) + Engine.get_singleton(_INAPP_PLUGIN).popup(url_endpoint) + else: + set_local_provider(provider) + OS.shell_open(url_endpoint) + + +# Login with Google oAuth2. +# A token is automatically obtained using an authorization code using @get_google_auth() +# @provider_id and @request_uri can be changed +func login_with_oauth(_token: String, provider: AuthProvider) -> void: + if _token: + is_oauth_login = true + var token : String = _token.uri_decode() + var is_successful: bool = true + if provider.should_exchange: + exchange_token(token, _local_uri, provider.access_token_uri, provider.get_client_id(), provider.get_client_secret()) + is_successful = await self.token_exchanged + token = auth.accesstoken + if is_successful and _is_ready(): + is_busy = true + _oauth_login_request_body.postBody = "access_token="+token+"&providerId="+provider.provider_id + _oauth_login_request_body.requestUri = _local_uri + requesting = Requests.LOGIN_WITH_OAUTH + auth_request_type = Auth_Type.LOGIN_OAUTH + var err = request(_base_url + _signin_with_oauth_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_oauth_login_request_body)) + _oauth_login_request_body.postBody = "" + _oauth_login_request_body.requestUri = "" + if err != OK: + is_busy = false + Firebase._printerr("Error logging in with oauth: %s" % err) + +# Exchange the authorization oAuth2 code obtained from browser with a proper access id_token +func exchange_token(code : String, redirect_uri : String, request_url: String, _client_id: String, _client_secret: String) -> void: + if _is_ready(): + is_busy = true + var exchange_token_body : Dictionary = { + code = code, + redirect_uri = redirect_uri, + client_id = _client_id, + client_secret = _client_secret, + grant_type = "authorization_code", + } + requesting = Requests.EXCHANGE_TOKEN + var err = request(request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(exchange_token_body)) + if err != OK: + is_busy = false + Firebase._printerr("Error exchanging tokens: %s" % err) + +# Open a web page in browser redirecting to Google oAuth2 page for the current project +# Once given user's authorization, a token will be generated. +# NOTE** with this method, the authorization process will be copy-pasted +func get_google_auth_manual(provider: AuthProvider = _local_provider) -> void: + provider.params.redirect_uri = "urn:ietf:wg:oauth:2.0:oob" + get_auth_with_redirect(provider) + +# A timer used to listen through TCP checked the redirect uri of the request +func _tcp_stream_timer() -> void: + var peer : StreamPeer = tcp_server.take_connection() + if peer != null: + var raw_result : String = peer.get_utf8_string(441) + if raw_result != "" and raw_result.begins_with("GET"): + tcp_timer.stop() + remove_child(tcp_timer) + has_child = false + var token : String = "" + for value in raw_result.split(" ")[1].lstrip("/?").split("&"): + var splitted: PackedStringArray = value.split("=") + if _local_provider.params.response_type in splitted[0]: + token = splitted[1] + break + + if token == "": + login_failed.emit() + peer.disconnect_from_host() + tcp_server.stop() + return + + var data : PackedByteArray = '

🔥 You can close this window now. 🔥

'.to_ascii_buffer() + peer.put_data(("HTTP/1.1 200 OK\n").to_ascii_buffer()) + peer.put_data(("Server: Godot Firebase SDK\n").to_ascii_buffer()) + peer.put_data(("Content-Length: %d\n" % data.size()).to_ascii_buffer()) + peer.put_data("Connection: close\n".to_ascii_buffer()) + peer.put_data(("Content-Type: text/html; charset=UTF-8\n\n").to_ascii_buffer()) + peer.put_data(data) + login_with_oauth(token, _local_provider) + await self.login_succeeded + peer.disconnect_from_host() + tcp_server.stop() + + +# Function used to logout of the system, this will also remove_at the local encrypted auth file if there is one +func logout() -> void: + auth = {} + remove_auth() + logged_out.emit() + +# Checks to see if we need a hard login +func needs_login() -> bool: + var encrypted_file = FileAccess.open_encrypted_with_pass("user://user.auth", FileAccess.READ, _config.apiKey) + var err = encrypted_file == null + return err + +# Function is called when requesting a manual token refresh +func manual_token_refresh(auth_data): + auth = auth_data + var refresh_token = null + auth = get_clean_keys(auth) + if auth.has("refreshtoken"): + refresh_token = auth.refreshtoken + elif auth.has("refresh_token"): + refresh_token = auth.refresh_token + _needs_refresh = true + _refresh_request_body.refresh_token = refresh_token + var err = request(_refresh_request_base_url + _refresh_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_refresh_request_body)) + if err != OK: + is_busy = false + Firebase._printerr("Error manually refreshing token: %s" % err) + + +# This function is called whenever there is an authentication request to Firebase +# On an error, this function with emit the signal 'login_failed' and print the error to the console +func _on_FirebaseAuth_request_completed(result : int, response_code : int, headers : PackedStringArray, body : PackedByteArray) -> void: + var json = Utilities.get_json_data(body.get_string_from_utf8()) + is_busy = false + var res + if response_code == 0: + # Mocked error results to trigger the correct signal. + # Can occur if there is no internet connection, or the service is down, + # in which case there is no json_body (and thus parsing would fail). + res = {"error": { + "code": "Connection error", + "message": "Error connecting to auth service"}} + else: + if json == null: + Firebase._printerr("Error while parsing auth body json") + auth_request.emit(ERR_PARSE_ERROR, "Error while parsing auth body json") + return + + res = json + if response_code == HTTPClient.RESPONSE_OK: + if not res.has("kind"): + auth = get_clean_keys(res) + match requesting: + Requests.EXCHANGE_TOKEN: + token_exchanged.emit(true) + begin_refresh_countdown() + # Refresh token countdown + auth_request.emit(1, auth) + + if _needs_refresh: + _needs_refresh = false + if not is_oauth_login: login_succeeded.emit(auth) + else: + match res.kind: + RESPONSE_SIGNUP: + auth = get_clean_keys(res) + signup_succeeded.emit(auth) + begin_refresh_countdown() + RESPONSE_SIGNIN, RESPONSE_ASSERTION, RESPONSE_CUSTOM_TOKEN: + auth = get_clean_keys(res) + login_succeeded.emit(auth) + begin_refresh_countdown() + RESPONSE_USERDATA: + var userdata = FirebaseUserData.new(res.users[0]) + userdata_received.emit(userdata) + auth_request.emit(1, auth) + else: + # error message would be INVALID_EMAIL, EMAIL_NOT_FOUND, INVALID_PASSWORD, USER_DISABLED or WEAK_PASSWORD + if requesting == Requests.EXCHANGE_TOKEN: + token_exchanged.emit(false) + login_failed.emit(res.error, res.error_description) + auth_request.emit(res.error, res.error_description) + else: + var sig = signup_failed if auth_request_type == Auth_Type.SIGNUP_EP else login_failed + sig.emit(res.error.code, res.error.message) + auth_request.emit(res.error.code, res.error.message) + requesting = Requests.NONE + auth_request_type = Auth_Type.NONE + is_oauth_login = false + + + +# Function used to save the auth data provided by Firebase into an encrypted file +# Note this does not work in HTML5 or UWP +func save_auth(auth : Dictionary) -> bool: + var encrypted_file = FileAccess.open_encrypted_with_pass("user://user.auth", FileAccess.WRITE, _config.apiKey) + var err = encrypted_file == null + if err: + Firebase._printerr("Error Opening File. Error Code: " + str(FileAccess.get_open_error())) + else: + encrypted_file.store_line(JSON.stringify(auth)) + return not err + + +# Function used to load the auth data file that has been stored locally +# Note this does not work in HTML5 or UWP +func load_auth() -> bool: + var encrypted_file = FileAccess.open_encrypted_with_pass("user://user.auth", FileAccess.READ, _config.apiKey) + var err = encrypted_file == null + if err: + Firebase._printerr("Error Opening Firebase Auth File. Error Code: " + str(FileAccess.get_open_error())) + auth_request.emit(err, "Error Opening Firebase Auth File.") + else: + var json = JSON.new() + var json_parse_result = json.parse(encrypted_file.get_line()) + if json_parse_result == OK: + var encrypted_file_data = json.data + manual_token_refresh(encrypted_file_data) + return not err + +# Function used to remove_at the local encrypted auth file +func remove_auth() -> void: + if (FileAccess.file_exists("user://user.auth")): + DirAccess.remove_absolute("user://user.auth") + else: + Firebase._printerr("No encrypted auth file exists") + + +# Function to check if there is an encrypted auth data file +# If there is, the game will load it and refresh the token +func check_auth_file() -> bool: + if (FileAccess.file_exists("user://user.auth")): + # Will ensure "auth_request" emitted + return load_auth() + else: + Firebase._printerr("Encrypted Firebase Auth file does not exist") + auth_request.emit(ERR_DOES_NOT_EXIST, "Encrypted Firebase Auth file does not exist") + return false + + +# Function used to change the email account for the currently logged in user +func change_user_email(email : String) -> void: + if _is_ready(): + is_busy = true + _change_email_body.email = email + _change_email_body.idToken = auth.idtoken + var err = request(_base_url + _update_account_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_change_email_body)) + if err != OK: + is_busy = false + Firebase._printerr("Error changing user email: %s" % err) + + +# Function used to change the password for the currently logged in user +func change_user_password(password : String) -> void: + if _is_ready(): + is_busy = true + _change_password_body.password = password + _change_password_body.idToken = auth.idtoken + var err = request(_base_url + _update_account_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_change_password_body)) + if err != OK: + is_busy = false + Firebase._printerr("Error changing user password: %s" % err) + + +# User Profile handlers +func update_account(idToken : String, displayName : String, photoUrl : String, deleteAttribute : PackedStringArray, returnSecureToken : bool) -> void: + if _is_ready(): + is_busy = true + _update_profile_body.idToken = idToken + _update_profile_body.displayName = displayName + _update_profile_body.photoUrl = photoUrl + _update_profile_body.deleteAttribute = deleteAttribute + _update_profile_body.returnSecureToken = returnSecureToken + var err = request(_base_url + _update_account_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_update_profile_body)) + if err != OK: + is_busy = false + Firebase._printerr("Error updating account: %s" % err) + +# Link account with Email and Password +func link_account(email : String, password : String) -> void: + if _is_ready(): + is_busy = true + link_account_body.idToken = auth.idtoken + link_account_body.email = email + link_account_body.password = password + var err = request(_base_url + _update_account_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(link_account_body)) + if err != OK: + is_busy = false + Firebase._printerr("Error updating account: %s" % err) + + +# Function to send a account verification email +func send_account_verification_email() -> void: + if _is_ready(): + is_busy = true + _account_verification_body.idToken = auth.idtoken + var err = request(_base_url + _oobcode_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_account_verification_body)) + if err != OK: + is_busy = false + Firebase._printerr("Error sending account verification email: %s" % err) + + +# Function used to reset the password for a user who has forgotten in. +# This will send the users account an email with a password reset link +func send_password_reset_email(email : String) -> void: + if _is_ready(): + is_busy = true + _password_reset_body.email = email + var err = request(_base_url + _oobcode_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_password_reset_body)) + if err != OK: + is_busy = false + Firebase._printerr("Error sending password reset email: %s" % err) + + +# Function called to get all +func get_user_data() -> void: + if _is_ready(): + is_busy = true + if not is_logged_in(): + print_debug("Not logged in") + is_busy = false + return + + var err = request(_base_url + _userdata_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify({"idToken":auth.idtoken})) + if err != OK: + is_busy = false + Firebase._printerr("Error getting user data: %s" % err) + + +# Function used to delete the account of the currently authenticated user +func delete_user_account() -> void: + if _is_ready(): + is_busy = true + var err = request(_base_url + _delete_account_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify({"idToken":auth.idtoken})) + if err != OK: + is_busy = false + Firebase._printerr("Error deleting user: %s" % err) + else: + remove_auth() + + +# Function is called when a new token is issued to a user. The function will yield until the token has expired, and then request a new one. +func begin_refresh_countdown() -> void: + var refresh_token = null + var expires_in = 1000 + auth = get_clean_keys(auth) + if auth.has("refreshtoken"): + refresh_token = auth.refreshtoken + expires_in = auth.expiresin + elif auth.has("refresh_token"): + refresh_token = auth.refresh_token + expires_in = auth.expires_in + if auth.has("userid"): + auth["localid"] = auth.userid + _needs_refresh = true + token_refresh_succeeded.emit(auth) + await get_tree().create_timer(float(expires_in)).timeout + _refresh_request_body.refresh_token = refresh_token + var err = request(_refresh_request_base_url + _refresh_request_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_refresh_request_body)) + if err != OK: + is_busy = false + Firebase._printerr("Error refreshing via countdown: %s" % err) + + +func get_token_from_url(provider: AuthProvider): + var token_type: String = provider.params.response_type if provider.params.response_type == "code" else "access_token" + if OS.has_feature('web'): + var token = JavaScriptBridge.eval(""" + var url_string = window.location.href.replaceAll('?#', '?'); + var url = new URL(url_string); + url.searchParams.get('"""+token_type+"""'); + """) + JavaScriptBridge.eval("""window.history.pushState({}, null, location.href.split('?')[0]);""") + return token + return null + + +func set_redirect_uri(redirect_uri : String) -> void: + self._local_uri = redirect_uri + +func set_local_provider(provider : AuthProvider) -> void: + self._local_provider = provider + +# This function is used to make all keys lowercase +# This is only used to cut down checked processing errors from Firebase +# This is due to Google have inconsistencies in the API that we are trying to fix +func get_clean_keys(auth_result : Dictionary) -> Dictionary: + var cleaned = {} + for key in auth_result.keys(): + cleaned[key.replace("_", "").to_lower()] = auth_result[key] + return cleaned + +# -------------------- +# PROVIDERS +# -------------------- + +func get_GoogleProvider() -> GoogleProvider: + return GoogleProvider.new(_config.clientId, _config.clientSecret) + +func get_FacebookProvider() -> FacebookProvider: + return FacebookProvider.new(_config.auth_providers.facebook_id, _config.auth_providers.facebook_secret) + +func get_GitHubProvider() -> GitHubProvider: + return GitHubProvider.new(_config.auth_providers.github_id, _config.auth_providers.github_secret) + +func get_TwitterProvider() -> TwitterProvider: + return TwitterProvider.new(_config.auth_providers.twitter_id, _config.auth_providers.twitter_secret) diff --git a/addons/godot-firebase/auth/auth.gd.uid b/addons/godot-firebase/auth/auth.gd.uid new file mode 100644 index 0000000..a7cb756 --- /dev/null +++ b/addons/godot-firebase/auth/auth.gd.uid @@ -0,0 +1 @@ +uid://bvlvihyfqktvr diff --git a/addons/godot-firebase/auth/auth_provider.gd b/addons/godot-firebase/auth/auth_provider.gd new file mode 100644 index 0000000..288cf2d --- /dev/null +++ b/addons/godot-firebase/auth/auth_provider.gd @@ -0,0 +1,32 @@ +@tool +class_name AuthProvider +extends RefCounted + +var redirect_uri: String = "" +var access_token_uri: String = "" +var provider_id: String = "" +var params: Dictionary = { + client_id = "", + scope = "", + response_type = "", + state = "", + redirect_type = "redirect_uri", +} +var client_secret: String = "" +var should_exchange: bool = false + + +func set_client_id(client_id: String) -> void: + self.params.client_id = client_id + +func set_client_secret(client_secret: String) -> void: + self.client_secret = client_secret + +func get_client_id() -> String: + return self.params.client_id + +func get_client_secret() -> String: + return self.client_secret + +func get_oauth_params() -> String: + return "" diff --git a/addons/godot-firebase/auth/auth_provider.gd.uid b/addons/godot-firebase/auth/auth_provider.gd.uid new file mode 100644 index 0000000..78488c1 --- /dev/null +++ b/addons/godot-firebase/auth/auth_provider.gd.uid @@ -0,0 +1 @@ +uid://i5o82uhvlsyj diff --git a/addons/godot-firebase/auth/providers/facebook.gd b/addons/godot-firebase/auth/providers/facebook.gd new file mode 100644 index 0000000..35e8dd8 --- /dev/null +++ b/addons/godot-firebase/auth/providers/facebook.gd @@ -0,0 +1,21 @@ +class_name FacebookProvider +extends AuthProvider + +func _init(client_id: String,client_secret: String): + randomize() + set_client_id(client_id) + set_client_secret(client_secret) + + self.redirect_uri = "https://www.facebook.com/v13.0/dialog/oauth?" + self.access_token_uri = "https://graph.facebook.com/v13.0/oauth/access_token" + self.provider_id = "facebook.com" + self.params.scope = "public_profile" + self.params.state = str(randf_range(0, 1)) + if Utilities.is_web(): + self.should_exchange = false + self.params.response_type = "token" + else: + self.should_exchange = true + self.params.response_type = "code" + + diff --git a/addons/godot-firebase/auth/providers/facebook.gd.uid b/addons/godot-firebase/auth/providers/facebook.gd.uid new file mode 100644 index 0000000..acaefec --- /dev/null +++ b/addons/godot-firebase/auth/providers/facebook.gd.uid @@ -0,0 +1 @@ +uid://s5c12vma7ufy diff --git a/addons/godot-firebase/auth/providers/github.gd b/addons/godot-firebase/auth/providers/github.gd new file mode 100644 index 0000000..bab073a --- /dev/null +++ b/addons/godot-firebase/auth/providers/github.gd @@ -0,0 +1,14 @@ +class_name GitHubProvider +extends AuthProvider + +func _init(client_id: String,client_secret: String): + randomize() + set_client_id(client_id) + set_client_secret(client_secret) + self.should_exchange = true + self.redirect_uri = "https://github.com/login/oauth/authorize?" + self.access_token_uri = "https://github.com/login/oauth/access_token" + self.provider_id = "github.com" + self.params.scope = "user:read" + self.params.state = str(randf_range(0, 1)) + self.params.response_type = "code" diff --git a/addons/godot-firebase/auth/providers/github.gd.uid b/addons/godot-firebase/auth/providers/github.gd.uid new file mode 100644 index 0000000..ebb0f2e --- /dev/null +++ b/addons/godot-firebase/auth/providers/github.gd.uid @@ -0,0 +1 @@ +uid://gactahg7liyw diff --git a/addons/godot-firebase/auth/providers/google.gd b/addons/godot-firebase/auth/providers/google.gd new file mode 100644 index 0000000..152a5cc --- /dev/null +++ b/addons/godot-firebase/auth/providers/google.gd @@ -0,0 +1,13 @@ +class_name GoogleProvider +extends AuthProvider + +func _init(client_id: String,client_secret: String): + set_client_id(client_id) + set_client_secret(client_secret) + self.should_exchange = true + self.redirect_uri = "https://accounts.google.com/o/oauth2/v2/auth?" + self.access_token_uri = "https://oauth2.googleapis.com/token" + self.provider_id = "google.com" + self.params.response_type = "code" + self.params.scope = "email openid profile" + self.params.response_type = "code" diff --git a/addons/godot-firebase/auth/providers/google.gd.uid b/addons/godot-firebase/auth/providers/google.gd.uid new file mode 100644 index 0000000..1fb0062 --- /dev/null +++ b/addons/godot-firebase/auth/providers/google.gd.uid @@ -0,0 +1 @@ +uid://dkl7y6dl8fg8s diff --git a/addons/godot-firebase/auth/providers/twitter.gd b/addons/godot-firebase/auth/providers/twitter.gd new file mode 100644 index 0000000..1ec11cf --- /dev/null +++ b/addons/godot-firebase/auth/providers/twitter.gd @@ -0,0 +1,39 @@ +class_name TwitterProvider +extends AuthProvider + +var request_token_endpoint: String = "https://api.twitter.com/oauth/access_token?oauth_callback=" + +var oauth_header: Dictionary = { + oauth_callback="", + oauth_consumer_key="", + oauth_nonce="", + oauth_signature="", + oauth_signature_method="HMAC-SHA1", + oauth_timestamp="", + oauth_version="1.0" +} + +func _init(client_id: String,client_secret: String): + randomize() + set_client_id(client_id) + set_client_secret(client_secret) + + self.oauth_header.oauth_consumer_key = client_id + self.oauth_header.oauth_nonce = Time.get_ticks_usec() + self.oauth_header.oauth_timestamp = Time.get_ticks_msec() + + + self.should_exchange = true + self.redirect_uri = "https://twitter.com/i/oauth2/authorize?" + self.access_token_uri = "https://api.twitter.com/2/oauth2/token" + self.provider_id = "twitter.com" + self.params.redirect_type = "redirect_uri" + self.params.response_type = "code" + self.params.scope = "users.read" + self.params.state = str(randf_range(0, 1)) + +func get_oauth_params() -> String: + var params: PackedStringArray = [] + for key in self.oauth.keys(): + params.append(key+"="+self.oauth.get(key)) + return "&".join(params) diff --git a/addons/godot-firebase/auth/providers/twitter.gd.uid b/addons/godot-firebase/auth/providers/twitter.gd.uid new file mode 100644 index 0000000..ba6e45a --- /dev/null +++ b/addons/godot-firebase/auth/providers/twitter.gd.uid @@ -0,0 +1 @@ +uid://bwvku5unmemyb diff --git a/addons/godot-firebase/auth/user_data.gd b/addons/godot-firebase/auth/user_data.gd new file mode 100644 index 0000000..c76e515 --- /dev/null +++ b/addons/godot-firebase/auth/user_data.gd @@ -0,0 +1,44 @@ +## @meta-authors TODO +## @meta-version 2.3 +## Authentication user data. +## Documentation TODO. +@tool +class_name FirebaseUserData +extends RefCounted + +var local_id : String = "" # The uid of the current user. +var email : String = "" +var email_verified := false # Whether or not the account's email has been verified. +var password_updated_at : float = 0 # The timestamp, in milliseconds, that the account password was last changed. +var last_login_at : float = 0 # The timestamp, in milliseconds, that the account last logged in at. +var created_at : float = 0 # The timestamp, in milliseconds, that the account was created at. +var provider_user_info : Array = [] + +var provider_id : String = "" +var display_name : String = "" +var photo_url : String = "" + +func _init(p_userdata : Dictionary): + local_id = p_userdata.get("localId", "") + email = p_userdata.get("email", "") + email_verified = p_userdata.get("emailVerified", false) + last_login_at = float(p_userdata.get("lastLoginAt", 0)) + created_at = float(p_userdata.get("createdAt", 0)) + password_updated_at = float(p_userdata.get("passwordUpdatedAt", 0)) + display_name = p_userdata.get("displayName", "") + provider_user_info = p_userdata.get("providerUserInfo", []) + if not provider_user_info.is_empty(): + provider_id = provider_user_info[0].get("providerId", "") + photo_url = provider_user_info[0].get("photoUrl", "") + display_name = provider_user_info[0].get("displayName", "") + +func as_text() -> String: + return _to_string() + +func _to_string() -> String: + var txt = "local_id : %s\n" % local_id + txt += "email : %s\n" % email + txt += "last_login_at : %d\n" % last_login_at + txt += "provider_id : %s\n" % provider_id + txt += "display name : %s\n" % display_name + return txt diff --git a/addons/godot-firebase/auth/user_data.gd.uid b/addons/godot-firebase/auth/user_data.gd.uid new file mode 100644 index 0000000..38767db --- /dev/null +++ b/addons/godot-firebase/auth/user_data.gd.uid @@ -0,0 +1 @@ +uid://d1njxyqnur3a1 diff --git a/addons/godot-firebase/database/database.gd b/addons/godot-firebase/database/database.gd new file mode 100644 index 0000000..3391518 --- /dev/null +++ b/addons/godot-firebase/database/database.gd @@ -0,0 +1,51 @@ +## @meta-authors TODO +## @meta-version 2.2 +## The Realtime Database API for Firebase. +## Documentation TODO. +@tool +class_name FirebaseDatabase +extends Node + +var _base_url : String = "" + +var _config : Dictionary = {} + +var _auth : Dictionary = {} + +func _set_config(config_json : Dictionary) -> void: + _config = config_json + _check_emulating() + +func _check_emulating() -> void : + ## Check emulating + if not Firebase.emulating: + _base_url = _config.databaseURL + else: + var port : String = _config.emulators.ports.realtimeDatabase + if port == "": + Firebase._printerr("You are in 'emulated' mode, but the port for Realtime Database has not been configured.") + else: + _base_url = "http://localhost" + +func _on_FirebaseAuth_login_succeeded(auth_result : Dictionary) -> void: + _auth = auth_result + +func _on_FirebaseAuth_token_refresh_succeeded(auth_result : Dictionary) -> void: + _auth = auth_result + +func _on_FirebaseAuth_logout() -> void: + _auth = {} + +func get_database_reference(path : String, filter : Dictionary = {}) -> FirebaseDatabaseReference: + var firebase_reference = load("res://addons/godot-firebase/database/firebase_database_reference.tscn").instantiate() + firebase_reference.set_db_path(path, filter) + firebase_reference.set_auth_and_config(_auth, _config) + add_child(firebase_reference) + return firebase_reference + +func get_once_database_reference(path : String, filter : Dictionary = {}) -> FirebaseOnceDatabaseReference: + var firebase_reference = load("res://addons/godot-firebase/database/firebase_once_database_reference.tscn").instantiate() + firebase_reference.set_db_path(path, filter) + firebase_reference.set_auth_and_config(_auth, _config) + add_child(firebase_reference) + return firebase_reference diff --git a/addons/godot-firebase/database/database.gd.uid b/addons/godot-firebase/database/database.gd.uid new file mode 100644 index 0000000..411d91e --- /dev/null +++ b/addons/godot-firebase/database/database.gd.uid @@ -0,0 +1 @@ +uid://ba2spgo4jo2qw diff --git a/addons/godot-firebase/database/database_store.gd b/addons/godot-firebase/database/database_store.gd new file mode 100644 index 0000000..a407241 --- /dev/null +++ b/addons/godot-firebase/database/database_store.gd @@ -0,0 +1,109 @@ +## @meta-authors TODO +## @meta-version 2.2 +## Data structure that holds the currently-known data at a given path (a.k.a. reference) in a Firebase Realtime Database. +## Can process both puts and patches into the data based checked realtime events received from the service. +@tool +class_name FirebaseDatabaseStore +extends Node + +const _DELIMITER : String = "/" +const _ROOT : String = "_root" + +## @default false +## Whether the store is in debug mode. +var debug : bool = false +var _data : Dictionary = { } + + +## @args path, payload +## Puts a new payload into this data store at the given path. Any existing values in this data store +## at the specified path will be completely erased. +func put(path : String, payload) -> void: + _update_data(path, payload, false) + +## @args path, payload +## Patches an update payload into this data store at the specified path. +## NOTE: When patching in updates to arrays, payload should contain the entire new array! Updating single elements/indexes of an array is not supported. Sometimes when manually mutating array data directly from the Firebase Realtime Database console, single-element patches will be sent out which can cause issues here. +func patch(path : String, payload) -> void: + _update_data(path, payload, true) + +## @args path, payload +## Deletes data at the reference point provided +## NOTE: This will delete without warning, so make sure the reference is pointed to the level you want and not the root or you will lose everything +func delete(path : String, payload) -> void: + _update_data(path, payload, true) + +## Returns a deep copy of this data store's payload. +func get_data() -> Dictionary: + return _data[_ROOT].duplicate(true) + +# +# Updates this data store by either putting or patching the provided payload into it at the given +# path. The provided payload can technically be any value. +# +func _update_data(path: String, payload, patch: bool) -> void: + if debug: + print("Updating data store (patch = %s) (%s = %s)..." % [patch, path, payload]) + + # + # Remove any leading separators. + # + path = path.lstrip(_DELIMITER) + + # + # Traverse the path. + # + var dict = _data + var keys = PackedStringArray([_ROOT]) + + keys.append_array(path.split(_DELIMITER, false)) + + var final_key_idx = (keys.size() - 1) + var final_key = (keys[final_key_idx]) + + keys.remove_at(final_key_idx) + + for key in keys: + if !dict.has(key): + dict[key] = { } + + dict = dict[key] + + # + # Handle non-patch (a.k.a. put) mode and then update the destination value. + # + var new_type = typeof(payload) + + if !patch: + dict.erase(final_key) + + if new_type == TYPE_NIL: + dict.erase(final_key) + elif new_type == TYPE_DICTIONARY: + if !dict.has(final_key): + dict[final_key] = { } + + _update_dictionary(dict[final_key], payload) + else: + dict[final_key] = payload + + if debug: + print("...Data store updated (%s)." % _data) + +# +# Helper method to "blit" changes in an update dictionary payload onto an original dictionary. +# Parameters are directly changed via reference. +# +func _update_dictionary(original_dict: Dictionary, update_payload: Dictionary) -> void: + for key in update_payload.keys(): + var val_type = typeof(update_payload[key]) + + if val_type == TYPE_NIL: + original_dict.erase(key) + elif val_type == TYPE_DICTIONARY: + if !original_dict.has(key): + original_dict[key] = { } + + _update_dictionary(original_dict[key], update_payload[key]) + else: + original_dict[key] = update_payload[key] diff --git a/addons/godot-firebase/database/database_store.gd.uid b/addons/godot-firebase/database/database_store.gd.uid new file mode 100644 index 0000000..ea1f68c --- /dev/null +++ b/addons/godot-firebase/database/database_store.gd.uid @@ -0,0 +1 @@ +uid://bo3i7q8s2bfmq diff --git a/addons/godot-firebase/database/firebase_database_reference.tscn b/addons/godot-firebase/database/firebase_database_reference.tscn new file mode 100644 index 0000000..27abf89 --- /dev/null +++ b/addons/godot-firebase/database/firebase_database_reference.tscn @@ -0,0 +1,17 @@ +[gd_scene load_steps=5 format=3 uid="uid://btltp52tywbe4"] + +[ext_resource type="Script" path="res://addons/godot-firebase/database/reference.gd" id="1_l3oy5"] +[ext_resource type="PackedScene" uid="uid://ctb4l7plg8kqg" path="res://addons/godot-firebase/queues/queueable_http_request.tscn" id="2_0qpk7"] +[ext_resource type="Script" path="res://addons/http-sse-client/HTTPSSEClient.gd" id="2_4l0io"] +[ext_resource type="Script" path="res://addons/godot-firebase/database/database_store.gd" id="3_c3r2w"] + +[node name="FirebaseDatabaseReference" type="Node"] +script = ExtResource("1_l3oy5") + +[node name="Pusher" parent="." instance=ExtResource("2_0qpk7")] + +[node name="Listener" type="Node" parent="."] +script = ExtResource("2_4l0io") + +[node name="DataStore" type="Node" parent="."] +script = ExtResource("3_c3r2w") diff --git a/addons/godot-firebase/database/firebase_once_database_reference.tscn b/addons/godot-firebase/database/firebase_once_database_reference.tscn new file mode 100644 index 0000000..c1e2913 --- /dev/null +++ b/addons/godot-firebase/database/firebase_once_database_reference.tscn @@ -0,0 +1,16 @@ +[gd_scene load_steps=3 format=3 uid="uid://d1u1bxp2fd60e"] + +[ext_resource type="Script" path="res://addons/godot-firebase/database/once_reference.gd" id="1_hq5s2"] +[ext_resource type="PackedScene" uid="uid://ctb4l7plg8kqg" path="res://addons/godot-firebase/queues/queueable_http_request.tscn" id="2_t2f32"] + +[node name="FirebaseOnceDatabaseReference" type="Node"] +script = ExtResource("1_hq5s2") + +[node name="Pusher" parent="." instance=ExtResource("2_t2f32")] +accept_gzip = false + +[node name="Oncer" parent="." instance=ExtResource("2_t2f32")] +accept_gzip = false + +[connection signal="queue_request_completed" from="Pusher" to="." method="on_push_request_complete"] +[connection signal="queue_request_completed" from="Oncer" to="." method="on_get_request_complete"] diff --git a/addons/godot-firebase/database/once_reference.gd b/addons/godot-firebase/database/once_reference.gd new file mode 100644 index 0000000..ac816e4 --- /dev/null +++ b/addons/godot-firebase/database/once_reference.gd @@ -0,0 +1,124 @@ +class_name FirebaseOnceDatabaseReference +extends Node + + +## @meta-authors BackAt50Ft +## @meta-version 1.0 +## A once off reference to a location in the Realtime Database. +## Documentation TODO. + +signal once_successful(dataSnapshot) +signal once_failed() + +signal push_successful() +signal push_failed() + +const ORDER_BY : String = "orderBy" +const LIMIT_TO_FIRST : String = "limitToFirst" +const LIMIT_TO_LAST : String = "limitToLast" +const START_AT : String = "startAt" +const END_AT : String = "endAt" +const EQUAL_TO : String = "equalTo" + +@onready var _oncer = $Oncer +@onready var _pusher = $Pusher + +var _auth : Dictionary +var _config : Dictionary +var _filter_query : Dictionary +var _db_path : String + +const _separator : String = "/" +const _json_list_tag : String = ".json" +const _query_tag : String = "?" +const _auth_tag : String = "auth=" + +const _auth_variable_begin : String = "[" +const _auth_variable_end : String = "]" +const _filter_tag : String = "&" +const _escaped_quote : String = '"' +const _equal_tag : String = "=" +const _key_filter_tag : String = "$key" + +var _headers : PackedStringArray = [] + +func set_db_path(path : String, filter_query_dict : Dictionary) -> void: + _db_path = path + _filter_query = filter_query_dict + +func set_auth_and_config(auth_ref : Dictionary, config_ref : Dictionary) -> void: + _auth = auth_ref + _config = config_ref + +# +# Gets a data snapshot once at the position passed in +# +func once(reference : String) -> void: + var ref_pos = _get_list_url() + _db_path + _separator + reference + _get_remaining_path() + _oncer.request(ref_pos, _headers, HTTPClient.METHOD_GET, "") + +func _get_remaining_path(is_push : bool = true) -> String: + var remaining_path = "" + if _filter_query_empty() or is_push: + remaining_path = _json_list_tag + _query_tag + _auth_tag + Firebase.Auth.auth.idtoken + else: + remaining_path = _json_list_tag + _query_tag + _get_filter() + _filter_tag + _auth_tag + Firebase.Auth.auth.idtoken + + if Firebase.emulating: + remaining_path += "&ns="+_config.projectId+"-default-rtdb" + + return remaining_path + +func _get_list_url(with_port:bool = true) -> String: + var url = Firebase.Database._base_url.trim_suffix(_separator) + if with_port and Firebase.emulating: + url += ":" + _config.emulators.ports.realtimeDatabase + return url + _separator + + +func _get_filter(): + if _filter_query_empty(): + return "" + + var filter = "" + + if _filter_query.has(ORDER_BY): + filter += ORDER_BY + _equal_tag + _escaped_quote + _filter_query[ORDER_BY] + _escaped_quote + _filter_query.erase(ORDER_BY) + else: + filter += ORDER_BY + _equal_tag + _escaped_quote + _key_filter_tag + _escaped_quote # Presumptuous, but to get it to work at all... + + for key in _filter_query.keys(): + filter += _filter_tag + key + _equal_tag + _filter_query[key] + + return filter + +func _filter_query_empty() -> bool: + return _filter_query == null or _filter_query.is_empty() + +func on_get_request_complete(result : int, response_code : int, headers : PackedStringArray, body : PackedByteArray) -> void: + if response_code == HTTPClient.RESPONSE_OK: + var bod = Utilities.get_json_data(body) + once_successful.emit(bod) + else: + once_failed.emit() + +func on_push_request_complete(result : int, response_code : int, headers : PackedStringArray, body : PackedByteArray) -> void: + if response_code == HTTPClient.RESPONSE_OK: + push_successful.emit() + else: + push_failed.emit() + +func push(data : Dictionary) -> void: + var to_push = JSON.stringify(data) + _pusher.request(_get_list_url() + _db_path + _get_remaining_path(true), _headers, HTTPClient.METHOD_POST, to_push) + +func update(path : String, data : Dictionary) -> void: + path = path.strip_edges(true, true) + + if path == _separator: + path = "" + + var to_update = JSON.stringify(data) + var resolved_path = (_get_list_url() + _db_path + "/" + path + _get_remaining_path()) + _pusher.request(resolved_path, _headers, HTTPClient.METHOD_PATCH, to_update) diff --git a/addons/godot-firebase/database/once_reference.gd.uid b/addons/godot-firebase/database/once_reference.gd.uid new file mode 100644 index 0000000..b685b80 --- /dev/null +++ b/addons/godot-firebase/database/once_reference.gd.uid @@ -0,0 +1 @@ +uid://d0b2x1kc1w1w3 diff --git a/addons/godot-firebase/database/reference.gd b/addons/godot-firebase/database/reference.gd new file mode 100644 index 0000000..0429b92 --- /dev/null +++ b/addons/godot-firebase/database/reference.gd @@ -0,0 +1,176 @@ +## @meta-authors BackAt50Ft +## @meta-version 2.4 +## A reference to a location in the Realtime Database. +## Documentation TODO. +@tool +class_name FirebaseDatabaseReference +extends Node + +signal new_data_update(data) +signal patch_data_update(data) +signal delete_data_update(data) + +signal once_successful(dataSnapshot) +signal once_failed() + +signal push_successful() +signal push_failed() + + +const ORDER_BY : String = "orderBy" +const LIMIT_TO_FIRST : String = "limitToFirst" +const LIMIT_TO_LAST : String = "limitToLast" +const START_AT : String = "startAt" +const END_AT : String = "endAt" +const EQUAL_TO : String = "equalTo" + +@onready var _pusher := $Pusher +@onready var _listener := $Listener +@onready var _store := $DataStore + +var _auth : Dictionary +var _config : Dictionary +var _filter_query : Dictionary +var _db_path : String +var _cached_filter : String +var _can_connect_to_host : bool = false + +const _put_tag : String = "put" +const _patch_tag : String = "patch" +const _delete_tag : String = "delete" +const _separator : String = "/" +const _json_list_tag : String = ".json" +const _query_tag : String = "?" +const _auth_tag : String = "auth=" +const _accept_header : String = "accept: text/event-stream" +const _auth_variable_begin : String = "[" +const _auth_variable_end : String = "]" +const _filter_tag : String = "&" +const _escaped_quote : String = '"' +const _equal_tag : String = "=" +const _key_filter_tag : String = "$key" + +var _headers : PackedStringArray = [] + +func _ready() -> void: +#region Set Listener info + $Listener.new_sse_event.connect(on_new_sse_event) + var base_url = _get_list_url(false).trim_suffix(_separator) + var extended_url = _separator + _db_path + _get_remaining_path(false) + var port = -1 + if Firebase.emulating: + port = int(_config.emulators.ports.realtimeDatabase) + $Listener.connect_to_host(base_url, extended_url, port) +#endregion Set Listener info + +#region Set Pusher info + $Pusher.queue_request_completed.connect(on_push_request_complete) +#endregion Set Pusher info + +func set_db_path(path : String, filter_query_dict : Dictionary) -> void: + _db_path = path + _filter_query = filter_query_dict + +func set_auth_and_config(auth_ref : Dictionary, config_ref : Dictionary) -> void: + _auth = auth_ref + _config = config_ref + +func on_new_sse_event(headers : Dictionary, event : String, data : Dictionary) -> void: + if data: + var command = event + if command and command != "keep-alive": + _route_data(command, data.path, data.data) + if command == _put_tag: + if data.path == _separator and data.data and data.data.keys().size() > 0: + for key in data.data.keys(): + new_data_update.emit(FirebaseResource.new(_separator + key, data.data[key])) + elif data.path != _separator: + new_data_update.emit(FirebaseResource.new(data.path, data.data)) + elif command == _patch_tag: + patch_data_update.emit(FirebaseResource.new(data.path, data.data)) + elif command == _delete_tag: + delete_data_update.emit(FirebaseResource.new(data.path, data.data)) + +func update(path : String, data : Dictionary) -> void: + path = path.strip_edges(true, true) + + if path == _separator: + path = "" + + var to_update = JSON.stringify(data) + + var resolved_path = (_get_list_url() + _db_path + "/" + path + _get_remaining_path()) + _pusher.request(resolved_path, _headers, HTTPClient.METHOD_PATCH, to_update) + +func push(data : Dictionary) -> void: + var to_push = JSON.stringify(data) + _pusher.request(_get_list_url() + _db_path + _get_remaining_path(), _headers, HTTPClient.METHOD_POST, to_push) + +func delete(reference : String) -> void: + _pusher.request(_get_list_url() + _db_path + _separator + reference + _get_remaining_path(), _headers, HTTPClient.METHOD_DELETE, "") + +# +# Returns a deep copy of the current local copy of the data stored at this reference in the Firebase +# Realtime Database. +# +func get_data() -> Dictionary: + if _store == null: + return { } + + return _store.get_data() + +func _get_remaining_path(is_push : bool = true) -> String: + var remaining_path = "" + if _filter_query_empty() or is_push: + remaining_path = _json_list_tag + _query_tag + _auth_tag + Firebase.Auth.auth.idtoken + else: + remaining_path = _json_list_tag + _query_tag + _get_filter() + _filter_tag + _auth_tag + Firebase.Auth.auth.idtoken + + if Firebase.emulating: + remaining_path += "&ns="+_config.projectId+"-default-rtdb" + + return remaining_path + +func _get_list_url(with_port:bool = true) -> String: + var url = Firebase.Database._base_url.trim_suffix(_separator) + if with_port and Firebase.emulating: + url += ":" + _config.emulators.ports.realtimeDatabase + return url + _separator + + +func _get_filter(): + if _filter_query_empty(): + return "" + # At the moment, this means you can't dynamically change your filter; I think it's okay to specify that in the rules. + if _cached_filter != "": + _cached_filter = "" + if _filter_query.has(ORDER_BY): + _cached_filter += ORDER_BY + _equal_tag + _escaped_quote + _filter_query[ORDER_BY] + _escaped_quote + _filter_query.erase(ORDER_BY) + else: + _cached_filter += ORDER_BY + _equal_tag + _escaped_quote + _key_filter_tag + _escaped_quote # Presumptuous, but to get it to work at all... + for key in _filter_query.keys(): + _cached_filter += _filter_tag + key + _equal_tag + _filter_query[key] + + return _cached_filter + +func _filter_query_empty() -> bool: + return _filter_query == null or _filter_query.is_empty() + +# +# Appropriately updates the current local copy of the data stored at this reference in the Firebase +# Realtime Database. +# +func _route_data(command : String, path : String, data) -> void: + if command == _put_tag: + _store.put(path, data) + elif command == _patch_tag: + _store.patch(path, data) + elif command == _delete_tag: + _store.delete(path, data) + +func on_push_request_complete(result : int, response_code : int, headers : PackedStringArray, body : PackedByteArray) -> void: + if response_code == HTTPClient.RESPONSE_OK: + push_successful.emit() + else: + push_failed.emit() diff --git a/addons/godot-firebase/database/reference.gd.uid b/addons/godot-firebase/database/reference.gd.uid new file mode 100644 index 0000000..2438588 --- /dev/null +++ b/addons/godot-firebase/database/reference.gd.uid @@ -0,0 +1 @@ +uid://ofyy1lc3qlfn diff --git a/addons/godot-firebase/database/resource.gd b/addons/godot-firebase/database/resource.gd new file mode 100644 index 0000000..ff8a620 --- /dev/null +++ b/addons/godot-firebase/database/resource.gd @@ -0,0 +1,16 @@ +## @meta-authors SIsilicon, fenix-hub +## @meta-version 2.2 +## A generic resource used by Firebase Database. +@tool +class_name FirebaseResource +extends Resource + +var key : String +var data + +func _init(key : String,data): + self.key = key.lstrip("/") + self.data = data + +func _to_string(): + return "{ key:{key}, data:{data} }".format({key = key, data = data}) diff --git a/addons/godot-firebase/database/resource.gd.uid b/addons/godot-firebase/database/resource.gd.uid new file mode 100644 index 0000000..a2fe9f4 --- /dev/null +++ b/addons/godot-firebase/database/resource.gd.uid @@ -0,0 +1 @@ +uid://cbqame2gc2atr diff --git a/addons/godot-firebase/dynamiclinks/dynamiclinks.gd b/addons/godot-firebase/dynamiclinks/dynamiclinks.gd new file mode 100644 index 0000000..8f7a6c7 --- /dev/null +++ b/addons/godot-firebase/dynamiclinks/dynamiclinks.gd @@ -0,0 +1,109 @@ +## @meta-authors TODO +## @meta-authors TODO +## @meta-version 1.1 +## The dynamic links API for Firebase +## Documentation TODO. +@tool +class_name FirebaseDynamicLinks +extends Node + +signal dynamic_link_generated(link_result) +signal generate_dynamic_link_error(error) + +const _AUTHORIZATION_HEADER : String = "Authorization: Bearer " +const _API_VERSION : String = "v1" + +var request : int = -1 + +var _base_url : String = "" + +var _config : Dictionary = {} + +var _auth : Dictionary +var _request_list_node : HTTPRequest + +var _headers : PackedStringArray = [] + +enum Requests { + NONE = -1, + GENERATE + } + +func _set_config(config_json : Dictionary) -> void: + _config = config_json + _request_list_node = HTTPRequest.new() + Utilities.fix_http_request(_request_list_node) + _request_list_node.request_completed.connect(_on_request_completed) + add_child(_request_list_node) + _check_emulating() + + +func _check_emulating() -> void : + ## Check emulating + if not Firebase.emulating: + _base_url = "https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=%s" + _base_url %= _config.apiKey + else: + var port : String = _config.emulators.ports.dynamicLinks + if port == "": + Firebase._printerr("You are in 'emulated' mode, but the port for Dynamic Links has not been configured.") + else: + _base_url = "http://localhost:{port}/{version}/".format({ version = _API_VERSION, port = port }) + + +var _link_request_body : Dictionary = { + "dynamicLinkInfo": { + "domainUriPrefix": "", + "link": "", + "androidInfo": { + "androidPackageName": "" + }, + "iosInfo": { + "iosBundleId": "" + } + }, + "suffix": { + "option": "" + } +} + +## @args log_link, APN, IBI, is_unguessable +## This function is used to generate a dynamic link using the Firebase REST API +## It will return a JSON with the shortened link +func generate_dynamic_link(long_link : String, APN : String, IBI : String, is_unguessable : bool) -> void: + if not _config.domainUriPrefix or _config.domainUriPrefix == "": + generate_dynamic_link_error.emit("Error: Missing domainUriPrefix in config file. Parameter is required.") + Firebase._printerr("Error: Missing domainUriPrefix in config file. Parameter is required.") + return + + request = Requests.GENERATE + _link_request_body.dynamicLinkInfo.domainUriPrefix = _config.domainUriPrefix + _link_request_body.dynamicLinkInfo.link = long_link + _link_request_body.dynamicLinkInfo.androidInfo.androidPackageName = APN + _link_request_body.dynamicLinkInfo.iosInfo.iosBundleId = IBI + if is_unguessable: + _link_request_body.suffix.option = "UNGUESSABLE" + else: + _link_request_body.suffix.option = "SHORT" + _request_list_node.request(_base_url, _headers, HTTPClient.METHOD_POST, JSON.stringify(_link_request_body)) + +func _on_request_completed(result : int, response_code : int, headers : PackedStringArray, body : PackedByteArray) -> void: + var json = JSON.new() + var json_parse_result = json.parse(body.get_string_from_utf8()) + if json_parse_result == OK: + var result_body = json.data.result # Check this + dynamic_link_generated.emit(result_body.shortLink) + else: + generate_dynamic_link_error.emit(json.get_error_message()) + # This used to return immediately when above, but it should still clear the request, so removing it + + request = Requests.NONE + +func _on_FirebaseAuth_login_succeeded(auth_result : Dictionary) -> void: + _auth = auth_result + +func _on_FirebaseAuth_token_refresh_succeeded(auth_result : Dictionary) -> void: + _auth = auth_result + +func _on_FirebaseAuth_logout() -> void: + _auth = {} diff --git a/addons/godot-firebase/dynamiclinks/dynamiclinks.gd.uid b/addons/godot-firebase/dynamiclinks/dynamiclinks.gd.uid new file mode 100644 index 0000000..38389f0 --- /dev/null +++ b/addons/godot-firebase/dynamiclinks/dynamiclinks.gd.uid @@ -0,0 +1 @@ +uid://dgbj7nixmfyrs diff --git a/addons/godot-firebase/firebase/firebase.gd b/addons/godot-firebase/firebase/firebase.gd new file mode 100644 index 0000000..f2eed9f --- /dev/null +++ b/addons/godot-firebase/firebase/firebase.gd @@ -0,0 +1,144 @@ +## @meta-authors Kyle Szklenski +## @meta-version 2.6 +## The Firebase Godot API. +## This singleton gives you access to your Firebase project and its capabilities. Using this requires you to fill out some Firebase configuration settings. It currently comes with four modules. +## - [code]Auth[/code]: Manages user authentication (logging and out, etc...) +## - [code]Database[/code]: A NonSQL realtime database for managing data in JSON structures. +## - [code]Firestore[/code]: Similar to Database, but stores data in collections and documents, among other things. +## - [code]Storage[/code]: Gives access to Cloud Storage; perfect for storing files like images and other assets. +## - [code]RemoteConfig[/code]: Gives access to Remote Config functionality; allows you to download your app's configuration from Firebase, do A/B testing, and more. +## +## @tutorial https://github.com/GodotNuts/GodotFirebase/wiki +@tool +extends Node + +const _ENVIRONMENT_VARIABLES : String = "firebase/environment_variables" +const _EMULATORS_PORTS : String = "firebase/emulators/ports" +const _AUTH_PROVIDERS : String = "firebase/auth_providers" + +## @type FirebaseAuth +## The Firebase Authentication API. +@onready var Auth := $Auth + +## @type FirebaseFirestore +## The Firebase Firestore API. +@onready var Firestore := $Firestore + +## @type FirebaseDatabase +## The Firebase Realtime Database API. +@onready var Database := $Database + +## @type FirebaseStorage +## The Firebase Storage API. +@onready var Storage := $Storage + +## @type FirebaseDynamicLinks +## The Firebase Dynamic Links API. +@onready var DynamicLinks := $DynamicLinks + +## @type FirebaseFunctions +## The Firebase Cloud Functions API +@onready var Functions := $Functions + +## @type FirebaseRemoteConfig +## The Firebase Remote Config API +@onready var RemoteConfigAPI := $RemoteConfig + +@export var emulating : bool = false + +# Configuration used by all files in this project +# These values can be found in your Firebase Project +# See the README checked Github for how to access +var _config : Dictionary = { + "apiKey": "", + "authDomain": "", + "databaseURL": "", + "projectId": "", + "storageBucket": "", + "messagingSenderId": "", + "appId": "", + "measurementId": "", + "clientId": "", + "clientSecret" : "", + "domainUriPrefix" : "", + "functionsGeoZone" : "", + "cacheLocation":"", + "emulators": { + "ports" : { + "authentication" : "", + "firestore" : "", + "realtimeDatabase" : "", + "functions" : "", + "storage" : "", + "dynamicLinks" : "" + } + }, + "workarounds":{ + "database_connection_closed_issue": false, # fixes https://github.com/firebase/firebase-tools/issues/3329 + }, + "auth_providers": { + "facebook_id":"", + "facebook_secret":"", + "github_id":"", + "github_secret":"", + "twitter_id":"", + "twitter_secret":"" + } +} + +func _ready() -> void: + _load_config() + + +func set_emulated(emulating : bool = true) -> void: + self.emulating = emulating + _check_emulating() + +func _check_emulating() -> void: + if emulating: + print("[Firebase] You are now in 'emulated' mode: the services you are using will try to connect to your local emulators, if available.") + for module in get_children(): + if module.has_method("_check_emulating"): + module._check_emulating() + +func _load_config() -> void: + if not (_config.apiKey != "" and _config.authDomain != ""): + var env = ConfigFile.new() + var err = env.load("res://addons/godot-firebase/.env") + if err == OK: + for key in _config.keys(): + var config_value = _config[key] + if key == "emulators" and config_value.has("ports"): + for port in config_value["ports"].keys(): + config_value["ports"][port] = env.get_value(_EMULATORS_PORTS, port, "") + if key == "auth_providers": + for provider in config_value.keys(): + config_value[provider] = env.get_value(_AUTH_PROVIDERS, provider, "") + else: + var value : String = env.get_value(_ENVIRONMENT_VARIABLES, key, "") + if value == "": + _print("The value for `%s` is not configured. If you are not planning to use it, ignore this message." % key) + else: + _config[key] = value + else: + _printerr("Unable to read .env file at path 'res://addons/godot-firebase/.env'") + + _setup_modules() + +func _setup_modules() -> void: + for module in get_children(): + module._set_config(_config) + if not module.has_method("_on_FirebaseAuth_login_succeeded"): + continue + Auth.login_succeeded.connect(module._on_FirebaseAuth_login_succeeded) + Auth.signup_succeeded.connect(module._on_FirebaseAuth_login_succeeded) + Auth.token_refresh_succeeded.connect(module._on_FirebaseAuth_token_refresh_succeeded) + Auth.logged_out.connect(module._on_FirebaseAuth_logout) + +# ------------- + +func _printerr(error : String) -> void: + printerr("[Firebase Error] >> " + error) + +func _print(msg : String) -> void: + print("[Firebase] >> " + str(msg)) diff --git a/addons/godot-firebase/firebase/firebase.gd.uid b/addons/godot-firebase/firebase/firebase.gd.uid new file mode 100644 index 0000000..b94b724 --- /dev/null +++ b/addons/godot-firebase/firebase/firebase.gd.uid @@ -0,0 +1 @@ +uid://c0vsvs7ol6n0x diff --git a/addons/godot-firebase/firebase/firebase.tscn b/addons/godot-firebase/firebase/firebase.tscn new file mode 100644 index 0000000..31f5b56 --- /dev/null +++ b/addons/godot-firebase/firebase/firebase.tscn @@ -0,0 +1,36 @@ +[gd_scene load_steps=9 format=3 uid="uid://cvb26atjckwlq"] + +[ext_resource type="Script" path="res://addons/godot-firebase/database/database.gd" id="1"] +[ext_resource type="Script" path="res://addons/godot-firebase/firestore/firestore.gd" id="2"] +[ext_resource type="Script" path="res://addons/godot-firebase/firebase/firebase.gd" id="3"] +[ext_resource type="Script" path="res://addons/godot-firebase/auth/auth.gd" id="4"] +[ext_resource type="Script" path="res://addons/godot-firebase/storage/storage.gd" id="5"] +[ext_resource type="Script" path="res://addons/godot-firebase/dynamiclinks/dynamiclinks.gd" id="6"] +[ext_resource type="Script" path="res://addons/godot-firebase/functions/functions.gd" id="7"] +[ext_resource type="PackedScene" uid="uid://5xa6ulbllkjk" path="res://addons/godot-firebase/remote_config/firebase_remote_config.tscn" id="8_mvdf4"] + +[node name="Firebase" type="Node"] +script = ExtResource("3") + +[node name="Auth" type="HTTPRequest" parent="."] +max_redirects = 12 +timeout = 10.0 +script = ExtResource("4") + +[node name="Firestore" type="Node" parent="."] +script = ExtResource("2") + +[node name="Database" type="Node" parent="."] +script = ExtResource("1") + +[node name="Storage" type="Node" parent="."] +script = ExtResource("5") + +[node name="DynamicLinks" type="Node" parent="."] +script = ExtResource("6") + +[node name="Functions" type="Node" parent="."] +script = ExtResource("7") + +[node name="RemoteConfig" parent="." instance=ExtResource("8_mvdf4")] +accept_gzip = false diff --git a/addons/godot-firebase/firestore/field_transform.gd b/addons/godot-firebase/firestore/field_transform.gd new file mode 100644 index 0000000..b69395b --- /dev/null +++ b/addons/godot-firebase/firestore/field_transform.gd @@ -0,0 +1,22 @@ +extends FirestoreTransform +class_name FieldTransform + +enum TransformType { SetToServerValue, Maximum, Minimum, Increment, AppendMissingElements, RemoveAllFromArray } + +const transtype_string_map = { + TransformType.SetToServerValue : "setToServerValue", + TransformType.Increment : "increment", + TransformType.Maximum : "maximum", + TransformType.Minimum : "minimum", + TransformType.AppendMissingElements : "appendMissingElements", + TransformType.RemoveAllFromArray : "removeAllFromArray" +} + +var document_exists : bool +var document_name : String +var field_path : String +var transform_type : TransformType +var value : Variant + +func get_transform_type() -> String: + return transtype_string_map[transform_type] diff --git a/addons/godot-firebase/firestore/field_transform.gd.uid b/addons/godot-firebase/firestore/field_transform.gd.uid new file mode 100644 index 0000000..413766c --- /dev/null +++ b/addons/godot-firebase/firestore/field_transform.gd.uid @@ -0,0 +1 @@ +uid://dw7p8101sxern diff --git a/addons/godot-firebase/firestore/field_transform_array.gd b/addons/godot-firebase/firestore/field_transform_array.gd new file mode 100644 index 0000000..72552e9 --- /dev/null +++ b/addons/godot-firebase/firestore/field_transform_array.gd @@ -0,0 +1,35 @@ +class_name FieldTransformArray +extends RefCounted + +var transforms = [] + +var _extended_url +var _collection_name +const _separator = "/" + +func set_config(config : Dictionary): + _extended_url = config.extended_url + _collection_name = config.collection_name + +func push_back(transform : FieldTransform) -> void: + transforms.push_back(transform) + +func serialize() -> Dictionary: + var body = {} + var writes_array = [] + for transform in transforms: + writes_array.push_back({ + "currentDocument": { "exists" : transform.document_exists }, + "transform" : { + "document": _extended_url + _collection_name + _separator + transform.document_name, + "fieldTransforms": [ + { + "fieldPath": transform.field_path, + transform.get_transform_type(): transform.value + }] + } + }) + + body = { "writes": writes_array } + + return body diff --git a/addons/godot-firebase/firestore/field_transform_array.gd.uid b/addons/godot-firebase/firestore/field_transform_array.gd.uid new file mode 100644 index 0000000..157d8be --- /dev/null +++ b/addons/godot-firebase/firestore/field_transform_array.gd.uid @@ -0,0 +1 @@ +uid://bopmc0mi55dvu diff --git a/addons/godot-firebase/firestore/field_transforms/decrement_transform.gd b/addons/godot-firebase/firestore/field_transforms/decrement_transform.gd new file mode 100644 index 0000000..ed7f4b7 --- /dev/null +++ b/addons/godot-firebase/firestore/field_transforms/decrement_transform.gd @@ -0,0 +1,19 @@ +class_name DecrementTransform +extends FieldTransform + +func _init(doc_name : String, doc_must_exist : bool, path_to_field : String, by_this_much : Variant) -> void: + document_name = doc_name + document_exists = doc_must_exist + field_path = path_to_field + + transform_type = FieldTransform.TransformType.Increment + + var value_type = typeof(by_this_much) + if value_type == TYPE_INT: + self.value = { + "integerValue": -by_this_much + } + elif value_type == TYPE_FLOAT: + self.value = { + "doubleValue": -by_this_much + } diff --git a/addons/godot-firebase/firestore/field_transforms/decrement_transform.gd.uid b/addons/godot-firebase/firestore/field_transforms/decrement_transform.gd.uid new file mode 100644 index 0000000..dc69e12 --- /dev/null +++ b/addons/godot-firebase/firestore/field_transforms/decrement_transform.gd.uid @@ -0,0 +1 @@ +uid://bit26sxq4daw7 diff --git a/addons/godot-firebase/firestore/field_transforms/increment_transform.gd b/addons/godot-firebase/firestore/field_transforms/increment_transform.gd new file mode 100644 index 0000000..5c7a38c --- /dev/null +++ b/addons/godot-firebase/firestore/field_transforms/increment_transform.gd @@ -0,0 +1,19 @@ +class_name IncrementTransform +extends FieldTransform + +func _init(doc_name : String, doc_must_exist : bool, path_to_field : String, by_this_much : Variant) -> void: + document_name = doc_name + document_exists = doc_must_exist + field_path = path_to_field + + transform_type = FieldTransform.TransformType.Increment + + var value_type = typeof(by_this_much) + if value_type == TYPE_INT: + self.value = { + "integerValue": by_this_much + } + elif value_type == TYPE_FLOAT: + self.value = { + "doubleValue": by_this_much + } diff --git a/addons/godot-firebase/firestore/field_transforms/increment_transform.gd.uid b/addons/godot-firebase/firestore/field_transforms/increment_transform.gd.uid new file mode 100644 index 0000000..77f15e1 --- /dev/null +++ b/addons/godot-firebase/firestore/field_transforms/increment_transform.gd.uid @@ -0,0 +1 @@ +uid://c5gx3a3dsmwop diff --git a/addons/godot-firebase/firestore/field_transforms/max_transform.gd b/addons/godot-firebase/firestore/field_transforms/max_transform.gd new file mode 100644 index 0000000..a10c87e --- /dev/null +++ b/addons/godot-firebase/firestore/field_transforms/max_transform.gd @@ -0,0 +1,19 @@ +class_name MaxTransform +extends FieldTransform + +func _init(doc_name : String, doc_must_exist : bool, path_to_field : String, value : Variant) -> void: + document_name = doc_name + document_exists = doc_must_exist + field_path = path_to_field + + transform_type = FieldTransform.TransformType.Maximum + + var value_type = typeof(value) + if value_type == TYPE_INT: + self.value = { + "integerValue": value + } + elif value_type == TYPE_FLOAT: + self.value = { + "doubleValue": value + } diff --git a/addons/godot-firebase/firestore/field_transforms/max_transform.gd.uid b/addons/godot-firebase/firestore/field_transforms/max_transform.gd.uid new file mode 100644 index 0000000..d52eefb --- /dev/null +++ b/addons/godot-firebase/firestore/field_transforms/max_transform.gd.uid @@ -0,0 +1 @@ +uid://xf5c8b0lrjpl diff --git a/addons/godot-firebase/firestore/field_transforms/min_transform.gd b/addons/godot-firebase/firestore/field_transforms/min_transform.gd new file mode 100644 index 0000000..82fd8e4 --- /dev/null +++ b/addons/godot-firebase/firestore/field_transforms/min_transform.gd @@ -0,0 +1,19 @@ +class_name MinTransform +extends FieldTransform + +func _init(doc_name : String, doc_must_exist : bool, path_to_field : String, value : Variant) -> void: + document_name = doc_name + document_exists = doc_must_exist + field_path = path_to_field + + transform_type = FieldTransform.TransformType.Minimum + + var value_type = typeof(value) + if value_type == TYPE_INT: + self.value = { + "integerValue": value + } + elif value_type == TYPE_FLOAT: + self.value = { + "doubleValue": value + } diff --git a/addons/godot-firebase/firestore/field_transforms/min_transform.gd.uid b/addons/godot-firebase/firestore/field_transforms/min_transform.gd.uid new file mode 100644 index 0000000..a5b5245 --- /dev/null +++ b/addons/godot-firebase/firestore/field_transforms/min_transform.gd.uid @@ -0,0 +1 @@ +uid://cei7mxm5uqrkc diff --git a/addons/godot-firebase/firestore/field_transforms/server_timestamp_transform.gd b/addons/godot-firebase/firestore/field_transforms/server_timestamp_transform.gd new file mode 100644 index 0000000..7c7c380 --- /dev/null +++ b/addons/godot-firebase/firestore/field_transforms/server_timestamp_transform.gd @@ -0,0 +1,10 @@ +class_name ServerTimestampTransform +extends FieldTransform + +func _init(doc_name : String, doc_must_exist : bool, path_to_field : String) -> void: + document_name = doc_name + document_exists = doc_must_exist + field_path = path_to_field + + transform_type = FieldTransform.TransformType.SetToServerValue + value = "REQUEST_TIME" diff --git a/addons/godot-firebase/firestore/field_transforms/server_timestamp_transform.gd.uid b/addons/godot-firebase/firestore/field_transforms/server_timestamp_transform.gd.uid new file mode 100644 index 0000000..9a30687 --- /dev/null +++ b/addons/godot-firebase/firestore/field_transforms/server_timestamp_transform.gd.uid @@ -0,0 +1 @@ +uid://cqkqdex0s16id diff --git a/addons/godot-firebase/firestore/firestore.gd b/addons/godot-firebase/firestore/firestore.gd new file mode 100644 index 0000000..4266f36 --- /dev/null +++ b/addons/godot-firebase/firestore/firestore.gd @@ -0,0 +1,243 @@ +## @meta-authors Nicolò 'fenix' Santilio, +## @meta-version 2.5 +## +## Referenced by [code]Firebase.Firestore[/code]. Represents the Firestore module. +## Cloud Firestore is a flexible, scalable database for mobile, web, and server development from Firebase and Google Cloud. +## Like Firebase Realtime Database, it keeps your data in sync across client apps through realtime listeners and offers offline support for mobile and web so you can build responsive apps that work regardless of network latency or Internet connectivity. Cloud Firestore also offers seamless integration with other Firebase and Google Cloud products, including Cloud Functions. +## +## Following Cloud Firestore's NoSQL data model, you store data in [b]documents[/b] that contain fields mapping to values. These documents are stored in [b]collections[/b], which are containers for your documents that you can use to organize your data and build queries. +## Documents support many different data types, from simple strings and numbers, to complex, nested objects. You can also create subcollections within documents and build hierarchical data structures that scale as your database grows. +## The Cloud Firestore data model supports whatever data structure works best for your app. +## +## (source: [url=https://firebase.google.com/docs/firestore]Firestore[/url]) +## +## @tutorial https://github.com/GodotNuts/GodotFirebase/wiki/Firestore +@tool +class_name FirebaseFirestore +extends Node + +const _API_VERSION : String = "v1" + +## Emitted when a [code]list()[/code] or [code]query()[/code] request is [b]not[/b] successfully completed. +signal error(code, status, message) + +enum Requests { + NONE = -1, ## Firestore is not processing any request. + LIST, ## Firestore is processing a [code]list()[/code] request checked a collection. + QUERY ## Firestore is processing a [code]query()[/code] request checked a collection. +} + +# TODO: Implement cache size limit +const CACHE_SIZE_UNLIMITED = -1 + +const _CACHE_EXTENSION : String = ".fscache" +const _CACHE_RECORD_FILE : String = "RmlyZXN0b3JlIGNhY2hlLXJlY29yZHMu.fscache" + +const _AUTHORIZATION_HEADER : String = "Authorization: Bearer " + +const _MAX_POOLED_REQUEST_AGE = 30 + +## The code indicating the request Firestore is processing. +## See @[enum FirebaseFirestore.Requests] to get a full list of codes identifiers. +## @enum Requests +var request: int = -1 + +## A Dictionary containing all authentication fields for the current logged user. +## @type Dictionary +var auth: Dictionary + +var _config: Dictionary = {} +var _cache_loc: String +var _encrypt_key := "5vg76n90345f7w390346" if Utilities.is_web() else OS.get_unique_id() + + +var _base_url: String = "" +var _extended_url: String = "projects/[PROJECT_ID]/databases/(default)/documents/" +var _query_suffix: String = ":runQuery" +var _agg_query_suffix: String = ":runAggregationQuery" + +#var _connect_check_node : HTTPRequest + +var _request_list_node: HTTPRequest +var _requests_queue: Array = [] +var _current_query: FirestoreQuery + +## Returns a reference collection by its [i]path[/i]. +## +## The returned object will be of [code]FirestoreCollection[/code] type. +## If saved into a variable, it can be used to issue requests checked the collection itself. +## @args path +## @return FirestoreCollection +func collection(path : String) -> FirestoreCollection: + for coll in get_children(): + if coll is FirestoreCollection: + if coll.collection_name == path: + return coll + + var coll : FirestoreCollection = FirestoreCollection.new() + coll._extended_url = _extended_url + coll._base_url = _base_url + coll._config = _config + coll.auth = auth + coll.collection_name = path + add_child(coll) + return coll + + +## Issue a query checked your Firestore database. +## +## [b]Note:[/b] a [code]FirestoreQuery[/code] object needs to be created to issue the query. +## When awaited, this function returns the resulting array from the query. +## +## ex. +## [code]var query_results = await Firebase.Firestore.query(FirestoreQuery.new())[/code] +## +## [b]Warning:[/b] It currently does not work offline! +## +## @args query +## @arg-types FirestoreQuery +## @return Array[FirestoreDocument] +func query(query : FirestoreQuery) -> Array: + if query.aggregations.size() > 0: + Firebase._printerr("Aggregation query sent with normal query call: " + str(query)) + return [] + + var task : FirestoreTask = FirestoreTask.new() + task.action = FirestoreTask.Task.TASK_QUERY + var body: Dictionary = { structuredQuery = query.query } + var url: String = _base_url + _extended_url + query.sub_collection_path + _query_suffix + + task.data = query + task._fields = JSON.stringify(body) + task._url = url + _pooled_request(task) + return await _handle_task_finished(task) + +## Issue an aggregation query (sum, average, count) against your Firestore database; +## cheaper than a normal query and counting (for instance) values directly. +## +## [b]Note:[/b] a [code]FirestoreQuery[/code] object needs to be created to issue the query. +## When awaited, this function returns the result from the aggregation query. +## +## ex. +## [code]var query_results = await Firebase.Firestore.query(FirestoreQuery.new())[/code] +## +## [b]Warning:[/b] It currently does not work offline! +## +## @args query +## @arg-types FirestoreQuery +## @return Variant representing the array results of the aggregation query +func aggregation_query(query : FirestoreQuery) -> Variant: + if query.aggregations.size() == 0: + Firebase._printerr("Aggregation query sent with no aggregation values: " + str(query)) + return 0 + + var task : FirestoreTask = FirestoreTask.new() + task.action = FirestoreTask.Task.TASK_AGG_QUERY + + var body: Dictionary = { structuredAggregationQuery = { structuredQuery = query.query, aggregations = query.aggregations } } + var url: String = _base_url + _extended_url + _agg_query_suffix + + task.data = query + task._fields = JSON.stringify(body) + task._url = url + _pooled_request(task) + var result = await _handle_task_finished(task) + return result + +## Request a list of contents (documents and/or collections) inside a collection, specified by its [i]id[/i]. This method will return an Array[FirestoreDocument] +## @args collection_id, page_size, page_token, order_by +## @arg-types String, int, String, String +## @arg-defaults , 0, "", "" +## @return Array[FirestoreDocument] +func list(path : String = "", page_size : int = 0, page_token : String = "", order_by : String = "") -> Array: + var task : FirestoreTask = FirestoreTask.new() + task.action = FirestoreTask.Task.TASK_LIST + var url : String = _base_url + _extended_url + path + if page_size != 0: + url+="?pageSize="+str(page_size) + if page_token != "": + url+="&pageToken="+page_token + if order_by != "": + url+="&orderBy="+order_by + + task.data = [path, page_size, page_token, order_by] + task._url = url + _pooled_request(task) + + return await _handle_task_finished(task) + + +func _set_config(config_json : Dictionary) -> void: + _config = config_json + _cache_loc = _config["cacheLocation"] + _extended_url = _extended_url.replace("[PROJECT_ID]", _config.projectId) + + # Since caching is causing a lot of issues, I'm removing this check for now. We will revisit this in the future, once we have some time to investigate why the cache is being corrupted. + + _check_emulating() + +func _check_emulating() -> void : + ## Check emulating + if not Firebase.emulating: + _base_url = "https://firestore.googleapis.com/{version}/".format({ version = _API_VERSION }) + else: + var port : String = _config.emulators.ports.firestore + if port == "": + Firebase._printerr("You are in 'emulated' mode, but the port for Firestore has not been configured.") + else: + _base_url = "http://localhost:{port}/{version}/".format({ version = _API_VERSION, port = port }) + +func _pooled_request(task : FirestoreTask) -> void: + if (auth == null or auth.is_empty()) and not Firebase.emulating: + Firebase._print("Unauthenticated request issued...") + Firebase.Auth.login_anonymous() + var result : Array = await Firebase.Auth.auth_request + if result[0] != 1: + _check_auth_error(result[0], result[1]) + Firebase._print("Client connected as Anonymous") + + if not Firebase.emulating: + task._headers = PackedStringArray([_AUTHORIZATION_HEADER + auth.idtoken]) + + var http_request = HTTPRequest.new() + http_request.timeout = 5 + Utilities.fix_http_request(http_request) + add_child(http_request) + http_request.request_completed.connect( + func(result, response_code, headers, body): + task._on_request_completed(result, response_code, headers, body) + http_request.queue_free() + ) + + http_request.request(task._url, task._headers, task._method, task._fields) + +func _on_FirebaseAuth_login_succeeded(auth_result : Dictionary) -> void: + auth = auth_result + for coll in get_children(): + if coll is FirestoreCollection: + coll.auth = auth + +func _on_FirebaseAuth_token_refresh_succeeded(auth_result : Dictionary) -> void: + auth = auth_result + for coll in get_children(): + if coll is FirestoreCollection: + coll.auth = auth + +func _on_FirebaseAuth_logout() -> void: + auth = {} + +func _check_auth_error(code : int, message : String) -> void: + var err : String + match code: + 400: err = "Please enable the Anonymous Sign-in method, or Authenticate the Client before issuing a request" + Firebase._printerr(err) + Firebase._printerr(message) + +func _handle_task_finished(task : FirestoreTask): + await task.task_finished + + if task.error.keys().size() > 0: + error.emit(task.error) + + return task.data diff --git a/addons/godot-firebase/firestore/firestore.gd.uid b/addons/godot-firebase/firestore/firestore.gd.uid new file mode 100644 index 0000000..383e146 --- /dev/null +++ b/addons/godot-firebase/firestore/firestore.gd.uid @@ -0,0 +1 @@ +uid://c2n2dkjpnwcsd diff --git a/addons/godot-firebase/firestore/firestore_collection.gd b/addons/godot-firebase/firestore/firestore_collection.gd new file mode 100644 index 0000000..4883f87 --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_collection.gd @@ -0,0 +1,178 @@ +## @meta-authors TODO +## @meta-authors TODO +## @meta-version 2.3 +## A reference to a Firestore Collection. +## Documentation TODO. +@tool +class_name FirestoreCollection +extends Node + +signal error(error_result) + +const _AUTHORIZATION_HEADER : String = "Authorization: Bearer " + +const _separator : String = "/" +const _query_tag : String = "?" +const _documentId_tag : String = "documentId=" + +var auth : Dictionary +var collection_name : String + +var _base_url : String +var _extended_url : String +var _config : Dictionary + +var _documents := {} + +# ----------------------- Requests + +## @args document_id +## @return FirestoreTask +## used to GET a document from the collection, specify @document_id +func get_doc(document_id : String, from_cache : bool = false, is_listener : bool = false) -> FirestoreDocument: + if from_cache: + # for now, just return the child directly; in the future, make it smarter so there's a default, if long, polling time for this + for child in get_children(): + if child.doc_name == document_id: + return child + + var task : FirestoreTask = FirestoreTask.new() + task.action = FirestoreTask.Task.TASK_GET + task.data = collection_name + "/" + document_id + var url = _get_request_url() + _separator + document_id.replace(" ", "%20") + + _process_request(task, document_id, url) + var result = await Firebase.Firestore._handle_task_finished(task) + if result != null: + for child in get_children(): + if child.doc_name == document_id: + child.replace(result, true) + result = child + break + else: + print("get_document returned null for %s %s" % [collection_name, document_id]) + + return result + +## @args document_id, fields +## @arg-defaults , {} +## @return FirestoreDocument +## used to ADD a new document to the collection, specify @documentID and @data +func add(document_id : String, data : Dictionary = {}) -> FirestoreDocument: + var task : FirestoreTask = FirestoreTask.new() + task.action = FirestoreTask.Task.TASK_POST + task.data = collection_name + "/" + document_id + var url = _get_request_url() + _query_tag + _documentId_tag + document_id + + _process_request(task, document_id, url, JSON.stringify(Utilities.dict2fields(data))) + var result = await Firebase.Firestore._handle_task_finished(task) + if result != null: + for child in get_children(): + if child.doc_name == document_id: + child.free() # Consider throwing an error for this since it shouldn't already exist + break + + result.collection_name = collection_name + add_child(result, true) + return result + +## @args document +## @return FirestoreDocument +# used to UPDATE a document, specify the document +func update(document : FirestoreDocument) -> FirestoreDocument: + var task : FirestoreTask = FirestoreTask.new() + task.action = FirestoreTask.Task.TASK_PATCH + task.data = collection_name + "/" + document.doc_name + var url = _get_request_url() + _separator + document.doc_name.replace(" ", "%20") + "?" + for key in document.keys(): + url+="updateMask.fieldPaths={key}&".format({key = key}) + + url = url.rstrip("&") + + for key in document.keys(): + if document.get_value(key) == null: + document._erase(key) + + var temp_transforms + if document._transforms != null: + temp_transforms = document._transforms + document._transforms = null + + var body = JSON.stringify({"fields": document.document}) + + _process_request(task, document.doc_name, url, body) + var result = await Firebase.Firestore._handle_task_finished(task) + if result != null: + for child in get_children(): + if child.doc_name == result.doc_name: + child.replace(result, true) + break + + if temp_transforms != null: + result._transforms = temp_transforms + + return result + + +## @args document +## @return Dictionary +# Used to commit changes from transforms, specify the document with the transforms +func commit(document : FirestoreDocument) -> Dictionary: + var task : FirestoreTask = FirestoreTask.new() + task.action = FirestoreTask.Task.TASK_COMMIT + var url = get_database_url("commit") + + document._transforms.set_config( + { + "extended_url": _extended_url, + "collection_name": collection_name + } + ) # Only place we can set this is here, oofness + + var body = document._transforms.serialize() + document.clear_field_transforms() + _process_request(task, document.doc_name, url, JSON.stringify(body)) + + return await Firebase.Firestore._handle_task_finished(task) # Not implementing the follow-up get here as user may have a listener that's already listening for changes, but user should call get if they don't + +## @args document_id +## @return FirestoreTask +# used to DELETE a document, specify the document +func delete(document : FirestoreDocument) -> bool: + var doc_name = document.doc_name + var task : FirestoreTask = FirestoreTask.new() + task.action = FirestoreTask.Task.TASK_DELETE + task.data = document.collection_name + "/" + doc_name + var url = _get_request_url() + _separator + doc_name.replace(" ", "%20") + _process_request(task, doc_name, url) + var result = await Firebase.Firestore._handle_task_finished(task) + + # Clean up the cache + if result: + for node in get_children(): + if node.doc_name == doc_name: + node.free() # Should be only one + break + + return result + +func _get_request_url() -> String: + return _base_url + _extended_url + collection_name + +func _process_request(task : FirestoreTask, document_id : String, url : String, fields := "") -> void: + if auth == null or auth.is_empty(): + Firebase._print("Unauthenticated request issued...") + Firebase.Auth.login_anonymous() + var result : Array = await Firebase.Auth.auth_request + if result[0] != 1: + Firebase.Firestore._check_auth_error(result[0], result[1]) + return + Firebase._print("Client authenticated as Anonymous User.") + + task._url = url + task._fields = fields + task._headers = PackedStringArray([_AUTHORIZATION_HEADER + auth.idtoken]) + Firebase.Firestore._pooled_request(task) + +func get_database_url(append) -> String: + return _base_url + _extended_url.rstrip("/") + ":" + append diff --git a/addons/godot-firebase/firestore/firestore_collection.gd.uid b/addons/godot-firebase/firestore/firestore_collection.gd.uid new file mode 100644 index 0000000..7055cfc --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_collection.gd.uid @@ -0,0 +1 @@ +uid://bnvqqpj5cima0 diff --git a/addons/godot-firebase/firestore/firestore_document.gd b/addons/godot-firebase/firestore/firestore_document.gd new file mode 100644 index 0000000..86d2459 --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_document.gd @@ -0,0 +1,185 @@ +## @meta-authors Kyle Szklenski +## @meta-version 2.2 +## A reference to a Firestore Document. +## Documentation TODO. +@tool +class_name FirestoreDocument +extends Node + +# A FirestoreDocument objects that holds all important values for a Firestore Document, +# @doc_name = name of the Firestore Document, which is the request PATH +# @doc_fields = fields held by Firestore Document, in APIs format +# created when requested from a `collection().get()` call + +var document : Dictionary # the Document itself +var doc_name : String # only .name +var create_time : String # createTime +var collection_name : String # Name of the collection to which it belongs +var _transforms : FieldTransformArray # The transforms to apply +signal changed(changes) + +func _init(doc : Dictionary = {}): + _transforms = FieldTransformArray.new() + + if doc.has("fields"): + document = doc.fields + if doc.has("name"): + doc_name = doc.name + if doc_name.count("/") > 2: + doc_name = (doc_name.split("/") as Array).back() + if doc.has("createTime"): + self.create_time = doc.createTime + +func replace(with : FirestoreDocument, is_listener := false) -> void: + var current = document.duplicate() + document = with.document + + var changes = { + "added": [], "removed": [], "updated": [], "is_listener": is_listener + } + + for key in current.keys(): + if not document.has(key): + changes.removed.push_back({ "key" : key }) + else: + var new_value = Utilities.from_firebase_type(document[key]) + var old_value = Utilities.from_firebase_type(current[key]) + if typeof(new_value) != typeof(old_value) or new_value != old_value: + if old_value == null: + changes.removed.push_back({ "key" : key }) # ?? + else: + changes.updated.push_back({ "key" : key, "old": old_value, "new" : new_value }) + + for key in document.keys(): + if not current.has(key): + changes.added.push_back({ "key" : key, "new" : Utilities.from_firebase_type(document[key]) }) + + if not (changes.added.is_empty() and changes.removed.is_empty() and changes.updated.is_empty()): + changed.emit(changes) + +func new_document(base_document: Dictionary) -> void: + var current = document.duplicate() + document = {} + for key in base_document.keys(): + document[key] = Utilities.to_firebase_type(key) + + var changes = { + "added": [], "removed": [], "updated": [], "is_listener": false + } + + for key in current.keys(): + if not document.has(key): + changes.removed.push_back({ "key" : key }) + else: + var new_value = Utilities.from_firebase_type(document[key]) + var old_value = Utilities.from_firebase_type(current[key]) + if typeof(new_value) != typeof(old_value) or new_value != old_value: + if old_value == null: + changes.removed.push_back({ "key" : key }) # ?? + else: + changes.updated.push_back({ "key" : key, "old": old_value, "new" : new_value }) + + for key in document.keys(): + if not current.has(key): + changes.added.push_back({ "key" : key, "new" : Utilities.from_firebase_type(document[key]) }) + + if not (changes.added.is_empty() and changes.removed.is_empty() and changes.updated.is_empty()): + changed.emit(changes) + +func is_null_value(key) -> bool: + return document.has(key) and Utilities.from_firebase_type(document[key]) == null + +# As of right now, we do not track these with track changes; instead, they'll come back when the document updates from the server. +# Until that time, it's expected if you want to track these types of changes that you commit for the transforms and then get the document yourself. +func add_field_transform(transform : FieldTransform) -> void: + _transforms.push_back(transform) + +func remove_field_transform(transform : FieldTransform) -> void: + _transforms.erase(transform) + +func clear_field_transforms() -> void: + _transforms.transforms.clear() + +func remove_field(field_path : String) -> void: + if document.has(field_path): + document[field_path] = Utilities.to_firebase_type(null) + + var changes = { + "added": [], "removed": [], "updated": [], "is_listener": false + } + + changes.removed.push_back({ "key" : field_path }) + changed.emit(changes) + +func _erase(field_path : String) -> void: + document.erase(field_path) + +func add_or_update_field(field_path : String, value : Variant) -> void: + var changes = { + "added": [], "removed": [], "updated": [], "is_listener": false + } + + var existing_value = get_value(field_path) + var has_field_path = existing_value != null and not is_null_value(field_path) + + var converted_value = Utilities.to_firebase_type(value) + document[field_path] = converted_value + + if has_field_path: + changes.updated.push_back({ "key" : field_path, "old" : existing_value, "new" : value }) + else: + changes.added.push_back({ "key" : field_path, "new" : value }) + + changed.emit(changes) + +func on_snapshot(when_called : Callable, poll_time : float = 1.0) -> FirestoreListener.FirestoreListenerConnection: + if get_child_count() >= 1: # Only one listener per + assert(false, "Multiple listeners not allowed for the same document yet") + return + + changed.connect(when_called, CONNECT_REFERENCE_COUNTED) + var listener = preload("res://addons/godot-firebase/firestore/firestore_listener.tscn").instantiate() + add_child(listener) + listener.initialize_listener(collection_name, doc_name, poll_time) + listener.owner = self + var result = listener.enable_connection() + return result + +func get_value(property : StringName) -> Variant: + if property == "doc_name": + return doc_name + elif property == "collection_name": + return collection_name + elif property == "create_time": + return create_time + + if document.has(property): + var result = Utilities.from_firebase_type(document[property]) + return result + + return null + +func _get(property: StringName) -> Variant: + return get_value(property) + +func _set(property: StringName, value: Variant) -> bool: + assert(value != null, "When using the dictionary setter, the value cannot be null; use erase_field instead.") + document[property] = Utilities.to_firebase_type(value) + return true + +func get_unsafe_document() -> Dictionary: + var result = {} + for key in keys(): + result[key] = Utilities.from_firebase_type(document[key]) + + return result + +func keys(): + return document.keys() + +# Call print(document) to return directly this document formatted +func _to_string() -> String: + return ("doc_name: {doc_name}, \ndata: {data}, \ncreate_time: {create_time}\n").format( + {doc_name = self.doc_name, + data = document, + create_time = self.create_time}) diff --git a/addons/godot-firebase/firestore/firestore_document.gd.uid b/addons/godot-firebase/firestore/firestore_document.gd.uid new file mode 100644 index 0000000..0d4c163 --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_document.gd.uid @@ -0,0 +1 @@ +uid://lvx6e1rnbjha diff --git a/addons/godot-firebase/firestore/firestore_listener.gd b/addons/godot-firebase/firestore/firestore_listener.gd new file mode 100644 index 0000000..7808a5c --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_listener.gd @@ -0,0 +1,47 @@ +class_name FirestoreListener +extends Node + +const MinPollTime = 60 * 2 # seconds, so 2 minutes + +var _doc_name : String +var _poll_time : float +var _collection : FirestoreCollection + +var _total_time = 0.0 +var _enabled := false + +func initialize_listener(collection_name : String, doc_name : String, poll_time : float) -> void: + _poll_time = max(poll_time, MinPollTime) + _doc_name = doc_name + _collection = Firebase.Firestore.collection(collection_name) + +func enable_connection() -> FirestoreListenerConnection: + _enabled = true + set_process(true) + return FirestoreListenerConnection.new(self) + +func _process(delta: float) -> void: + if _enabled: + _total_time += delta + if _total_time >= _poll_time: + _check_for_server_updates() + _total_time = 0.0 + +func _check_for_server_updates() -> void: + var executor = func(): + var doc = await _collection.get_doc(_doc_name, false, true) + if doc == null: + set_process(false) # Document was deleted out from under us, so stop updating + + executor.call() # Hack to work around the await here, otherwise would have to call with await in _process and that's no bueno + +class FirestoreListenerConnection extends RefCounted: + var connection + + func _init(connection_node): + connection = connection_node + + func stop(): + if connection != null and is_instance_valid(connection): + connection.set_process(false) + connection.free() diff --git a/addons/godot-firebase/firestore/firestore_listener.gd.uid b/addons/godot-firebase/firestore/firestore_listener.gd.uid new file mode 100644 index 0000000..fc7e68e --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_listener.gd.uid @@ -0,0 +1 @@ +uid://bnn8dx3q452pr diff --git a/addons/godot-firebase/firestore/firestore_listener.tscn b/addons/godot-firebase/firestore/firestore_listener.tscn new file mode 100644 index 0000000..9f5e246 --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_listener.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bwv7vtgssc0n5"] + +[ext_resource type="Script" path="res://addons/godot-firebase/firestore/firestore_listener.gd" id="1_qlaei"] + +[node name="FirestoreListener" type="Node"] +script = ExtResource("1_qlaei") diff --git a/addons/godot-firebase/firestore/firestore_query.gd b/addons/godot-firebase/firestore/firestore_query.gd new file mode 100644 index 0000000..b093831 --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_query.gd @@ -0,0 +1,255 @@ +## @meta-authors Nicoló 'fenix' Santilio, Kyle Szklenski +## @meta-version 1.4 +## A firestore query. +## Documentation TODO. +@tool +extends RefCounted +class_name FirestoreQuery + +class Order: + var obj: Dictionary + +class Cursor: + var values: Array + var before: bool + + func _init(v : Array,b : bool): + values = v + before = b + +signal query_result(query_result) + +const TEMPLATE_QUERY: Dictionary = { + select = {}, + from = [], + where = {}, + orderBy = [], + startAt = {}, + endAt = {}, + offset = 0, + limit = 0 + } + +var query: Dictionary = {} +var aggregations: Array[Dictionary] = [] +var sub_collection_path: String = "" + +enum OPERATOR { + # Standard operators + OPERATOR_UNSPECIFIED, + LESS_THAN, + LESS_THAN_OR_EQUAL, + GREATER_THAN, + GREATER_THAN_OR_EQUAL, + EQUAL, + NOT_EQUAL, + ARRAY_CONTAINS, + ARRAY_CONTAINS_ANY, + IN, + NOT_IN, + + # Unary operators + IS_NAN, + IS_NULL, + IS_NOT_NAN, + IS_NOT_NULL, + + # Complex operators + AND, + OR + } + +enum DIRECTION { + DIRECTION_UNSPECIFIED, + ASCENDING, + DESCENDING + } + + +# Select which fields you want to return as a reflection from your query. +# Fields must be added inside a list. Only a field is accepted inside the list +# Leave the Array empty if you want to return the whole document +func select(fields) -> FirestoreQuery: + match typeof(fields): + TYPE_STRING: + query["select"] = { fields = { fieldPath = fields } } + TYPE_ARRAY: + for field in fields: + field = ({ fieldPath = field }) + query["select"] = { fields = fields } + _: + print("Type of 'fields' is not accepted.") + return self + + + +# Select the collection you want to return the query result from +# if @all_descendants also sub-collections will be returned. If false, only documents will be returned +func from(collection_id : String, all_descendants : bool = true) -> FirestoreQuery: + query["from"] = [{collectionId = collection_id, allDescendants = all_descendants}] + return self + +# @collections_array MUST be an Array of Arrays with this structure +# [ ["collection_id", true/false] ] +func from_many(collections_array : Array) -> FirestoreQuery: + var collections : Array = [] + for collection in collections_array: + collections.append({collectionId = collection[0], allDescendants = collection[1]}) + query["from"] = collections.duplicate(true) + return self + + +# Query the value of a field you want to match +# @field : the name of the field +# @operator : from FirestoreQuery.OPERATOR +# @value : can be any type - String, int, bool, float +# @chain : from FirestoreQuery.OPERATOR.[OR/AND], use it only if you want to chain "AND" or "OR" logic with futher where() calls +# eg. super.where("name", OPERATOR.EQUAL, "Matt", OPERATOR.AND).where("age", OPERATOR.LESS_THAN, 20) +func where(field : String, operator : int, value = null, chain : int = -1): + if operator in [OPERATOR.IS_NAN, OPERATOR.IS_NULL, OPERATOR.IS_NOT_NAN, OPERATOR.IS_NOT_NULL]: + if (chain in [OPERATOR.AND, OPERATOR.OR]) or (query.has("where") and query.where.has("compositeFilter")): + var filters : Array = [] + if query.has("where") and query.where.has("compositeFilter"): + if chain == -1: + filters = query.where.compositeFilter.filters.duplicate(true) + chain = OPERATOR.get(query.where.compositeFilter.op) + else: + filters.append(query.where) + filters.append(create_unary_filter(field, operator)) + query["where"] = create_composite_filter(chain, filters) + else: + query["where"] = create_unary_filter(field, operator) + else: + if value == null: + print("A value must be defined to match the field: {field}".format({field = field})) + else: + if (chain in [OPERATOR.AND, OPERATOR.OR]) or (query.has("where") and query.where.has("compositeFilter")): + var filters : Array = [] + if query.has("where") and query.where.has("compositeFilter"): + if chain == -1: + filters = query.where.compositeFilter.filters.duplicate(true) + chain = OPERATOR.get(query.where.compositeFilter.op) + else: + filters.append(query.where) + filters.append(create_field_filter(field, operator, value)) + query["where"] = create_composite_filter(chain, filters) + else: + query["where"] = create_field_filter(field, operator, value) + return self + + +# Order by a field, defining its name and the order direction +# default directoin = Ascending +func order_by(field : String, direction : int = DIRECTION.ASCENDING) -> FirestoreQuery: + query["orderBy"] = [_order_object(field, direction).obj] + return self + + +# Order by a set of fields and directions +# @order_list is an Array of Arrays with the following structure +# [@field_name , @DIRECTION.[direction]] +# else, order_object() can be called to return an already parsed Dictionary +func order_by_fields(order_field_list : Array) -> FirestoreQuery: + var order_list : Array = [] + for order in order_field_list: + if order is Array: + order_list.append(_order_object(order[0], order[1]).obj) + elif order is Order: + order_list.append(order.obj) + query["orderBy"] = order_list + return self + +func start_at(value, before : bool) -> FirestoreQuery: + var cursor : Cursor = _cursor_object(value, before) + query["startAt"] = { values = cursor.values, before = cursor.before } + print(query["startAt"]) + return self + + +func end_at(value, before : bool) -> FirestoreQuery: + var cursor : Cursor = _cursor_object(value, before) + query["startAt"] = { values = cursor.values, before = cursor.before } + print(query["startAt"]) + return self + + +func offset(offset : int) -> FirestoreQuery: + if offset < 0: + print("If specified, offset must be >= 0") + else: + query["offset"] = offset + return self + + +func limit(limit : int) -> FirestoreQuery: + if limit < 0: + print("If specified, offset must be >= 0") + else: + query["limit"] = limit + return self + + +func aggregate() -> FirestoreAggregation: + return FirestoreAggregation.new(self) + +class FirestoreAggregation extends RefCounted: + var _query: FirestoreQuery + + func _init(query: FirestoreQuery) -> void: + _query = query + + func sum(field: String) -> FirestoreQuery: + _query.aggregations.push_back({ sum = { field = { fieldPath = field }}}) + return _query + + func count(up_to: int) -> FirestoreQuery: + _query.aggregations.push_back({ count = { upTo = up_to }}) + return _query + + func average(field: String) -> FirestoreQuery: + _query.aggregations.push_back({ avg = { field = { fieldPath = field }}}) + return _query + +# UTILITIES ---------------------------------------- + +static func _cursor_object(value, before : bool) -> Cursor: + var parse : Dictionary = Utilities.dict2fields({value = value}).fields.value + var cursor : Cursor = Cursor.new(parse.arrayValue.values if parse.has("arrayValue") else [parse], before) + return cursor + +static func _order_object(field : String, direction : int) -> Order: + var order : Order = Order.new() + order.obj = { field = { fieldPath = field }, direction = DIRECTION.keys()[direction] } + return order + + +func create_field_filter(field : String, operator : int, value) -> Dictionary: + return { + fieldFilter = { + field = { fieldPath = field }, + op = OPERATOR.keys()[operator], + value = Utilities.dict2fields({value = value}).fields.value + } } + +func create_unary_filter(field : String, operator : int) -> Dictionary: + return { + unaryFilter = { + field = { fieldPath = field }, + op = OPERATOR.keys()[operator], + } } + +func create_composite_filter(operator : int, filters : Array) -> Dictionary: + return { + compositeFilter = { + op = OPERATOR.keys()[operator], + filters = filters + } } + +func clean() -> void: + query = { } + +func _to_string() -> String: + var pretty : String = "QUERY:\n" + for key in query.keys(): + pretty += "- {key} = {value}\n".format({key = key, value = query.get(key)}) + return pretty diff --git a/addons/godot-firebase/firestore/firestore_query.gd.uid b/addons/godot-firebase/firestore/firestore_query.gd.uid new file mode 100644 index 0000000..f3f4b9b --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_query.gd.uid @@ -0,0 +1 @@ +uid://c7laxjcm52kh5 diff --git a/addons/godot-firebase/firestore/firestore_task.gd b/addons/godot-firebase/firestore/firestore_task.gd new file mode 100644 index 0000000..8122ae5 --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_task.gd @@ -0,0 +1,188 @@ +## @meta-authors Nicolò 'fenix' Santilio, Kyle 'backat50ft' Szklenski +## @meta-version 1.4 +## +## A [code]FirestoreTask[/code] is an independent node inheriting [code]HTTPRequest[/code] that processes a [code]Firestore[/code] request. +## Once the Task is completed (both if successfully or not) it will emit the relative signal (or a general purpose signal [code]task_finished()[/code]) and will destroy automatically. +## +## Being a [code]Node[/code] it can be stored in a variable to yield checked it, and receive its result as a callback. +## All signals emitted by a [code]FirestoreTask[/code] represent a direct level of signal communication, which can be high ([code]get_document(document), result_query(result)[/code]) or low ([code]task_finished(result)[/code]). +## An indirect level of communication with Tasks is also provided, redirecting signals to the [class FirebaseFirestore] module. +## +## ex. +## [code]var task : FirestoreTask = Firebase.Firestore.query(query)[/code] +## [code]var result : Array = await task.task_finished[/code] +## [code]var result : Array = await task.result_query[/code] +## [code]var result : Array = await Firebase.Firestore.task_finished[/code] +## [code]var result : Array = await Firebase.Firestore.result_query[/code] +## +## @tutorial https://github.com/GodotNuts/GodotFirebase/wiki/Firestore#FirestoreTask + +@tool +class_name FirestoreTask +extends RefCounted + +## Emitted when a request is completed. The request can be successful or not successful: if not, an [code]error[/code] Dictionary will be passed as a result. +## @arg-types Variant +signal task_finished() + +enum Task { + TASK_GET, ## A GET Request Task, processing a get() request + TASK_POST, ## A POST Request Task, processing add() request + TASK_PATCH, ## A PATCH Request Task, processing a update() request + TASK_DELETE, ## A DELETE Request Task, processing a delete() request + TASK_QUERY, ## A POST Request Task, processing a query() request + TASK_AGG_QUERY, ## A POST Request Task, processing an aggregation_query() request + TASK_LIST, ## A POST Request Task, processing a list() request + TASK_COMMIT ## A POST Request Task that hits the write api +} + +## Mapping of Task enum values to descriptions for use in printing user-friendly error codes. +const TASK_MAP = { + Task.TASK_GET: "GET DOCUMENT", + Task.TASK_POST: "ADD DOCUMENT", + Task.TASK_PATCH: "UPDATE DOCUMENT", + Task.TASK_DELETE: "DELETE DOCUMENT", + Task.TASK_QUERY: "QUERY COLLECTION", + Task.TASK_LIST: "LIST DOCUMENTS", + Task.TASK_COMMIT: "COMMIT DOCUMENT", + Task.TASK_AGG_QUERY: "AGG QUERY COLLECTION" +} + +## The code indicating the request Firestore is processing. +## See @[enum FirebaseFirestore.Requests] to get a full list of codes identifiers. +## @setter set_action +var action : int = -1 : set = set_action + +## A variable, temporary holding the result of the request. +var data +var error: Dictionary +var document: FirestoreDocument + +var _response_headers: PackedStringArray = PackedStringArray() +var _response_code: int = 0 + +var _method: int = -1 +var _url: String = "" +var _fields: String = "" +var _headers: PackedStringArray = [] + +func _on_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void: + var bod = body.get_string_from_utf8() + if bod != "": + bod = Utilities.get_json_data(bod) + + var failed: bool = bod is Dictionary and bod.has("error") and response_code != HTTPClient.RESPONSE_OK + # Probably going to regret this... + if response_code == HTTPClient.RESPONSE_OK: + match action: + Task.TASK_POST, Task.TASK_GET, Task.TASK_PATCH: + document = FirestoreDocument.new(bod) + data = document + Task.TASK_DELETE: + data = true + Task.TASK_QUERY: + data = [] + for doc in bod: + if doc.has('document'): + data.append(FirestoreDocument.new(doc.document)) + Task.TASK_AGG_QUERY: + var agg_results = [] + for agg_result in bod: + var idx = 0 + var query_results = {} + for field_value in agg_result.result.aggregateFields.keys(): + var agg = data.aggregations[idx] + var field = agg_result.result.aggregateFields[field_value] + query_results[agg.keys()[0]] = Utilities.from_firebase_type(field) + idx += 1 + agg_results.push_back(query_results) + data = agg_results + Task.TASK_LIST: + data = [] + if bod.has('documents'): + for doc in bod.documents: + data.append(FirestoreDocument.new(doc)) + if bod.has("nextPageToken"): + data.append(bod.nextPageToken) + Task.TASK_COMMIT: + data = bod # Commit's response is not a full document, so don't treat it as such + else: + var description = "" + if TASK_MAP.has(action): + description = "(" + TASK_MAP[action] + ")" + + Firebase._printerr("Action in error was: " + str(action) + " " + description) + build_error(bod, action, description) + + task_finished.emit() + +func build_error(_error, action, description) -> void: + if _error: + if _error is Array and _error.size() > 0 and _error[0].has("error"): + _error = _error[0].error + elif _error is Dictionary and _error.keys().size() > 0 and _error.has("error"): + _error = _error.error + + error = _error + else: + #error.code, error.status, error.message + error = { "error": { + "code": 0, + "status": "Unknown Error", + "message": "Error: %s - %s" % [action, description] + } + } + + data = null + +func set_action(value : int) -> void: + action = value + match action: + Task.TASK_GET, Task.TASK_LIST: + _method = HTTPClient.METHOD_GET + Task.TASK_POST, Task.TASK_QUERY, Task.TASK_AGG_QUERY: + _method = HTTPClient.METHOD_POST + Task.TASK_PATCH: + _method = HTTPClient.METHOD_PATCH + Task.TASK_DELETE: + _method = HTTPClient.METHOD_DELETE + Task.TASK_COMMIT: + _method = HTTPClient.METHOD_POST + _: + assert(false) + + +func _merge_dict(dic_a : Dictionary, dic_b : Dictionary, nullify := false) -> Dictionary: + var ret := dic_a.duplicate(true) + for key in dic_b: + var val = dic_b[key] + + if val == null and nullify: + ret.erase(key) + elif val is Array: + ret[key] = _merge_array(ret.get(key) if ret.get(key) else [], val) + elif val is Dictionary: + ret[key] = _merge_dict(ret.get(key) if ret.get(key) else {}, val) + else: + ret[key] = val + return ret + + +func _merge_array(arr_a : Array, arr_b : Array, nullify := false) -> Array: + var ret := arr_a.duplicate(true) + ret.resize(len(arr_b)) + + var deletions := 0 + for i in len(arr_b): + var index : int = i - deletions + var val = arr_b[index] + if val == null and nullify: + ret.remove_at(index) + deletions += i + elif val is Array: + ret[index] = _merge_array(ret[index] if ret[index] else [], val) + elif val is Dictionary: + ret[index] = _merge_dict(ret[index] if ret[index] else {}, val) + else: + ret[index] = val + return ret diff --git a/addons/godot-firebase/firestore/firestore_task.gd.uid b/addons/godot-firebase/firestore/firestore_task.gd.uid new file mode 100644 index 0000000..a41a3ec --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_task.gd.uid @@ -0,0 +1 @@ +uid://bacq4pvag0wii diff --git a/addons/godot-firebase/firestore/firestore_transform.gd b/addons/godot-firebase/firestore/firestore_transform.gd new file mode 100644 index 0000000..6de6597 --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_transform.gd @@ -0,0 +1,3 @@ +class_name FirestoreTransform +extends RefCounted + diff --git a/addons/godot-firebase/firestore/firestore_transform.gd.uid b/addons/godot-firebase/firestore/firestore_transform.gd.uid new file mode 100644 index 0000000..373858f --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_transform.gd.uid @@ -0,0 +1 @@ +uid://pybqqmlkru0q diff --git a/addons/godot-firebase/functions/function_task.gd b/addons/godot-firebase/functions/function_task.gd new file mode 100644 index 0000000..96a6222 --- /dev/null +++ b/addons/godot-firebase/functions/function_task.gd @@ -0,0 +1,59 @@ +## @meta-authors Nicolò 'fenix' Santilio, +## @meta-version 1.2 +## +## ex. +## [code]var task : FirestoreTask = Firebase.Firestore.query(query)[/code] +## [code]var result : Array = await task.task_finished[/code] +## [code]var result : Array = await task.result_query[/code] +## [code]var result : Array = await Firebase.Firestore.task_finished[/code] +## [code]var result : Array = await Firebase.Firestore.result_query[/code] +## +## @tutorial https://github.com/GodotNuts/GodotFirebase/wiki/Firestore#FirestoreTask + +@tool +class_name FunctionTask +extends RefCounted + +## Emitted when a request is completed. The request can be successful or not successful: if not, an [code]error[/code] Dictionary will be passed as a result. +## @arg-types Variant +signal task_finished(result) + +## Emitted when a cloud function is correctly executed, returning the Response Code and Result Body +## @arg-types FirestoreDocument +signal function_executed(response, result) + +## Emitted when a request is [b]not[/b] successfully completed. +## @arg-types Dictionary +signal task_error(code, status, message) + +## A variable, temporary holding the result of the request. +var data: Dictionary +var error: Dictionary + +## Whether the data came from cache. +var from_cache : bool = false + +var _response_headers : PackedStringArray = PackedStringArray() +var _response_code : int = 0 + +var _method : int = -1 +var _url : String = "" +var _fields : String = "" +var _headers : PackedStringArray = [] + +func _on_request_completed(result : int, response_code : int, headers : PackedStringArray, body : PackedByteArray) -> void: + var bod = Utilities.get_json_data(body) + if bod == null: + bod = {content = body.get_string_from_utf8()} # I don't understand what this line does at all. What the hell?! + + var offline: bool = typeof(bod) == TYPE_NIL + from_cache = offline + + data = bod + if response_code == HTTPClient.RESPONSE_OK and data!=null: + function_executed.emit(result, data) + else: + error = {result=result, response_code=response_code, data=data} + task_error.emit(result, response_code, str(data)) + + task_finished.emit(data) diff --git a/addons/godot-firebase/functions/function_task.gd.uid b/addons/godot-firebase/functions/function_task.gd.uid new file mode 100644 index 0000000..b9d4f5f --- /dev/null +++ b/addons/godot-firebase/functions/function_task.gd.uid @@ -0,0 +1 @@ +uid://xrp4ueej55sc diff --git a/addons/godot-firebase/functions/functions.gd b/addons/godot-firebase/functions/functions.gd new file mode 100644 index 0000000..8ccfa97 --- /dev/null +++ b/addons/godot-firebase/functions/functions.gd @@ -0,0 +1,219 @@ +## @meta-authors Nicolò 'fenix' Santilio, +## @meta-version 2.5 +## +## (source: [url=https://firebase.google.com/docs/functions]Functions[/url]) +## +## @tutorial https://github.com/GodotNuts/GodotFirebase/wiki/Functions +@tool +class_name FirebaseFunctions +extends Node + +## Emitted when a [code]query()[/code] request is successfully completed. [code]error()[/code] signal will be emitted otherwise. +## @arg-types Array +## Emitted when a [code]list()[/code] or [code]query()[/code] request is [b]not[/b] successfully completed. +signal task_error(code,status,message) + +# TODO: Implement cache size limit +const CACHE_SIZE_UNLIMITED = -1 + +const _CACHE_EXTENSION : String = ".fscache" +const _CACHE_RECORD_FILE : String = "RmlyZXN0b3JlIGNhY2hlLXJlY29yZHMu.fscache" + +const _AUTHORIZATION_HEADER : String = "Authorization: Bearer " + +const _MAX_POOLED_REQUEST_AGE = 30 + +## The code indicating the request Firestore is processing. +## See @[enum FirebaseFirestore.Requests] to get a full list of codes identifiers. +## @enum Requests +var request : int = -1 + +## Whether cache files can be used and generated. +## @default true +var persistence_enabled : bool = false + +## Whether an internet connection can be used. +## @default true +var networking: bool = true : set = set_networking + +## A Dictionary containing all authentication fields for the current logged user. +## @type Dictionary +var auth : Dictionary + +var _config : Dictionary = {} +var _cache_loc: String +var _encrypt_key: String = "" if Utilities.is_web() else OS.get_unique_id() + +var _base_url : String = "" + +var _http_request_pool : Array = [] + +var _offline: bool = false : set = _set_offline + +func _ready() -> void: + set_process(false) + +func _process(delta : float) -> void: + for i in range(_http_request_pool.size() - 1, -1, -1): + var request = _http_request_pool[i] + if not request.get_meta("requesting"): + var lifetime: float = request.get_meta("lifetime") + delta + if lifetime > _MAX_POOLED_REQUEST_AGE: + request.queue_free() + _http_request_pool.remove_at(i) + return # Prevent setting a value on request after it's already been queue_freed + request.set_meta("lifetime", lifetime) + + +## @args +## @return FunctionTask +func execute(function: String, method: int, params: Dictionary = {}, body: Dictionary = {}) -> FunctionTask: + set_process(true) + var function_task : FunctionTask = FunctionTask.new() + function_task.task_error.connect(_on_task_error) + function_task.task_finished.connect(_on_task_finished) + function_task.function_executed.connect(_on_function_executed) + + function_task._method = method + + var url : String = _base_url + ("/" if not _base_url.ends_with("/") else "") + function + function_task._url = url + + if not params.is_empty(): + url += "?" + for key in params.keys(): + url += key + "=" + params[key] + "&" + + if not body.is_empty(): + function_task._fields = JSON.stringify(body) + + _pooled_request(function_task) + return function_task + + +func set_networking(value: bool) -> void: + if value: + enable_networking() + else: + disable_networking() + + +func enable_networking() -> void: + if networking: + return + networking = true + _base_url = _base_url.replace("storeoffline", "functions") + + +func disable_networking() -> void: + if not networking: + return + networking = false + # Pointing to an invalid url should do the trick. + _base_url = _base_url.replace("functions", "storeoffline") + + +func _set_offline(value: bool) -> void: + if value == _offline: + return + + _offline = value + if not persistence_enabled: + return + + return + + +func _set_config(config_json : Dictionary) -> void: + _config = config_json + _cache_loc = _config["cacheLocation"] + + if _encrypt_key == "": _encrypt_key = _config.apiKey + _check_emulating() + + +func _check_emulating() -> void : + ## Check emulating + if not Firebase.emulating: + _base_url = "https://{zone}-{projectId}.cloudfunctions.net/".format({ zone = _config.functionsGeoZone, projectId = _config.projectId }) + else: + var port : String = _config.emulators.ports.functions + if port == "": + Firebase._printerr("You are in 'emulated' mode, but the port for Cloud Functions has not been configured.") + else: + _base_url = "http://localhost:{port}/{projectId}/{zone}/".format({ port = port, zone = _config.functionsGeoZone, projectId = _config.projectId }) + + +func _pooled_request(task : FunctionTask) -> void: + if _offline: + task._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 404, PackedStringArray(), PackedByteArray()) + return + + if auth == null or auth.is_empty(): + Firebase._print("Unauthenticated request issued...") + Firebase.Auth.login_anonymous() + var result : Array = await Firebase.Auth.auth_request + if result[0] != 1: + _check_auth_error(result[0], result[1]) + Firebase._print("Client connected as Anonymous") + + + task._headers = ["Content-Type: application/json", _AUTHORIZATION_HEADER + auth.idtoken] + + var http_request : HTTPRequest + for request in _http_request_pool: + if not request.get_meta("requesting"): + http_request = request + break + + if not http_request: + http_request = HTTPRequest.new() + Utilities.fix_http_request(http_request) + http_request.accept_gzip = false + _http_request_pool.append(http_request) + add_child(http_request) + http_request.request_completed.connect(_on_pooled_request_completed.bind(http_request)) + + http_request.set_meta("requesting", true) + http_request.set_meta("lifetime", 0.0) + http_request.set_meta("task", task) + http_request.request(task._url, task._headers, task._method, task._fields) + + +# ------------- + +func _on_task_finished(data : Dictionary) : + pass + +func _on_function_executed(result : int, data : Dictionary) : + pass + +func _on_task_error(code : int, status : int, message : String): + task_error.emit(code, status, message) + Firebase._printerr(message) + +func _on_FirebaseAuth_login_succeeded(auth_result : Dictionary) -> void: + auth = auth_result + + +func _on_FirebaseAuth_token_refresh_succeeded(auth_result : Dictionary) -> void: + auth = auth_result + + +func _on_pooled_request_completed(result : int, response_code : int, headers : PackedStringArray, body : PackedByteArray, request : HTTPRequest) -> void: + request.get_meta("task")._on_request_completed(result, response_code, headers, body) + request.set_meta("requesting", false) + + +func _on_connect_check_request_completed(result : int, _response_code, _headers, _body) -> void: + _set_offline(result != HTTPRequest.RESULT_SUCCESS) + + +func _on_FirebaseAuth_logout() -> void: + auth = {} + +func _check_auth_error(code : int, message : String) -> void: + var err : String + match code: + 400: err = "Please, enable Anonymous Sign-in method or Authenticate the Client before issuing a request (best option)" + Firebase._printerr(err) diff --git a/addons/godot-firebase/functions/functions.gd.uid b/addons/godot-firebase/functions/functions.gd.uid new file mode 100644 index 0000000..6711769 --- /dev/null +++ b/addons/godot-firebase/functions/functions.gd.uid @@ -0,0 +1 @@ +uid://bil4t34fdutkf diff --git a/addons/godot-firebase/icon.svg b/addons/godot-firebase/icon.svg new file mode 100644 index 0000000..ed10921 --- /dev/null +++ b/addons/godot-firebase/icon.svg @@ -0,0 +1 @@ + diff --git a/addons/godot-firebase/icon.svg.import b/addons/godot-firebase/icon.svg.import new file mode 100644 index 0000000..943b056 --- /dev/null +++ b/addons/godot-firebase/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://2selq12fp4q0" +path="res://.godot/imported/icon.svg-5c4f39d37c9275a3768de73a392fd315.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/godot-firebase/icon.svg" +dest_files=["res://.godot/imported/icon.svg-5c4f39d37c9275a3768de73a392fd315.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/godot-firebase/plugin.cfg b/addons/godot-firebase/plugin.cfg new file mode 100644 index 0000000..9740be9 --- /dev/null +++ b/addons/godot-firebase/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="GodotFirebase" +description="Google Firebase SDK written in GDScript for use in Godot Engine 4.0 projects." +author="GodotNutsOrg" +version="2.0" +script="plugin.gd" diff --git a/addons/godot-firebase/plugin.gd b/addons/godot-firebase/plugin.gd new file mode 100644 index 0000000..f68d5ae --- /dev/null +++ b/addons/godot-firebase/plugin.gd @@ -0,0 +1,8 @@ +@tool +extends EditorPlugin + +func _enter_tree() -> void: + add_autoload_singleton("Firebase", "res://addons/godot-firebase/firebase/firebase.tscn") + +func _exit_tree() -> void: + remove_autoload_singleton("Firebase") diff --git a/addons/godot-firebase/plugin.gd.uid b/addons/godot-firebase/plugin.gd.uid new file mode 100644 index 0000000..dd6a914 --- /dev/null +++ b/addons/godot-firebase/plugin.gd.uid @@ -0,0 +1 @@ +uid://cu50owt435wxm diff --git a/addons/godot-firebase/queues/queueable_http_request.gd b/addons/godot-firebase/queues/queueable_http_request.gd new file mode 100644 index 0000000..0143a78 --- /dev/null +++ b/addons/godot-firebase/queues/queueable_http_request.gd @@ -0,0 +1,30 @@ +class_name QueueableHTTPRequest +extends HTTPRequest + +signal queue_request_completed(result : int, response_code : int, headers : PackedStringArray, body : PackedByteArray) + +var _queue := [] + +# Determine if we need to set Use Threads to true; it can cause collisions with get_http_client_status() due to a thread returning the data _after_ having checked the connection status and result in double-requests. + +func _ready() -> void: + request_completed.connect( + func(result : int, response_code : int, headers : PackedStringArray, body : PackedByteArray): + queue_request_completed.emit(result, response_code, headers, body) + + if not _queue.is_empty(): + var req = _queue.pop_front() + self.request(req.url, req.headers, req.method, req.data) + ) + +func request(url : String, headers : PackedStringArray = PackedStringArray(), method := HTTPClient.METHOD_GET, data : String = "") -> Error: + var status = get_http_client_status() + var result = OK + + if status != HTTPClient.STATUS_DISCONNECTED: + _queue.push_back({url=url, headers=headers, method=method, data=data}) + return result + + result = super.request(url, headers, method, data) + + return result diff --git a/addons/godot-firebase/queues/queueable_http_request.gd.uid b/addons/godot-firebase/queues/queueable_http_request.gd.uid new file mode 100644 index 0000000..439970a --- /dev/null +++ b/addons/godot-firebase/queues/queueable_http_request.gd.uid @@ -0,0 +1 @@ +uid://37mycjbymm3y diff --git a/addons/godot-firebase/queues/queueable_http_request.tscn b/addons/godot-firebase/queues/queueable_http_request.tscn new file mode 100644 index 0000000..d166941 --- /dev/null +++ b/addons/godot-firebase/queues/queueable_http_request.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://ctb4l7plg8kqg"] + +[ext_resource type="Script" path="res://addons/godot-firebase/queues/queueable_http_request.gd" id="1_2rucc"] + +[node name="QueueableHTTPRequest" type="HTTPRequest"] +script = ExtResource("1_2rucc") diff --git a/addons/godot-firebase/remote_config/firebase_remote_config.gd b/addons/godot-firebase/remote_config/firebase_remote_config.gd new file mode 100644 index 0000000..ee3653e --- /dev/null +++ b/addons/godot-firebase/remote_config/firebase_remote_config.gd @@ -0,0 +1,36 @@ +@tool +class_name FirebaseRemoteConfig +extends Node + +const RemoteConfigFunctionId = "getRemoteConfig" + +signal remote_config_received(config) +signal remote_config_error(error) + +var _project_config = {} +var _headers : PackedStringArray = [ +] +var _auth : Dictionary + +func _set_config(config_json : Dictionary) -> void: + _project_config = config_json # This may get confusing, hoping the variable name makes it easier to understand + +func _on_FirebaseAuth_login_succeeded(auth_result : Dictionary) -> void: + _auth = auth_result + +func _on_FirebaseAuth_token_refresh_succeeded(auth_result : Dictionary) -> void: + _auth = auth_result + +func _on_FirebaseAuth_logout() -> void: + _auth = {} + +func get_remote_config() -> void: + var function_task = Firebase.Functions.execute("getRemoteConfig", HTTPClient.METHOD_GET, {}, {}) as FunctionTask + var result = await function_task.task_finished + Firebase._print("Config request result: " + str(result)) + if result.has("error"): + remote_config_error.emit(result) + return + + var config = RemoteConfig.new(result) + remote_config_received.emit(config) diff --git a/addons/godot-firebase/remote_config/firebase_remote_config.gd.uid b/addons/godot-firebase/remote_config/firebase_remote_config.gd.uid new file mode 100644 index 0000000..c01c9c4 --- /dev/null +++ b/addons/godot-firebase/remote_config/firebase_remote_config.gd.uid @@ -0,0 +1 @@ +uid://xqngri5s6yc5 diff --git a/addons/godot-firebase/remote_config/firebase_remote_config.tscn b/addons/godot-firebase/remote_config/firebase_remote_config.tscn new file mode 100644 index 0000000..5c42d3f --- /dev/null +++ b/addons/godot-firebase/remote_config/firebase_remote_config.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3 uid="uid://5xa6ulbllkjk"] + +[ext_resource type="Script" path="res://addons/godot-firebase/remote_config/firebase_remote_config.gd" id="1_wx4ds"] + +[node name="FirebaseRemoteConfig" type="HTTPRequest"] +use_threads = true +script = ExtResource("1_wx4ds") diff --git a/addons/godot-firebase/remote_config/remote_config.gd b/addons/godot-firebase/remote_config/remote_config.gd new file mode 100644 index 0000000..2b72cc6 --- /dev/null +++ b/addons/godot-firebase/remote_config/remote_config.gd @@ -0,0 +1,14 @@ +class_name RemoteConfig +extends RefCounted + +var default_config = {} + +func _init(values : Dictionary) -> void: + default_config = values + +func get_value(key : String) -> Variant: + if default_config.has(key): + return default_config[key] + + Firebase._printerr("Remote config does not contain key: " + key) + return null diff --git a/addons/godot-firebase/remote_config/remote_config.gd.uid b/addons/godot-firebase/remote_config/remote_config.gd.uid new file mode 100644 index 0000000..dffda04 --- /dev/null +++ b/addons/godot-firebase/remote_config/remote_config.gd.uid @@ -0,0 +1 @@ +uid://bqimgkjkven2l diff --git a/addons/godot-firebase/storage/storage.gd b/addons/godot-firebase/storage/storage.gd new file mode 100644 index 0000000..baabd75 --- /dev/null +++ b/addons/godot-firebase/storage/storage.gd @@ -0,0 +1,362 @@ +## @meta-authors SIsilicon +## @meta-version 2.2 +## The Storage API for Firebase. +## This object handles all firebase storage tasks, variables and references. To use this API, you must first create a [StorageReference] with [method ref]. With the reference, you can then query and manipulate the file or folder in the cloud storage. +## +## [i]Note: In HTML builds, you must configure [url=https://firebase.google.com/docs/storage/web/download-files#cors_configuration]CORS[/url] in your storage bucket.[i] +@tool +class_name FirebaseStorage +extends Node + +const _API_VERSION : String = "v0" + +## @arg-types int, int, PackedStringArray +## @arg-enums HTTPRequest.Result, HTTPClient.ResponseCode +## Emitted when a [StorageTask] has finished with an error. +signal task_failed(result, response_code, data) + +## The current storage bucket the Storage API is referencing. +var bucket : String + +## @default false +## Whether a task is currently being processed. +var requesting : bool = false + +var _auth : Dictionary +var _config : Dictionary + +var _references : Dictionary = {} + +var _base_url : String = "" +var _extended_url : String = "/[API_VERSION]/b/[APP_ID]/o/[FILE_PATH]" +var _root_ref : StorageReference + +var _http_client : HTTPClient = HTTPClient.new() +var _pending_tasks : Array = [] + +var _current_task : StorageTask +var _response_code : int +var _response_headers : PackedStringArray +var _response_data : PackedByteArray +var _content_length : int +var _reading_body : bool + +func _notification(what : int) -> void: + if what == NOTIFICATION_INTERNAL_PROCESS: + _internal_process(get_process_delta_time()) + +func _internal_process(_delta : float) -> void: + if not requesting: + set_process_internal(false) + return + + var task = _current_task + + match _http_client.get_status(): + HTTPClient.STATUS_DISCONNECTED: + _http_client.connect_to_host(_base_url, 443, TLSOptions.client()) # Uhh, check if this is going to work. I assume not. + + HTTPClient.STATUS_RESOLVING, \ + HTTPClient.STATUS_REQUESTING, \ + HTTPClient.STATUS_CONNECTING: + _http_client.poll() + + HTTPClient.STATUS_CONNECTED: + var err := _http_client.request_raw(task._method, task._url, task._headers, task.data) + if err: + _finish_request(HTTPRequest.RESULT_CONNECTION_ERROR) + + HTTPClient.STATUS_BODY: + if _http_client.has_response() or _reading_body: + _reading_body = true + + # If there is a response... + if _response_headers.is_empty(): + _response_headers = _http_client.get_response_headers() # Get response headers. + _response_code = _http_client.get_response_code() + + for header in _response_headers: + if "Content-Length" in header: + _content_length = header.trim_prefix("Content-Length: ").to_int() + break + + _http_client.poll() + var chunk = _http_client.read_response_body_chunk() # Get a chunk. + if chunk.size() == 0: + # Got nothing, wait for buffers to fill a bit. + pass + else: + _response_data += chunk # Append to read buffer. + if _content_length != 0: + task.progress = float(_response_data.size()) / _content_length + + if _http_client.get_status() != HTTPClient.STATUS_BODY: + task.progress = 1.0 + _finish_request(HTTPRequest.RESULT_SUCCESS) + else: + task.progress = 1.0 + _finish_request(HTTPRequest.RESULT_SUCCESS) + + HTTPClient.STATUS_CANT_CONNECT: + _finish_request(HTTPRequest.RESULT_CANT_CONNECT) + HTTPClient.STATUS_CANT_RESOLVE: + _finish_request(HTTPRequest.RESULT_CANT_RESOLVE) + HTTPClient.STATUS_CONNECTION_ERROR: + _finish_request(HTTPRequest.RESULT_CONNECTION_ERROR) + HTTPClient.STATUS_TLS_HANDSHAKE_ERROR: + _finish_request(HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR) + +## @args path +## @arg-defaults "" +## @return StorageReference +## Returns a reference to a file or folder in the storage bucket. It's this reference that should be used to control the file/folder checked the server end. +func ref(path := "") -> StorageReference: + if _config == null or _config.is_empty(): + return null + + # Create a root storage reference if there's none + # and we're not making one. + if path != "" and not _root_ref: + _root_ref = ref() + + path = _simplify_path(path) + if not _references.has(path): + var ref := StorageReference.new() + _references[path] = ref + ref.bucket = bucket + ref.full_path = path + ref.file_name = path.get_file() + ref.parent = ref(path.path_join("..")) + ref.root = _root_ref + ref.storage = self + add_child(ref) + return ref + else: + return _references[path] + +func _set_config(config_json : Dictionary) -> void: + _config = config_json + if bucket != _config.storageBucket: + bucket = _config.storageBucket + _http_client.close() + _check_emulating() + + +func _check_emulating() -> void : + ## Check emulating + if not Firebase.emulating: + _base_url = "https://firebasestorage.googleapis.com" + else: + var port : String = _config.emulators.ports.storage + if port == "": + Firebase._printerr("You are in 'emulated' mode, but the port for Storage has not been configured.") + else: + _base_url = "http://localhost:{port}/{version}/".format({ version = _API_VERSION, port = port }) + + +func _upload(data : PackedByteArray, headers : PackedStringArray, ref : StorageReference, meta_only : bool) -> Variant: + if _is_invalid_authentication(): + Firebase._printerr("Error uploading to storage: Invalid authentication") + return 0 + + var task := StorageTask.new() + task.ref = ref + task._url = _get_file_url(ref) + task.action = StorageTask.Task.TASK_UPLOAD_META if meta_only else StorageTask.Task.TASK_UPLOAD + task._headers = headers + task.data = data + _process_request(task) + return await task.task_finished + +func _download(ref : StorageReference, meta_only : bool, url_only : bool) -> Variant: + if _is_invalid_authentication(): + Firebase._printerr("Error downloading from storage: Invalid authentication") + return 0 + + var info_task := StorageTask.new() + info_task.ref = ref + info_task._url = _get_file_url(ref) + info_task.action = StorageTask.Task.TASK_DOWNLOAD_URL if url_only else StorageTask.Task.TASK_DOWNLOAD_META + _process_request(info_task) + + if url_only or meta_only: + return await info_task.task_finished + + var task := StorageTask.new() + task.ref = ref + task._url = _get_file_url(ref) + "?alt=media&token=" + task.action = StorageTask.Task.TASK_DOWNLOAD + _pending_tasks.append(task) + + var data = await info_task.task_finished + if info_task.result == OK: + task._url += info_task.data.downloadTokens + else: + task.data = info_task.data + task.response_headers = info_task.response_headers + task.response_code = info_task.response_code + task.result = info_task.result + task.finished = true + task.task_finished.emit(null) + task_failed.emit(task.result, task.response_code, task.data) + _pending_tasks.erase(task) + return null + + return await task.task_finished + +func _list(ref : StorageReference, list_all : bool) -> Array: + if _is_invalid_authentication(): + Firebase._printerr("Error getting object list from storage: Invalid authentication") + return [] + + var task := StorageTask.new() + task.ref = ref + task._url = _get_file_url(_root_ref).trim_suffix("/") + task.action = StorageTask.Task.TASK_LIST_ALL if list_all else StorageTask.Task.TASK_LIST + _process_request(task) + return await task.task_finished + +func _delete(ref : StorageReference) -> bool: + if _is_invalid_authentication(): + Firebase._printerr("Error deleting object from storage: Invalid authentication") + return false + + var task := StorageTask.new() + task.ref = ref + task._url = _get_file_url(ref) + task.action = StorageTask.Task.TASK_DELETE + _process_request(task) + var data = await task.task_finished + + return data == null + +func _process_request(task : StorageTask) -> void: + if requesting: + _pending_tasks.append(task) + return + requesting = true + + var headers = Array(task._headers) + headers.append("Authorization: Bearer " + _auth.idtoken) + task._headers = PackedStringArray(headers) + + _current_task = task + _response_code = 0 + _response_headers = PackedStringArray() + _response_data = PackedByteArray() + _content_length = 0 + _reading_body = false + + if not _http_client.get_status() in [HTTPClient.STATUS_CONNECTED, HTTPClient.STATUS_DISCONNECTED]: + _http_client.close() + set_process_internal(true) + +func _finish_request(result : int) -> void: + var task := _current_task + requesting = false + + task.result = result + task.response_code = _response_code + task.response_headers = _response_headers + + match task.action: + StorageTask.Task.TASK_DOWNLOAD: + task.data = _response_data + + StorageTask.Task.TASK_DELETE: + _references.erase(task.ref.full_path) + for child in get_children(): + if child.full_path == task.ref.full_path: + child.queue_free() + break + if typeof(task.data) == TYPE_PACKED_BYTE_ARRAY: + task.data = null + + StorageTask.Task.TASK_DOWNLOAD_URL: + var json = Utilities.get_json_data(_response_data) + if json != null and json.has("error"): + Firebase._printerr("Error getting object download url: "+json["error"].message) + if json != null and json.has("downloadTokens"): + task.data = _base_url + _get_file_url(task.ref) + "?alt=media&token=" + json.downloadTokens + else: + task.data = "" + + StorageTask.Task.TASK_LIST, StorageTask.Task.TASK_LIST_ALL: + var json = Utilities.get_json_data(_response_data) + var items := [] + if json != null and json.has("error"): + Firebase._printerr("Error getting data from storage: "+json["error"].message) + if json != null and json.has("items"): + for item in json.items: + var item_name : String = item.name + if item.bucket != bucket: + continue + if not item_name.begins_with(task.ref.full_path): + continue + if task.action == StorageTask.Task.TASK_LIST: + var dir_path : Array = item_name.split("/") + var slash_count : int = task.ref.full_path.count("/") + item_name = "" + for i in slash_count + 1: + item_name += dir_path[i] + if i != slash_count and slash_count != 0: + item_name += "/" + if item_name in items: + continue + + items.append(item_name) + task.data = items + + _: + var json = Utilities.get_json_data(_response_data) + task.data = json + + var next_task = _get_next_pending_task() + + task.finished = true + task.task_finished.emit(task.data) # I believe this parameter has been missing all along, but not sure. Caused weird results at times with a yield/await returning null, but the task containing data. + if typeof(task.data) == TYPE_DICTIONARY and task.data.has("error"): + task_failed.emit(task.result, task.response_code, task.data) + + if next_task and not next_task.finished: + _process_request(next_task) + +func _get_next_pending_task() -> StorageTask: + if _pending_tasks.is_empty(): + return null + + return _pending_tasks.pop_front() + +func _get_file_url(ref : StorageReference) -> String: + var url := _extended_url.replace("[APP_ID]", ref.bucket) + url = url.replace("[API_VERSION]", _API_VERSION) + return url.replace("[FILE_PATH]", ref.full_path.uri_encode()) + +# Removes any "../" or "./" in the file path. +func _simplify_path(path : String) -> String: + var dirs := path.split("/") + var new_dirs := [] + for dir in dirs: + if dir == "..": + new_dirs.pop_back() + elif dir == ".": + pass + else: + new_dirs.push_back(dir) + + var new_path := "/".join(PackedStringArray(new_dirs)) + new_path = new_path.replace("//", "/") + new_path = new_path.replace("\\", "/") + return new_path + +func _on_FirebaseAuth_login_succeeded(auth_token : Dictionary) -> void: + _auth = auth_token + +func _on_FirebaseAuth_token_refresh_succeeded(auth_result : Dictionary) -> void: + _auth = auth_result + +func _on_FirebaseAuth_logout() -> void: + _auth = {} + +func _is_invalid_authentication() -> bool: + return (_config == null or _config.is_empty()) or (_auth == null or _auth.is_empty()) diff --git a/addons/godot-firebase/storage/storage.gd.uid b/addons/godot-firebase/storage/storage.gd.uid new file mode 100644 index 0000000..4549b37 --- /dev/null +++ b/addons/godot-firebase/storage/storage.gd.uid @@ -0,0 +1 @@ +uid://vxab4cp6e8hx diff --git a/addons/godot-firebase/storage/storage_reference.gd b/addons/godot-firebase/storage/storage_reference.gd new file mode 100644 index 0000000..2989d12 --- /dev/null +++ b/addons/godot-firebase/storage/storage_reference.gd @@ -0,0 +1,159 @@ +## @meta-authors SIsilicon +## @meta-version 2.2 +## A reference to a file or folder in the Firebase cloud storage. +## This object is used to interact with the cloud storage. You may get data from the server, as well as upload your own back to it. +@tool +class_name StorageReference +extends Node + +## The default MIME type to use when uploading a file. +## Data sent with this type are interpreted as plain binary data. Note that firebase will generate an MIME type based checked the file extenstion if none is provided. +const DEFAULT_MIME_TYPE = "application/octet-stream" + +## A dictionary of common MIME types based checked a file extension. +## Example: [code]MIME_TYPES.png[/code] will return [code]image/png[/code]. +const MIME_TYPES = { + "bmp": "image/bmp", + "css": "text/css", + "csv": "text/csv", + "gd": "text/plain", + "htm": "text/html", + "html": "text/html", + "jpeg": "image/jpeg", + "jpg": "image/jpeg", + "json": "application/json", + "mp3": "audio/mpeg", + "mpeg": "video/mpeg", + "ogg": "audio/ogg", + "ogv": "video/ogg", + "png": "image/png", + "shader": "text/plain", + "svg": "image/svg+xml", + "tif": "image/tiff", + "tiff": "image/tiff", + "tres": "text/plain", + "tscn": "text/plain", + "txt": "text/plain", + "wav": "audio/wav", + "webm": "video/webm", + "webp": "image/webp", + "xml": "text/xml", +} + +## @default "" +## The stroage bucket this referenced file/folder is located in. +var bucket : String = "" + +## @default "" +## The path to the file/folder relative to [member bucket]. +var full_path : String = "" + +## @default "" +## The name of the file/folder, including any file extension. +## Example: If the [member full_path] is [code]images/user/image.png[/code], then the [member name] would be [code]image.png[/code]. +var file_name : String = "" + +## The parent [StorageReference] one level up the file hierarchy. +## If the current [StorageReference] is the root (i.e. the [member full_path] is [code]""[/code]) then the [member parent] will be [code]null[/code]. +var parent : StorageReference + +## The root [StorageReference]. +var root : StorageReference + +## @type FirebaseStorage +## The Storage API that created this [StorageReference] to begin with. +var storage # FirebaseStorage (Can't static type due to cyclic reference) + +## @args path +## @return StorageReference +## Returns a reference to another [StorageReference] relative to this one. +func child(path : String) -> StorageReference: + return storage.ref(full_path.path_join(path)) + +## @args data, metadata +## @return int +## Makes an attempt to upload data to the referenced file location. Returns Variant +func put_data(data : PackedByteArray, metadata := {}) -> Variant: + if not "Content-Length" in metadata and not Utilities.is_web(): + metadata["Content-Length"] = data.size() + + var headers := [] + for key in metadata: + headers.append("%s: %s" % [key, metadata[key]]) + + return await storage._upload(data, headers, self, false) + + +## @args data, metadata +## @return int +## Like [method put_data], but [code]data[/code] is a [String]. +func put_string(data : String, metadata := {}) -> Variant: + return await put_data(data.to_utf8_buffer(), metadata) + +## @args file_path, metadata +## @return int +## Like [method put_data], but the data comes from a file at [code]file_path[/code]. +func put_file(file_path : String, metadata := {}) -> Variant: + var file := FileAccess.open(file_path, FileAccess.READ) + var data := file.get_buffer(file.get_length()) + + if "Content-Type" in metadata: + metadata["Content-Type"] = MIME_TYPES.get(file_path.get_extension(), DEFAULT_MIME_TYPE) + + return await put_data(data, metadata) + +## @return Variant +## Makes an attempt to download the files from the referenced file location. Status checked this task is found in the returned [StorageTask]. +func get_data() -> Variant: + var result = await storage._download(self, false, false) + return result + +## @return StorageTask +## Like [method get_data], but the data in the returned [StorageTask] comes in the form of a [String]. +func get_string() -> String: + var task := await get_data() + _on_task_finished(task, "stringify") + return task.data + +## @return StorageTask +## Attempts to get the download url that points to the referenced file's data. Using the url directly may require an authentication header. Status checked this task is found in the returned [StorageTask]. +func get_download_url() -> Variant: + return await storage._download(self, false, true) + +## @return StorageTask +## Attempts to get the metadata of the referenced file. Status checked this task is found in the returned [StorageTask]. +func get_metadata() -> Variant: + return await storage._download(self, true, false) + +## @args metadata +## @return StorageTask +## Attempts to update the metadata of the referenced file. Any field with a value of [code]null[/code] will be deleted checked the server end. Status checked this task is found in the returned [StorageTask]. +func update_metadata(metadata : Dictionary) -> Variant: + var data := JSON.stringify(metadata).to_utf8_buffer() + var headers := PackedStringArray(["Accept: application/json"]) + return await storage._upload(data, headers, self, true) + +## @return StorageTask +## Attempts to get the list of files and/or folders under the referenced folder This function is not nested unlike [method list_all]. Status checked this task is found in the returned [StorageTask]. +func list() -> Array: + return await storage._list(self, false) + +## @return StorageTask +## Attempts to get the list of files and/or folders under the referenced folder This function is nested unlike [method list]. Status checked this task is found in the returned [StorageTask]. +func list_all() -> Array: + return await storage._list(self, true) + +## @return StorageTask +## Attempts to delete the referenced file/folder. If successful, the reference will become invalid And can no longer be used. If you need to reference this location again, make a new reference with [method StorageTask.ref]. Status checked this task is found in the returned [StorageTask]. +func delete() -> bool: + return await storage._delete(self) + +func _to_string() -> String: + var string := "gs://%s/%s" % [bucket, full_path] + return string + +func _on_task_finished(task : StorageTask, action : String) -> void: + match action: + "stringify": + if typeof(task.data) == TYPE_PACKED_BYTE_ARRAY: + task.data = task.data.get_string_from_utf8() diff --git a/addons/godot-firebase/storage/storage_reference.gd.uid b/addons/godot-firebase/storage/storage_reference.gd.uid new file mode 100644 index 0000000..081bdff --- /dev/null +++ b/addons/godot-firebase/storage/storage_reference.gd.uid @@ -0,0 +1 @@ +uid://bpvs4eelnej5i diff --git a/addons/godot-firebase/storage/storage_task.gd b/addons/godot-firebase/storage/storage_task.gd new file mode 100644 index 0000000..fa5cefd --- /dev/null +++ b/addons/godot-firebase/storage/storage_task.gd @@ -0,0 +1,74 @@ +## @meta-authors SIsilicon, Kyle 'backat50ft' Szklenski +## @meta-version 2.2 +## An object that keeps track of an operation performed by [StorageReference]. +@tool +class_name StorageTask +extends RefCounted + +enum Task { + TASK_UPLOAD, + TASK_UPLOAD_META, + TASK_DOWNLOAD, + TASK_DOWNLOAD_META, + TASK_DOWNLOAD_URL, + TASK_LIST, + TASK_LIST_ALL, + TASK_DELETE, + TASK_MAX ## The number of [enum Task] constants. +} + +## Emitted when the task is finished. Returns data depending checked the success and action of the task. +signal task_finished(data) + +## Boolean to determine if this request involves metadata only +var is_meta : bool + +## @enum Task +## @default -1 +## @setter set_action +## The kind of operation this [StorageTask] is keeping track of. +var action : int = -1 : set = set_action + +var ref # Should not be needed, damnit + +## @default PackedByteArray() +## Data that the tracked task will/has returned. +var data = PackedByteArray() # data can be of any type. + +## @default 0.0 +## The percentage of data that has been received. +var progress : float = 0.0 + +## @default -1 +## @enum HTTPRequest.Result +## The resulting status of the task. Anyting other than [constant HTTPRequest.RESULT_SUCCESS] means an error has occured. +var result : int = -1 + +## @default false +## Whether the task is finished processing. +var finished : bool = false + +## @default PackedStringArray() +## The returned HTTP response headers. +var response_headers := PackedStringArray() + +## @default 0 +## @enum HTTPClient.ResponseCode +## The returned HTTP response code. +var response_code : int = 0 + +var _method : int = -1 +var _url : String = "" +var _headers : PackedStringArray = PackedStringArray() + +func set_action(value : int) -> void: + action = value + match action: + Task.TASK_UPLOAD: + _method = HTTPClient.METHOD_POST + Task.TASK_UPLOAD_META: + _method = HTTPClient.METHOD_PATCH + Task.TASK_DELETE: + _method = HTTPClient.METHOD_DELETE + _: + _method = HTTPClient.METHOD_GET diff --git a/addons/godot-firebase/storage/storage_task.gd.uid b/addons/godot-firebase/storage/storage_task.gd.uid new file mode 100644 index 0000000..cdbadc4 --- /dev/null +++ b/addons/godot-firebase/storage/storage_task.gd.uid @@ -0,0 +1 @@ +uid://bfw61qsheaqjr diff --git a/addons/http-sse-client/HTTPSSEClient.gd b/addons/http-sse-client/HTTPSSEClient.gd new file mode 100644 index 0000000..2a01706 --- /dev/null +++ b/addons/http-sse-client/HTTPSSEClient.gd @@ -0,0 +1,129 @@ +@tool +extends Node + +signal new_sse_event(headers, event, data) +signal connected +signal connection_error(error) + +const event_tag = "event:" +const data_tag = "data:" +const continue_internal = "continue_internal" + +var httpclient = HTTPClient.new() +var is_connected = false + +var domain +var url_after_domain +var port +var trusted_chain +var common_name_override +var told_to_connect = false +var connection_in_progress = false +var is_requested = false +var response_body = PackedByteArray() + +func connect_to_host(domain : String, url_after_domain : String, port : int = -1, trusted_chain : X509Certificate = null, common_name_override : String = ""): + process_mode = Node.PROCESS_MODE_INHERIT + self.domain = domain + self.url_after_domain = url_after_domain + self.port = port + self.trusted_chain = trusted_chain + self.common_name_override = common_name_override + told_to_connect = true + +func attempt_to_connect(): + var tls_options = TLSOptions.client(trusted_chain, common_name_override) + var err = httpclient.connect_to_host(domain, port, tls_options) + if err == OK: + connected.emit() + is_connected = true + else: + connection_error.emit(str(err)) + +func attempt_to_request(httpclient_status): + if httpclient_status == HTTPClient.STATUS_CONNECTING or httpclient_status == HTTPClient.STATUS_RESOLVING: + return + + if httpclient_status == HTTPClient.STATUS_CONNECTED: + var err = httpclient.request(HTTPClient.METHOD_POST, url_after_domain, ["Accept: text/event-stream"]) + if err == OK: + is_requested = true + +func _parse_response_body(headers): + var body = response_body.get_string_from_utf8() + if body: + var event_data = get_event_data(body) + if event_data.event != "keep-alive" and event_data.event != continue_internal: + var result = Utilities.get_json_data(event_data.data) + if result != null: + var parsed_text = result + if response_body.size() > 0: # stop here if the value doesn't parse + response_body.resize(0) + new_sse_event.emit(headers, event_data.event, result) + else: + if event_data.event != continue_internal: + response_body.resize(0) + +func _process(delta): + if !told_to_connect: + return + + if !is_connected: + if !connection_in_progress: + attempt_to_connect() + connection_in_progress = true + return + + httpclient.poll() + var httpclient_status = httpclient.get_status() + if !is_requested: + attempt_to_request(httpclient_status) + return + + if httpclient.has_response() or httpclient_status == HTTPClient.STATUS_BODY: + var headers = httpclient.get_response_headers_as_dictionary() + + if httpclient_status == HTTPClient.STATUS_BODY: + httpclient.poll() + var chunk = httpclient.read_response_body_chunk() + if(chunk.size() == 0): + return + else: + response_body = response_body + chunk + + _parse_response_body(headers) + + elif Firebase.emulating and Firebase._config.workarounds.database_connection_closed_issue: + # Emulation does not send the close connection header currently, so we need to manually read the response body + # see issue https://github.com/firebase/firebase-tools/issues/3329 in firebase-tools + # also comment https://github.com/GodotNuts/GodotFirebase/issues/154#issuecomment-831377763 which explains the issue + while httpclient.connection.get_available_bytes(): + var data = httpclient.connection.get_partial_data(1) + if data[0] == OK: + response_body.append_array(data[1]) + if response_body.size() > 0: + _parse_response_body(headers) + +func get_event_data(body : String): + var result = {} + var event_idx = body.find(event_tag) + if event_idx == -1: + result["event"] = continue_internal + return result + assert(event_idx != -1) + var data_idx = body.find(data_tag, event_idx + event_tag.length()) + assert(data_idx != -1) + var event = body.substr(event_idx, data_idx) + var event_value = event.replace(event_tag, "").strip_edges() + assert(event_value) + assert(event_value.length() > 0) + result["event"] = event_value + var data = body.right(body.length() - (data_idx + data_tag.length())).strip_edges() + assert(data) + assert(data.length() > 0) + result["data"] = data + return result + +func _exit_tree(): + if httpclient: + httpclient.close() diff --git a/addons/http-sse-client/HTTPSSEClient.gd.uid b/addons/http-sse-client/HTTPSSEClient.gd.uid new file mode 100644 index 0000000..5003b58 --- /dev/null +++ b/addons/http-sse-client/HTTPSSEClient.gd.uid @@ -0,0 +1 @@ +uid://cdxp41ctxh5eb diff --git a/addons/http-sse-client/HTTPSSEClient.tscn b/addons/http-sse-client/HTTPSSEClient.tscn new file mode 100644 index 0000000..9bbd1c8 --- /dev/null +++ b/addons/http-sse-client/HTTPSSEClient.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://addons/http-sse-client/HTTPSSEClient.gd" type="Script" id=1] + +[node name="HTTPSSEClient" type="Node"] +script = ExtResource( 1 ) diff --git a/addons/http-sse-client/LICENSE b/addons/http-sse-client/LICENSE new file mode 100644 index 0000000..9354ed8 --- /dev/null +++ b/addons/http-sse-client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Kyle Szklenski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/addons/http-sse-client/README.md b/addons/http-sse-client/README.md new file mode 100644 index 0000000..a800fbb --- /dev/null +++ b/addons/http-sse-client/README.md @@ -0,0 +1,16 @@ +# HTTPSSEClient + +This is an implementation of the server-sent events/event-source protocol (https://www.w3.org/TR/eventsource/) in GDScript for the Godot game engine. + +To use this, simply download this project and place it into the `res://addons/HTTPSSEClient/` folder in your project; then you can just turn it on. + +I've included Demo.tscn and Demo.gd to show the usage of this plugin, and here's a summary: + +1) Download and place into the proper folder as the above suggests +2) Switch the new plugin, found in Project Settings -> Plugins, to active +3) Instantiate a new HTTPSSEClient node in your scene tree somewhere +4) Click on the script icon for the newly-created node +5) Enter in any connection information necessary to connect to your SSE-supported server; for demonstration purposes, I use Firebase, and in the config dictionary, I just add the entire config I get back from adding a new Android app to any Firebase project (it'll give you back the google-services.json file, copy/paste it into config and change the url in the script to firebase_url and you're set for this) +6) If you're using Firebase, you need a sub_url value that is something like "/your_demo_list.json?auth=" and then the value of either your Firebase ID token, or your database secret. It's not clear how long database secrets will remain functional as they're already deprecated, but it is supported for the time being due to backward compatibility issues. + +When using my GDFirebase plugin, all of the above is handled for you automatically, so you will only need to use the information provided by that plugin. diff --git a/addons/http-sse-client/httpsseclient_plugin.gd b/addons/http-sse-client/httpsseclient_plugin.gd new file mode 100644 index 0000000..87303c8 --- /dev/null +++ b/addons/http-sse-client/httpsseclient_plugin.gd @@ -0,0 +1,8 @@ +@tool +extends EditorPlugin + +func _enter_tree(): + add_custom_type("HTTPSSEClient", "Node", preload("HTTPSSEClient.gd"), preload("icon.png")) + +func _exit_tree(): + remove_custom_type("HTTPSSEClient") diff --git a/addons/http-sse-client/httpsseclient_plugin.gd.uid b/addons/http-sse-client/httpsseclient_plugin.gd.uid new file mode 100644 index 0000000..66dfd1d --- /dev/null +++ b/addons/http-sse-client/httpsseclient_plugin.gd.uid @@ -0,0 +1 @@ +uid://l8ynb8chj2cg diff --git a/addons/http-sse-client/icon.png b/addons/http-sse-client/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..eed56b9130dfbd1d657e5e880d4b0a648b6f1d2a GIT binary patch literal 137 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!93?!50ihlx9oB=)|u0Z<#|Nrr4pPUs_}>f+Yrc5>%;%_`oj39K%ESpu6{1-oD!M + + + + AvailableLibraries + + + LibraryIdentifier + ios-arm64_armv7 + LibraryPath + godot_svc-device.release_debug.a + SupportedArchitectures + + arm64 + armv7 + + SupportedPlatform + ios + + + LibraryIdentifier + ios-arm64_x86_64-simulator + LibraryPath + godot_svc-simulator.release_debug.a + SupportedArchitectures + + arm64 + x86_64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + diff --git a/ios_plugins/godot_svc/bin 3.x/godot_svc.debug.xcframework/ios-arm64_armv7/godot_svc-device.release_debug.a b/ios_plugins/godot_svc/bin 3.x/godot_svc.debug.xcframework/ios-arm64_armv7/godot_svc-device.release_debug.a new file mode 100644 index 0000000000000000000000000000000000000000..27697acbc07c36228813ee505a8c2c3f503eac81 GIT binary patch literal 82832 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�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 z%0DT5e)PafkHz07@vB9`V7Zn3vHU$Kd@r8w(ba(O_N2Qt75m?#48#5n>bKROqY{2V z8qN&x)3ajrYgqUvWuftU1c&};{&{6wZjpuLw=l{i<#R;%s$^{SYo3H3#G4_yFY;e{ zOn*@LeJbNE%d7ZF%2)V#DflOa|2p_?Pb__FMgK{)upaYNNBUcY?^O?`ENiU_AG5Dl z_&vf;mHv~$?@nP~ALht(Uj9pxe*?l#l|IQn%eh~N=JB`r1-_gf88LEaY6aQ@d10dH*_+S$LG5B=~ zU$<`^o}$!$8oyrQOE*TAa^$m4oAAl_OTaIBNv9u|agO0{g6FXCr5eTAzs1IvW){bP z2>c-lui8Iju&3V^UZ#Ih8yNpF8y}Z(j{MW`^!!TOFWqQVhWRxEy}|Ht&1e6h9QJD! ztNmJA(;fhydP;yjLt0L{BN5j8I`B^lKUqF*1AkEXsvR)2Eu9aOe?sd|7XBdkM}+S+ zG26l)xA9dwaOh8`(%+@lp9_A)DEt!e1Hw;ce;xQ8!dLCs5&t&uPYPd}t4W2*--m5{ zui@CzchJUnrjO-#9DM6loj&P~RsIH9j?#Zqj^BY97CTbOvAhc*XY7sE+aT+ON7-8i z{-Ee_@)=JA{3GLt=OM@)PN9c((7EoaC|5fTCw>OfR+iasSOOv!zntfFy4Cy#wCBU3 z5GlvL!=;(trI~q4GkwLGMa9^FIC1-g9T^X#e>Ckws55j+@Z-Yh>K|)8YMunz(-7ME7s%K#Yg|R`t&{oAV|* z&#O-PlFSZQEF#8(CpFgaF&&QcDLT&!v2f^jN|3{<-=Tau@*?jST3*e6D1S)gQN9$G z>{8n}3PE^Y(DIxQ(Rscd)35iTYMYqvb&#)rQOm3O59L1}(?6d2!~DP#J*z_c@fjxG zJL2L+{VC4O;}+PA?W*=LeeAb_ztr(_{zT{55DRDThiPv%+WD}^o0*PhSxS48))U5i z6Z8zoxbQ_r3g?*~(^Hb^jmy{Vknj7ow)1r@Zh78(MSJM+f+BI$sm><8_3qmvMsgDLT&&W8v)iaTo4SIs?d3-Dg>n}J(^>wt{!S|IIN4%`fS3GfbJ z0g&-}flSv0z&k-_0zVH-2Qu6s=CzFfD3I~@3-$tOUx(0P!E3}n0DLd_Gl7gJ3rKq= z0IBylCPFHofhwPYDxZP%%xCfchWLM3{Oy_dNjwZ>xNi%6jo`V!`{4i9R2}~tKriTC z;8nnSAoZ;Rehydy3?LmB37swUt5bCN-vAlkA)v}vp+6<`hlIXO=o+D~7TOQoj_?bB zOwVi}^Y;QE^XVM%&k+A%c+&p}{@n_@o&YuizX|L`xUT~7ci!iP{-n^2z;5t22)zcV z>J5(c9YE@722#&z!9pOzzf2K)eUE3i%Yb->Spz7hBlU?q_8Uk&^R%UbCNZnUf& z`5I@w8`yyIm;t2xxj@QK1XBJCZiFcR9~x)=21xm1K*~P{q&!~?Q~oF*5Tq?Qa|B5Ho(6V--V3CjJwOaaGrt66JiS2L+XF<` zJ^vn|Hv#_v^3{UZ0~vl55MA=jtAPw30J2`p5&rqWZ1_(D{vG@$0zV0S9S;s@_p3m3 zp);QaGT!e4nLpnHQqRLemk9nmUGskgB!8dKytmi^`etAu@H$`_^zxgQn?NrGGN0cA zr2c#$!)FWq9Of7d&pFEFz(;}CApBQ>4F3fn!`}m>otuFFfb`c3-XOSKun_q7;O7F- zwaq+B`}_IU{mTe{%B}e)fhfwEzXsBuuV5MO$3SG&OuQgg{@(>63uf*UdJhnC{x1QM zgqaTkp?c;$KxH3L*(Z2|AnzmS&pENO52)`sKUeE1 z`-I*DRQ3UteL&jR0i=EW9t!Pf608ef?h$!!|;rU$ashVh5f(Dpa+ON$h3gh0f%A#`++Bc*8_)u>wrgr zp8*~Lt_L0lGM)k8Dquelbxf7VPT(%k9|!gUKMd>zq7G;F06T&BSxbQ(!25xlfgc36 z06zq*2i^#*28MuZfj0mvfbh!<0zU=}0B-^o0k;8tz~#U^;LSiU@D^YW5Mfk#T@Cbr zt_1>AeP-b!3nmPrkrM|5`viM|Zus{A`w*@}{5Ok#i}+WI|61{{5dVPqdj&1QVJM|u z_M_B02xRtJv{9DAoTKw0Fe}(u5#NR8JBm6AkTjDD0r4*qf1mh!#Xm>cf%0sJ1O*B zOj;OUzvw$8`i6x+L1?aZqU#WS9-*J4J;*@wK6Ra>r$^HCio};C^1q@!$eZ`8_ZoTl z(S1_*=KX4}(B^&Wt<(p54~xB%&jpS2ct!tiu{TfX=gD_j=_fHMr27HwM_lIpYnjOV zL_S15^bd&sABg=q!e1%j&HLFmMSs?XTL0&SZ{Ck`;A4E|edq>Bk9j{?&isRYJ_-Mt z$eZ_*lZ7_#8^29^pwGNttdaNw5PUm@~?qTjqP+$H+W`@QRh zHt+L3FX3Ax{F|cRyw9t^Aee~>h50TL-yf6imHeA8{2`%BXzw(9lX8yEzY~z3h2L6E zAmAoLp0xQc(sbA}%Qaw`_pZN0{UL4ML;f7~PSMayR|MUPWRymx)vgqQd#O?aUX(SC$C?~zxcJ(KpT5yP_n33YdttHa6? z`e(xDWP^_PMU)@Jq??Y-inMv}{biM2Jexthbdyl_>brXgM|U^EQN9C{M7powN7}si zpNsK|bRY7V?hXmxhsg_FBmAjv5O&gyFh8Md@G?z*R;3s1U*fw^=m81;k3xGzf3eVm zBL7PhpU`=VMt({D{SN-heks45LT3qmkCeB0kIef*%A5Djygwyfje1JAPH5J9x^f9` z-oqb--IPBm{Q1J~ko@mP81e^%{-Mxj9mELy6<^Z(hDz^%wO8o*DnF4v3IBTuUoG?* z^ar!1bXXm8z<~QD^BZ)Q(0h>gvrtF+W^4YxQ+{fPwO{y$C=dFJLVt$xs7vNMRzFqx z2dq5de?{pZu=+*ccF}*-tM&hm^&_*xIwJBnk`LN^$0~>Q7xXj2e}w6S%+O^ze1)D5 zX#)Sx2On7)bw7k{cAEFMcKXu}dV@pXH_^WA^7lLFeB`U0&vs;|zk>3%(-|l)JN*N9 zJbj@f{Ax#fIvwHXIp_^2JG=g3hrU}Jbb-UZsR(Qj|3l=poxar}e;WOYo&Oz&e%=S$ z`G0WGyx+F-J&y2Sb)@Gvj`I4TqrA>{l=o9mZr6XWL*EWZ{0$EM7dq0{8P*xAlwbB#(9i@ed%3nMc#N%aqx{ISl5{N5%#1XX^$=s%ujvPK!s}~*Z4t2 z^9_H|P}m+>UnG7KZ;3gW!G117)H9m0sE-80*&uQzitRXusAn`-)W`l6+{AD8Mtzdk zf~0I;%-44Ci23?&?6dZXKGE|kQpA@0U>D;XYbN73eju6=V;$!kc{o@8GeYl_O?)M{cWN3H4Tj__GmAE zIgdV)Gho&?l-?S5=FyrJ`S_$WcLm^R8}-%c<|g~eLrRt8XZ}L&CkSoC0XX>HgfeOmexn8ojDg_r|&oN4tVl2a(tttoqKl z{gjeu=!K3$eAH)YTk#prhNj|n9HfMgnb)8~=-npQ)ik!_+s4sNFt(F8bkC9-DqB@q zRlI6hX$YN6aCJ##s$;_bl0!Q}_JhdQtWJ6snX{)$AtuRHiDbTFsuFH(MQ?zEsCd=f ziVv*gET{(j@)xN?zJmI@?^XIU&)4c>DAt7AaHxr*%%y;X}**W3cW1q6u)!g=Rdq-84utgC&F3i-so9$^>pRINBZ4l@%Gdl})R zFi7f;UY9!0A z6CGrmTe#)0&5`L1GCPy*KFL9Ms!XCf)d7f+)|#faM$GWnHS+Wb-Z!+FbF{`#WFMwu z1K-;`nNT_l9SUW|2YuJ=ufa|sfIg^Z2 z7=1>Y>DZQ6Y}!~#qH46Gvqn3VEA9laSZ1jLi4Mu}XXlMxXQXVB9DAwG%e;EoGTR{* zjp54|+pA_`D_Kxr1d_G;1&gf&g+&Qo1x2HD9ScV1<|k2iMM4buSBw@zqWq$<5^V&M zCK_9VovF@WIGOHI4%FQoG z(3YRzQDMOETKVlCQZPfAeY`aM8 z0l;54?%S3AnImpg#b`sR%FWP*&6`6w+O@5@3AfOgpKkIcl%_ASuhd>~U8MGk>lqVO zT@IXnqdrF4i|5`~(<9-WztPV(+9rCo zF)Um*p5NnN_C*iDah?ZL zFZu>W$*|bx!~0Z+Y9%4*jUIyI)EC72DY^;`)=^Y6rnd+Bq)G}$gRMN&cO{(XIdPpX z9hN01mnZMhg-obBqEF>tvh#Gf9+=M95`aEcUtpiSr%6^GDbLgCcjyZ`>_b!zeWRbJ zW8SM*(@3}qvF|ACV|{^(E8?-v)A6#g!wqS$_KH3)GL-2>^{y9|f_LJ-HW)9OZ0AgJ zT}du@^>}a3Gd|fYOc|e#YyM~$+hMYL1MgAYd~m(^QODoT(@DsOzn!P^xASzI#~H}S zZ`IK-Y(6{{;cw^ZsCA9@MbpF?!C#1eH_uu|aLAJAG?*GE~>6mUa zKfX-n$;E=#2)<9SPVjcYdjxk2I-i-EJbe%sdGa%UL^JQT`;B(Zc?joq&q;h{9%;fH z6Myapv%6oVkne#I=ZyT>-P^Ux9`>(so#Z=n32LitKF;rCnfo!bp8NNdwS6b6pP2~9 z{R4DP{-E$>Ir5m_tLPur`{JYDtGJYh-N3!g?^RfT)WMWJu*)-D^o;X+6$8%jBY)Bg z#|Y1TaTsFMzPLG9-_LzCbf2pL*#JQ2t0O>6LTA zwC}HZ6oI(E!#o%IG-(u^c@9$t9{E|wJUXriCO->5y6;mTuDR}+?xR9`g{~4hPv}QP zUykTwyJvWx@P8xnMMhrefY6_&Jp4>QjPK>D^qBtmc99Q?{2Gxr{qjj84>`Iy!Z-ag z_w{jqhI!7@3mMXTMPG*SbxpFYe_?ps`kUt@Pe7jgGg@Svd`W2YoCm>K9#I#79d`P1 z2Y;=DX8GFXZw6tfnJ;#Fql5o|gYIz9s~vR25ug3(FV=g+R;{Ur!pRr3#hw=O?nL=Smy{?<7JaB3O!nr?US^4}8k4-Wt5*xN5{v8Yy!45+sbqRa zeNi)eNlj~Q{d*_#eT~l>bNP!F7G6=fuwWrP7y9zS(CmDlZ&4xM;S>}tF3{4|lS82e zmG56!Qo0;{h;iHp>jlC6K1&73BmGfe76!#13UV$(nsXasi{PgO`Mr4hKPfmFx=Ak) zyixEj!NY)mf?EYYF8F1^{es-< zO8I{i{FC5Zj4|{t7i<^&lHl`#6EL<=u1K(1@BzWE3jRoN7RE27SFlO2OYkAVenEcA zgz`TVd_yn;;~M>Q1PcXu)*1ab3Vu%T`+_T_JR1eiz(26vvVI}x#Y7bElM5EMhBq{{ zMZ&GNg^l`q-)c#E!4>UIqe(~Nq5#n*HYwP zo@`r8jGbZ)XG|dVrlqLNm>Vpx5G5w8CN8prCFbi`T5S&zzs4)3DRFlRYK0?rqO~g; zQ?x?Gd`zg(Hw(qBi1FnoUxW_39dhGeFF(%hR--JzQMD{C{#ZjAr@^)Wrp|Oi14Dx~cp{KFr4H?CYjBHrKT`hGVOyFxUR$Z{M0Cj|?!4<}*9%FfY9L z!ExPG5$P0eNc@LH<`+U^%E;%1eAcJjc-KumFY?rfX}ZNZJD#KQpU(DMGo>>+)vI?esCZy^pn?De;4t)b|Vcv=eh!?zTvX=nXmzuRag(* zo@=3>`dnB2gy^z5)||P!7<#_93%aw-HTA_#1+uJzU?PX-KLmvS`8`17eK~G3i2fEJ z@^pT^U^Ni+ets1Yr87SWqKIIjgB>_mL0!$MC1Sn=}j> zR204uDSRV87ykS%iE+Fnz0Kw^%NG7C@VANcTViqad0^W4)ee4{gTKtd|GtCf_pW8LtLeapCNQEAhLhNf`xiK=~bL*mTM%m51tTAJG;YUd{w*xnTmVG~sy zjz_DI=>sjzzXB(s@kBLjy-dP3W-PA6b`A(NH>tfn_V}aP8bb@v5!v?kQNL|Bmpu@) zjBm@8$%UG_I_#?u8?apk+je3$m0FFFjV%T0e?AuR(hfXrqNmSF^&F^hVe;odm**GA zb0gdMV;gv!;||`5SoLT~mKQ%bHSXZ~MJkt}cSep7FH|25woWB*0hEBd@;dW?Y=yRN9Stv5}&{@0uvcSb@CK%d=7HblKd zU&{5rMSLL$SFgbufT8FHtsv|}|I2YOnSCkO|MrPK^vTNMaJD#?!oHO2fBQw>plAs| zAMM)<``FhzouoJA`rlr!4pAWiy_aZxS=Z>XK7~G>Q_RtgG{1VW)`(JHQUeYa2anYz&reaFh`v5fSmBO}eO5UpMcOMa z`D3leJ}K>$-@T^Wj2EO#Z~aR3opa9VOjn`!W6is(-)C7TQCDaL=V}#|cn%Nr)>zg6 zJfJVSPJ2DB`SauVCHycXyluxcd+}p@e_N-WkPm-br~Ti&PJ1|XVD}r>yKsM{?+tOL z${zGBJr?fIh%O+i?#?r5GxrdAuQ(e255jkbdozXqa0-8XCveYTT8@fyFm1Zvc?wPZ zw8z-^lH7yZ<9{hFJ1S3m2Gh>p^HSP*d*@^Y%=VYuFa`V;r$?vAA^H+Ritw{|?H*g?nVTxdg}>Z(f58(`?trv}#(X zEk?_;0?GIPi)kL&>iW?e*QX&5(^1wLyOIAr_&qpOtDm138qYpd9eoky^b&YpDF@VD z{oH1H;M8kwYhV2w>qnEUj&nS&m-}aBShp9ut*RmLCY^2l2+siicPj{o)fhrBULQ_*`j~YVGrJaKghb0)IRVM>^o}|`@s8) z?Gt;>++B*iEJ1x-wi|h?o;9(bdL3~*kNoL$^_vrF>l z1<4=O=?Aeck#+k)tjo++@gT3Ujx8ac`yA!)w&JPfbG7VfS*Z4-{7iqP+tJ7q7M|dKkazOA%Mc$jClrm#v?+>jp2|-apVr%F_na;|Hmy^FvdIjb8ds zLO);TwjMp#WvTIt`b-$pFQTvN$%DTaGJ|OzTfZOc*VOYxrWNhd1)L4UytAhkxX!J{ z57h>LKQc0%{am`Ezl)_=mlxAw^3MJ&lxZ^Q0Y+`E#wWqic9reb>(W%YQKxzSNqy#d z>VJn0#J@9{Ex7OOsdon!AJ(^D>?_nS4}A*ORxdVTbpCd%vE#@Sd`6vtK5@cs)@7#4 zkwOz#&L%kM_Nt$VENnoFveN`z{+8??|qXp0;d|%tVcbQ z0;f8W4$tEuHNN4UP*p-+&1GI`TVKSr(H||Nw6XJFw=dlXnEuP22hltkr!Llg_Aka( z&^YEQaBB8!^gY(B==+e@P}hx&vAYk)m2f<@w->vN@uyDo8>&CN%JQ5Tn*R9<*}q+k z{_!en(utQrTdql$FrQUkCwUJPeQ#5XJY1&pFcz1TKl0S%FWVyXSf$V4c=80{RG4wJ z7c&Pp?~YQH!?^3D#2qceaoAwW!afH5LHZ>R%CX*MJ!?2-2PeV)+sBX^93cf|MTZqr-o4OotNPHt+d_n!x)vl=S7(>Q?Cc@ z;`4LTcJmr@8x@CYD`+Ey7ict^SLf&KL;hw5{XxA=`aJt#NIk2^h(#W(nMdU!qF^gwp3 z^mJZ=G@$>5!ac1UbLH6m#~W!A_Pm*vv1d3feb1@1G&An1x*0@0 z?_^m@8KGZET1K~`j3`@=GKwHC&A9sqj9ZUox`+3Fa8|zueZ+WmQFa&F(j7=gJ<4QO zDeh}rRu%hm(-w3;ujiDepXWSGw-<9?%Q;i*{$Mz?boU=O6i4q9@R%}NJ!_DDjP;9j zAKKPf`E1f?!i=Y`nfq4qi`xUll=Gnby$M;Hq%JyK!Ookm&>4>c> zV1LZ=3&?!Ql&yt+kMfi90;hI^HyeHL7l3l#kj*(d`NT^wPuYWenrqNjR-j*6F)~uv zi2m%iX=m*zKlA@OA9J0h`m^nCjTBxC*+~+1B+ZR>nTB?mj&_-WdEErefhX=}x^%gt z&qqH#G~GJ&dGxg}o@MQO6K(Ykv{l;enTPh_O1tg*=wIt$?-25#b3tJL&;)D$a@6N8 z^aqn%lU7{iI{&ucLZ(w>y5^K#?14Y!u-*apv~KhPu01bIux`H;Wi&Kt_=?Vp0;ev> z2<+db+CyOfi)k0A`q<&J7JcfArMst3?Ch?`TxAIS3(D>se!*?sz7k=-0NTte&Vg>G z!*T`A47#l|rzTtbz3`)r*k^WoF66t=uf2?Xy@Yc-*W}wi0y`&TOm)3;&u4HV`Sg)< zt^Mj=c9||)U4N(V@up#&$T;p>ht5Txh(5~L#JZxMk*K*+;MB#)->Y1n6JNmCb>{A? zQ3lIV7NsbY67&~%u8`(d^K8_sr1ym1yCrr{_%iHGCHKWyF}Vv+4yZ4bRrl&lMw0Xm3}f0l<=NMo=i6*kIJ4(ev~QI9WX6w|YGe14jQ2j25uSan`4xW37UltM8qfT3 z9QQ${FE#xr(`0NhWzD$8TQ3I35offn{3ne;W<2`;;~11!Up;%!_wVOi-#j}QZQS|q z9A{!}%(I8%2>QBx&atBt{`BiixAvcnwu5nRKgS4;FJ|nZ%p|WKJ1B=&v)Re|8uk_J zXH8q=IEl8CwEtX}5$hLUL!apGW8bC677zL?ciFn(K-@iZ^0sf{V!Mx}H@Pg^v0eVm z-H&02w{{kzztz0Q)jd%v{}^UmDTUE9(GkAPb|ducI7Xyyq4w7dnU~aZpKS? zSKe{AHRQrS(U)V*v#fbD_PMNJ5t^6F)911}T}Tt+nqlSQJ8GVXT~2 z(#sT0!3)0EUA@t8bFF}71w8l?NH7Ni)!8VMDTC-7T~lDycGoVk!80Sh-16%5PPd>C zzbSihU+kW;E!`S$Z)Z-y)=4*cpu7$XDKwp(oi*e_<{WTY^Snq|`7=-mxpBvvlU`)a zK-tsj($LrA`Tu|6`#oIqY4P9n4Uzh5tCkm4s_$|CIm)Nr`%T*}i*DkGT`kzS(OTBrRM)_dy?f`F z!>hcr{oYWhslBmrj<=!B8)* zy<4_;ZC>y7Mm5$pw>L#bGvUfBSC#30=&w;W{}Xgz!22(C|5M_N?kThEd%kR&tnA*t z(X*)9#@?=n!Gx|w{H@ouYy!VS2BK8_h=eHDatYxFWZmI2DfDlbHRaiwpD^t83IA}4 z@b$6|F+D~2AUi*}LJbM_Z4tgeLj2pt;BOZ`CM$Fa`pvnvsqEV}SsQjtLqhm!E`WrS z`k7!~i}0stu>}4W;RiK8LH|MF=cNdLQuu`_(w93`2TV`FH{Uy!Y)Y`ths{28m^9HP z*!POwriJNQa{gxA6w#%!?-4xgqDz&32ZWC)C0#=N{BAJaO&SvH-zNN2_5TRIH%zxz zLxTR}{O~YbPKxxM=F=#+RO$0wp!uotw@COES}Y;{9^t1--z&m@Bt`gMEWV-Ryag^H zf2PPj4&f)nZ_YVS#a|L1K0$xg82rX~enR});`s^s9}<45{%No9Q{{jE7<{Rh`0n5u z%eDC;SJsqSDleHI$MqNeMkH>P!MHtAec55*Nx!0a{FjSg6@I&fFZ~`iY-D;r=lZPs zV`)285bD{2Ux&zv{qWI_{D<(X9)*7Z{6Gpj82%W3Ug4W`!b>^wPxDIn(tjx)|1lji z&?W}o(gH!lNs^8d$n}d{o{*12`gig<#~^IRAdHB8~b?1CnkJy zVy|Z&?ZGccL|(@=ooAwWlxO1zajaYXH@>D;*mstALRQD8+l9T`LcLaWL7)% z9|ylv*v}|w$lqZU#Oxu9_GF{pt`+uSBY$xyvhZo;pF9Gi% z$R825%r`90W8$$kv%_UF)>J+krnKsO!xJ*AN5uDvzvo_~FtOaHpq*3;TjnR0XLVFQ zA)l8(-Y4u>`o$wz`XO%_wz20``M|EDpFe-vV3$ ztN?x;I8Xd@fwX4^koI`QpBs9apUFq*q1EOD}?ECLsE(^0gZM6+nh70bUOOfJXlkU;*eNjecx8Q1!{D(Vq_dDY_&} zvUM}_#$6WSv*Ki07i{MT_m$ok6ji5Tx7koEPL zM*s6b*4M*8*4G2TuLB#=$%5h2wg99 ziO@@ck3-%EWcqS}sy+bU1v_#y`m=@a5k5C>v)oSO`2)~@6!>}2&ujD_27UoFpI-p| z1HgZwT|nx6M5BMF_-_YNU#CWYBan8~i+`Q?-vng3tAI>r9+2ha5&HOK$p;|I;|P%H zItZk`13<>puhG8+NPQ6?^#uiUfz+EV^vOw@J`S7<|06>06MO{t1pIq}dx1Sb#+#zJSE4xx`@V!R3bqrf&`KTx#;Ak*^053CY?g+_k~@LBj50UrbUfV3wM_;X+`@QctlLy+IF1ntiPq6s`q{2cgGgeHRa zpT_e(>U#x9eaC^+HwdJ@qk_+C^gjcnJ|guU7Me(X{Kh8r?E+F?ACUU^4NmIYDado< zXkRCg`iRuGO=u$Z@r=6;_%~|w*8@KYeRaUyz?*=KuL{WcmH_tveZbiWH&3HK59kG* ztI_WT&H_C{qdy%;J5Nv0b`A@^uF-!I_%rZd0bYb~SW`yy9|!&`{0D(E;eSk{|0s}l z9}#>;qyI3F^`alhbQ78GeL@qNZhmk3yO4Vb$as5!Ga$E9qrV5p{N(dqm5)H?M+Xp7 zn)xk4*8`cZa={WH^(_Hn%2HmW(O(FpKA%SaJRs|TI*{_G@!T0xiSl8M{*%B%@E-y` z13U_3KJXi^m?D%P(da(_q+R=fET4yg)Y~C6zpGCFfpm@iK=S#WDEhAjeh(M`HUNu& z_dtK4M!ygE75L`?&x3!iM!y%x@Hs&0!#-R^^E>I(*9WWz_5$xkxSbmPc*(Bxbpo^D z&+m-`{VhPIqaMioTq_t9I1fmDGk{EIjz)jB@TUl-3l6(^lg= zP*pyl(SHEQ_Jh9PngoB0g+%{eAmiC3*eAFX$a=9I$ng&O_yfV3wUNPE0MRQ2*18qN6^<=LQ7 zCH&++j8A^$0M`Rihpc}9A`RA;fdK38fz`lkfwXT8umX4sFbKRE7y!anbq>V}pbzvl zz&v0X&=5Jf%Q@wG7w~L>%h8j8(uM(B}X;&Ec>;QfXxEc6OU<>fu zz=mmZZm;?L(FblW^=mGWu z0jfT8d_xvYs1^(edIc@PK{O)D4G8uLb_iAr1_Zr=mf#>7nF=r1C)goaEf^5=3R;33 zZ&Y}}KEV#bYQcb@SI`n1L|_$Ouurf8RC)goaEf^5=3IbHP zvnN_s7*jE#pZPB|k@fq<37SuQ59lL86B|H(O=#kkp!pi3>oplVx}!+r^8h2c(JfKy%(inph8dx6s5(K=%qwYy*9#(8P;D z^IVrO!V_^nl36A+@!g<{geGnVeSy%#cY?-#1r?sicJMOlSs40=xuA~_7X2CbIwR z5Sqw-bgj@t_NT~0rH^J@qfT|5dWWd9mW6St|9#IPdkw2cK4+{oaS=(ruE^vH|-((Ka%!5 z{tu`0^qJ5zVWNBI#NLS$ z-PkY3bRVAf4E}G= z>dBhu?mPS8vvGZH&$$!b{pTGxZ=(D7`9tSVbazhQJ{@|d@5TS;rys@tgVPV=|LN%+ zITPKzIS=9gz=a1doai39@D=>uHTRLZ6Wu*?cjEuCxr1}l)B4{1@Vj@t8y4lgl9!&= zamltzIxj((OAg|H|0M_TfA1yx@PFu%SMY!ElH>S)^pa!vzjJ=?d`Qkei2uX$Pvie1 zzP `+D(z(03gFI~HzR2yWr&!inyag|FlPfrSI`8Cv+tLKM06FVj)zsB6ZpWMj6` zFH8lEKEZtd@zp7y(Z`zaEq;>W!Oyu!YxsG#<_GW)fNnQC7sv;NevF+9=nA2~$j$}y zTA@30KtrEb^wplH`PIUoCHm`yewXNP5&BbgD9y=udKR zL;6Fa|8>R>x=!fpgx)GN&%z=9pM*Y2dq5u+`WB&I6ndkif4kV@6MN12pI=D&cM1PV zNngLvD}+8G^p_>PSs(P2)Zby@U&QpeP=1&s&}ESi+N}5an$Tu_(6zJ=^~bdLr)UrA zxcOe|-DPavbspao)ebKGL%x}oQF7j_OKfr%YX+$n z_+Bj&HU#Eqew*aS3`yT=v1hi>-)4TIPg4t*06C;l=gs#kr=rTS%Mrem6iZX|ukM_iv<+VX{Z}cWB$B_X@oRX(hc4Hqv3vsA#kP@kNo}hkT}M z6S`CAxhPA9H|srjqbx|9^`ZZUvY+MZu*`bWc9aeIX8md1LhH#vPv)J zj)<2I->3i|b5(?+`zgXvUkB`<`wD)f&HCYGl76#Z_znrr=h<|JQTM5D0Cv*-micL` zU*A#b#XXY5ccIXI68?um^Pw1BjnD@~{+ANpW})*G4SOa3u2Jz1Se+N>@FyXs@=xe{ zRQd-jv)*%&@Xh+r3naW(%I~|vH|uB1CA?V=I|93@->jdVFMP8exf^DXZ`K$8Q0Q%v zzc)(xZkP0)FZsJo=q|CxtY7}Ugs(=P(w&9&Giz#xg+1uX{fPMudb7|IAU6wj#3MA< z6U@r&ux3m9dX(~@Cky>1?3!h3zrUjVROFY0|CG`{V0nceqP(qtIFI#nT8C96`o2j% zXw&|$5`LlZJ#4SA(JTE=OS-PJIyHD^h4dp!qwZGNW~Xn1K0E!mgZ{8X-?^~IF8?Cp zx6?adi=F0sTRZ(M%G*w7q{Y)eKz+3HH#ziu#gU#}j_~CU`Uyw;pLOW_mV`f1Irz6Y_@8vtFP9_!b!b0!eOpnp?eumB zf3_pOZ=yZg<@tWkPP1RJ)3-b5^BnqiJNVBz+QV^2eZ_~nZ&)?X>xPjW1i@{7iIs+eyS!C2apo|booY};E{+Ze8C6>Hz#A&<5g zXm0ET4_%t0A1x>OvN+Nl(kC353^Rt+ZZd~iI?lI)#m#MDBcAVDR$tSqx4^1gFqa8SNy&=(&L@V)IE3+S$Hn zpja~+$1!lxj2iP8#n@TF<2}~f-a)AY3tQw2O&n>`7Advoc*QtRwp>?Xnlbyosxqkz zau^N${;U(;#yGGoF6pVxC+FddVh;MaO*je&^*$T!D<=1?~3QJ`q4 zHMO^eYU^(ct*>clj2{dx=kDQ<-buW^q4d@YdlOl+B7c!?XC>kFs;g{nLdS2~dV^AB z(y@@^Lug}rW2B)myeZs>6M^DNEk(Ejb(*U!T&NYHTie2QV=1s(T2L6m4rm;GgItX6 zJmy~Jwo+|ninAuiJk~L;*^DJESy7PR)EsG8-%wk_&D|m9%Q}<*EJ^HK;<{VM$(OW$ zbn{VpknH#rCkufQXMBRT!EC(8h9;MbYX++-b)Tl{ORURDb(o~R+NiUwWI#~CW1W!W zAXarWNozP#)6i7h9>M;2jF6~BddK^9HI40I+c{Xa(-idq>A9h@Rh3o6tCp39R+d%; zSC>?V626vTJ4zkhS_(Eo7pFcf+OA^F>ZDq9nNN|1+R}uA_2!z@s?8tz8Tj5de- z6z3zGab^)Fdz)%n(W#n3tq}FA)#oJ^wK@4HR}rz7QNAw(Nqz89sn!r{(SMDTX=Bxk z$7jbKcx{^++5Js|R9{gUlF_oVw7hg#)#^2&HKof-*Oa1VgVh*qUBS?G#cM)U@2@D0 z`s!x8s(5A0i+zC-)V?f^=zUav?AYr9$5@4|Q$;t$kF2&ADk%(2<_ z$&y%8{Qug!zR);|Fn)zr0!pZYQl+*hD#lvRyS+6X1>{*-I?87QvX!U zz}|1a`DVWPW^Q(Nc4zkcx+9a0eSOv4Q&Zil{Y>Z8bFT2lJ~qx9>qM;4M`U7XhN(78 zS_Uy4*}+SE5O;)_O!aIx`O@I=iBUX!92(_?htc!sM0aC??5RQhzPSwN^zLlnx<#t- zLSN8Yk?6o|Pu`x||8%k1(S<=p-5hk|*b3xRs}jWX_C32FV?%YQRvSDtGF1q0J~5)5 zU{^VlsgB@sR;6&L-CdbFrii{Yhs+P@lO*kq>J+h~FL+Ll3-q@F~YJZj; znLS-y)g!f`Bjb}*)n|$w#G-0kPA*T%Wlc#uw<7R zlv75*9JrKONKFK;UtOKKJuJQ5Q7s_TS5q=ru$u)@{w!cL|4`O z_ZQ*E)~i4u4sKI(7ZP*p+BpgqdJC64Gjpny;nJf+jE-&_4C92E} z*12AwgteJzf(hssLqpayB<|C)VfZnfrekuxzcuJmg`?GsEwn zi3nH$s|S(C$-dXmGruEaxTqn+?_=!{*Hz>t`gz`f^)!Ya4VihYFH)~7&<{&v-2U&= zFVWBQfXKr(6cw7xA1QQ#exWnqxZmEI$Xk^B+}Rh=O94V?kU<@ICzm7$^m zn`^YZS;)iEpA)SAgEMgq6;VIMb0SZ+Ph=|GOJIeh`i>4xO!)i?&x<^x%dqITfC6Nl z)AxTQ`W5DPoD4?ZcuYUAV)D}c3V$u~1}GGvCi*QxKk3hrDlg?%nBTuLoY9b35P8=i zk6j1ddstWs0{rq?(%F!B9WHc)91d25b_r&IRD$WaKe3Yt;xzgaFP91q2Fl{Wv?UzA z{)Fh%@G!Vp9F(!@SJ-KXRlmZkeub59h&AwCflDOxd8>T=tojvJekS8>WBh5v1hc?x;QQ=-FdsV^@fzBPcp11I zcoE2ai$L_7h`kT;zZAaD-Us<-g?|Q!M~8^L5Atd6gYsvAn4YKF`(Qn3?}Pf$-UpHP zK8T%ywD&=IK6@WbM|&Sc+WR10!$TGIxB_Ip%RtuilE!U|!e0=+&)x^~&kNsY?}Pj~ z;WuLMgY}$7dg@mb92V>p%nQ=qhpLw#?R}8`9es%Pxdg;>O~l>@;};N*XQ7C_55|9i zcx;jFo!6-BeLRNvbD;4)AY$)><;)?Tcv|pXAnW@kkn(1LO~}VS4OI3%$e$GcI1tZr z5qlrxKQH`#Ap1?9MrH4V{9VHD1hU^iN21r>2h-mt>9as|^+bCge1>epTLI{*zLN*g z?p|{za5JzB+yu-6?*_I2Hv+RjemA}Wh)-jby@fl0l!G`tsOs;=&jB9-eFk_B@FWng zTAF8o4*;iuTYxnno@|v}hzEhgpix%y01$t%`VN`tE1>TOvRumR0d57BfsX>IX9-Y$D=H+cER`)(SFA#6n_obEK!&?WgP+C0@B#9YZV`Hm(0QRhW_dWLT?B^1K$h1c^2S77r|^|M0i5>; zy-(y-g#M7}A=A9~3=nVAw@LlXz3v++zhClyB>B%mF2f^|-rQ%m3IC$-+ezcxw9jwU z2XW@T<_~Nyi~fv!lx5oQE#}9u9S6e&$^WF#FADvf&_7cjoDT~9J&^RI&})Q#Md&vq z{|TXQ6`FP<7+xift6@Ouf1dhU^d9mtpw)!mLcT@cOg=6;^B(e)=yyTne?=Mtt$D9V z$JREz3v#LdWl8@jko-^76`IK$AdTY};m<;6mOn50tP_3KOZs0>eei5NW{qYuPr-lq^%57t6VqZ_pAOE<`ow68gmD7H;mZMR!uf2P*! zzrGhUzOz!*JhB&3#e~}&6{R-!r7Y+=t8UBPx>o;h+{H%UKCpjwlNi_euWx^h?mOP~ z`v$hMsMd+^%V}uqNm{u}ZyWKJ+t5akv~ri)wc%gEUFmiTzqG$=Z$!Mb#&)zI T!g7||<7((d@atde<{ + + + + AvailableLibraries + + + LibraryIdentifier + ios-arm64_armv7 + LibraryPath + godot_svc-device.release.a + SupportedArchitectures + + arm64 + armv7 + + SupportedPlatform + ios + + + LibraryIdentifier + ios-arm64_x86_64-simulator + LibraryPath + godot_svc-simulator.release.a + SupportedArchitectures + + arm64 + x86_64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + diff --git a/ios_plugins/godot_svc/bin 3.x/godot_svc.release.xcframework/ios-arm64_armv7/godot_svc-device.release.a b/ios_plugins/godot_svc/bin 3.x/godot_svc.release.xcframework/ios-arm64_armv7/godot_svc-device.release.a new file mode 100644 index 0000000000000000000000000000000000000000..8e84c29ac89c1707f19d439905d1a3993246862d GIT binary patch literal 73680 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 literal 0 HcmV?d00001 diff --git a/ios_plugins/godot_svc/bin 3.x/godot_svc.release.xcframework/ios-arm64_x86_64-simulator/godot_svc-simulator.release.a b/ios_plugins/godot_svc/bin 3.x/godot_svc.release.xcframework/ios-arm64_x86_64-simulator/godot_svc-simulator.release.a new file mode 100644 index 0000000000000000000000000000000000000000..0398756ff4d0df560be81e894900e22fad41e55c GIT binary patch literal 73064 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 literal 0 HcmV?d00001 diff --git a/ios_plugins/godot_svc/src/godot_svc.gdip b/ios_plugins/godot_svc/src/godot_svc.gdip new file mode 100644 index 0000000..96aaebf --- /dev/null +++ b/ios_plugins/godot_svc/src/godot_svc.gdip @@ -0,0 +1,17 @@ +[config] +name="GodotSVC" +binary="godot_svc.xcframework" + +initialization="register_godot_svc_plugin" +deinitialization="unregister_godot_svc_plugin" + +[dependencies] +linked=[] +embedded=[] +system=[] + +capabilities=[] + +files=[] + +[plist] diff --git a/ios_plugins/godot_svc/src/godot_svc.h b/ios_plugins/godot_svc/src/godot_svc.h new file mode 100644 index 0000000..f0a5379 --- /dev/null +++ b/ios_plugins/godot_svc/src/godot_svc.h @@ -0,0 +1,30 @@ +/*************************************************************************/ +/* godot_svc.h */ +/*************************************************************************/ + +#ifndef GODOTSVC_H +#define GODOTSVC_H + +#include "core/version.h" +#if VERSION_MAJOR == 4 +#include "core/object/class_db.h" +#else +#include "core/object.h" +#endif + +class GodotSvc : public Object { + + GDCLASS(GodotSvc, Object); + + static GodotSvc *instance; + static void _bind_methods(); + +public: + static GodotSvc *get_singleton(); + void popup(String url); + void close(); + GodotSvc(); + ~GodotSvc(); +}; + +#endif diff --git a/ios_plugins/godot_svc/src/godot_svc.mm b/ios_plugins/godot_svc/src/godot_svc.mm new file mode 100644 index 0000000..f73416e --- /dev/null +++ b/ios_plugins/godot_svc/src/godot_svc.mm @@ -0,0 +1,41 @@ +/*************************************************************************/ +/* godot_svc.mm */ +/*************************************************************************/ +#include "godot_svc.h" + +#import "platform/iphone/app_delegate.h" +#import "platform/iphone/view_controller.h" +#import "godot_svc_delegate.mm" +#import + +GodotSvc *GodotSvc::instance = NULL; +GodotSvcDelegate *godot_svc_delegate = nil; +void GodotSvc::_bind_methods() { + ClassDB::bind_method(D_METHOD("popup"), &GodotSvc::popup); + ClassDB::bind_method(D_METHOD("close"), &GodotSvc::close); +} + +GodotSvc::GodotSvc() { + ERR_FAIL_COND(instance != NULL); + instance = this; + godot_svc_delegate = [[GodotSvcDelegate alloc] init]; +} + +void GodotSvc::popup(String url){ + NSString *nsURL = [[NSString alloc] initWithUTF8String:url.utf8().get_data()]; + [godot_svc_delegate loadSvc:nsURL]; +} + +void GodotSvc::close(){ + [godot_svc_delegate closeSvc]; +} + +GodotSvc *GodotSvc::get_singleton() { + return instance; +}; + +GodotSvc::~GodotSvc() { + if (godot_svc_delegate) { + godot_svc_delegate = nil; + } +} diff --git a/ios_plugins/godot_svc/src/godot_svc_delegate.mm b/ios_plugins/godot_svc/src/godot_svc_delegate.mm new file mode 100644 index 0000000..a8f7fde --- /dev/null +++ b/ios_plugins/godot_svc/src/godot_svc_delegate.mm @@ -0,0 +1,36 @@ +/*************************************************************************/ +/* godot_svc_delegate.mm */ +/*************************************************************************/ +#import "platform/iphone/view_controller.h" +#import +#import "WebKit/WebKit.h" +#import + +@interface GodotSvcDelegate : NSObject +- (void)loadSvc:(NSString *)url; +- (void)closeSvc; +@end + +@implementation GodotSvcDelegate +- (void)loadSvc:(NSString *)url { + // Parses String Into URL Request + NSURL *nsurl=[NSURL URLWithString:url]; + // Gets root controller to present the safari view controller + UIViewController *root_controller = [[UIApplication sharedApplication] delegate].window.rootViewController; + // add svc + SFSafariViewController *svc = [[SFSafariViewController alloc] initWithURL:nsurl]; + svc.delegate = (id) self; + [root_controller presentViewController:svc animated:YES completion:nil]; +} + +- (void)closeSvc { + UIViewController *root_controller = [[UIApplication sharedApplication] delegate].window.rootViewController; + [root_controller dismissViewControllerAnimated:true completion:nil]; + +} +- (void)safariViewControllerDidFinish:(SFSafariViewController *)controller { + UIViewController *root_controller = [[UIApplication sharedApplication] delegate].window.rootViewController; + [root_controller dismissViewControllerAnimated:true completion:nil]; +} +@end + diff --git a/ios_plugins/godot_svc/src/godot_svc_module.cpp b/ios_plugins/godot_svc/src/godot_svc_module.cpp new file mode 100644 index 0000000..4349f45 --- /dev/null +++ b/ios_plugins/godot_svc/src/godot_svc_module.cpp @@ -0,0 +1,23 @@ +/*************************************************************************/ +/* godot_svc_module.cpp */ +/*************************************************************************/ +#include "godot_svc_module.h" +#include "core/version.h" +#if VERSION_MAJOR == 4 +#include "core/config/engine.h" +#else +#include "core/engine.h" +#endif +#include "godot_svc.h" + +GodotSvc *godot_svc; +void register_godot_svc_plugin() { + godot_svc = memnew(GodotSvc); + Engine::get_singleton()->add_singleton(Engine::Singleton("GodotSvc", godot_svc)); +} + +void unregister_godot_svc_plugin() { + if (godot_svc) { + memdelete(godot_svc); + } +} \ No newline at end of file diff --git a/ios_plugins/godot_svc/src/godot_svc_module.h b/ios_plugins/godot_svc/src/godot_svc_module.h new file mode 100644 index 0000000..41a5ecd --- /dev/null +++ b/ios_plugins/godot_svc/src/godot_svc_module.h @@ -0,0 +1,6 @@ +/*************************************************************************/ +/* godot_svc_module.h */ +/*************************************************************************/ + +void register_godot_svc_plugin(); +void unregister_godot_svc_plugin(); diff --git a/project.godot b/project.godot index ca5f77a..82a7979 100644 --- a/project.godot +++ b/project.godot @@ -24,6 +24,8 @@ RigidbodyGeneric="*res://scripts/rigidbody_generic.gd" GameGlobals="*res://scripts/game_globals.gd" HelperFuncs="*res://scripts/HelperFuncs.gd" ColorSwatch="*res://scripts/ColorSwatch.gd" +Firebase="*res://addons/godot-firebase/firebase/firebase.tscn" +Leaderboard="*res://scripts/leaderboard.gd" [display] @@ -38,7 +40,7 @@ version_control/autoload_on_startup=true [editor_plugins] -enabled=PackedStringArray("res://addons/anthonyec.camera_preview/plugin.cfg", "res://addons/proton_scatter/plugin.cfg", "res://addons/terrain_3d/plugin.cfg") +enabled=PackedStringArray("res://addons/anthonyec.camera_preview/plugin.cfg", "res://addons/godot-firebase/plugin.cfg", "res://addons/proton_scatter/plugin.cfg", "res://addons/terrain_3d/plugin.cfg") [global_group] diff --git a/scripts/authentication.gd b/scripts/authentication.gd new file mode 100644 index 0000000..4b661b8 --- /dev/null +++ b/scripts/authentication.gd @@ -0,0 +1,51 @@ +extends Control + +@onready var email_edit: LineEdit = $VBoxContainer/email_edit +@onready var password_edit: LineEdit = $VBoxContainer/password_edit +@onready var state_label: Label = %StateLabel + +func _ready() -> void: + Firebase.Auth.login_succeeded.connect(on_login_succeeded) + Firebase.Auth.login_failed.connect(on_login_failed) + Firebase.Auth.signup_succeeded.connect(on_signup_succeeded) + Firebase.Auth.signup_failed.connect(on_signup_failed) + + if Firebase.Auth.check_auth_file(): + state_label.text = "Logged in" + +func _on_log_in_pressed() -> void: + var email = email_edit.text + var password = password_edit.text + Firebase.Auth.login_with_email_and_password(email,password) + + state_label.text = "Logging In" + + +func _on_sign_up_pressed() -> void: + var email = email_edit.text + var password = password_edit.text + Firebase.Auth.signup_with_email_and_password(email,password) + + state_label.text = "Signing Up" + +func on_login_succeeded(auth): + print(auth) + state_label.text = "Login Success!" + Firebase.Auth.save_auth(auth) + visible = false + +func on_signup_succeeded(auth): + print(auth) + state_label.text = "Signup Success!" + Firebase.Auth.save_auth(auth) + visible = false + +func on_login_failed(error_code,message): + print(error_code) + print(message) + state_label.text = "Login failed. Error: %s" % message + +func on_signup_failed(error_code,message): + print(error_code) + print(message) + state_label.text = "Signup failed. Error: %s" % message diff --git a/scripts/authentication.gd.uid b/scripts/authentication.gd.uid new file mode 100644 index 0000000..8979e81 --- /dev/null +++ b/scripts/authentication.gd.uid @@ -0,0 +1 @@ +uid://cbmnj1dfgfwp5 diff --git a/scripts/leaderboard.gd b/scripts/leaderboard.gd new file mode 100644 index 0000000..370c754 --- /dev/null +++ b/scripts/leaderboard.gd @@ -0,0 +1,27 @@ +extends Node + +var COLLECTION_ID = "leaderboard" + +func save_leaderboard_data(): + var auth = Firebase.Auth.auth + if auth.localid: + var collection: FirestoreCollection = Firebase.Firestore.collection(COLLECTION_ID) + var data : Dictionary = { + "user_name" : GameGlobals.user_names[GameGlobals.user_id], + "leaderboard" : GameGlobals.current_leaderboard_name, + "high_score" : GameGlobals.high_score, + "money" : GameGlobals.money, + "deposited_money" : GameGlobals.deposited_money + } + var task = await collection.get_doc(auth.localid) + if task: + await collection.update(FirestoreDocument.new(data)) + else: + await collection.add(auth.localid,data) + +func load_leaderboard_data(): + var auth = Firebase.Auth.auth + if auth.localid: + var collection: FirestoreCollection = Firebase.Firestore.collection(COLLECTION_ID) + var task = await collection.get_doc(auth.localid) + var result = task.get_value("high_score") diff --git a/scripts/leaderboard.gd.uid b/scripts/leaderboard.gd.uid new file mode 100644 index 0000000..1f749c2 --- /dev/null +++ b/scripts/leaderboard.gd.uid @@ -0,0 +1 @@ +uid://ccnfikfg8kph1 diff --git a/scripts/save_load.gd b/scripts/save_load.gd index d348d15..cda3186 100644 --- a/scripts/save_load.gd +++ b/scripts/save_load.gd @@ -47,6 +47,7 @@ func load_persistent_data(): print("No file found...") func save_user_data(): + Leaderboard.save_leaderboard_data() var user_save_path = str("user://user_",str(GameGlobals.user_id),"_",str(GameGlobals.all_user_leaderboards[GameGlobals.user_id][GameGlobals.last_leaderboard_id]),"_data.save") var file = FileAccess.open(user_save_path, FileAccess.WRITE) diff --git a/tube_top.tscn b/tube_top.tscn index 463f077..a6a82ac 100644 --- a/tube_top.tscn +++ b/tube_top.tscn @@ -235,7 +235,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.74512, 0) shape = SubResource("CylinderShape3D_o3r5e") [node name="Switch1" parent="." instance=ExtResource("3_cja26")] -transform = Transform3D(0.938131, 0, -0.346281, 0, 1, 0, 0.346281, 0, 0.938131, -1.50651, 1.486, 2.18003) +transform = Transform3D(1.4072, 0, -0.519421, 0, 1.5, 0, 0.519421, 0, 1.4072, -1.50651, 1.486, 2.18003) [node name="TUBELID1" type="MeshInstance3D" parent="."] transform = Transform3D(-4.37114e-08, 1, 0, -0.998931, -4.36647e-08, 0.0462197, 0.0462197, 2.02033e-09, 0.998931, 0, -0.190922, -2.99477) From 2403fe0f33d4061bc169520d7092c1c5375815d5 Mon Sep 17 00:00:00 2001 From: derek Date: Wed, 9 Apr 2025 11:47:41 -0500 Subject: [PATCH 2/4] leaderboards can now be updated --- scripts/leaderboard.gd | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/scripts/leaderboard.gd b/scripts/leaderboard.gd index 370c754..a4eae83 100644 --- a/scripts/leaderboard.gd +++ b/scripts/leaderboard.gd @@ -1,27 +1,38 @@ extends Node -var COLLECTION_ID = "leaderboard" - func save_leaderboard_data(): var auth = Firebase.Auth.auth + var COLLECTION_ID = get_leaderboard_name() if auth.localid: var collection: FirestoreCollection = Firebase.Firestore.collection(COLLECTION_ID) var data : Dictionary = { "user_name" : GameGlobals.user_names[GameGlobals.user_id], - "leaderboard" : GameGlobals.current_leaderboard_name, + "leaderboard" : get_leaderboard_name(), "high_score" : GameGlobals.high_score, "money" : GameGlobals.money, "deposited_money" : GameGlobals.deposited_money } - var task = await collection.get_doc(auth.localid) - if task: - await collection.update(FirestoreDocument.new(data)) + print("DATA: ",data) + var document = await collection.get_doc(auth.localid) + if document: + for key in data.keys(): + document.add_or_update_field(key,data[key]) + var task = await collection.update(document) + if task: + print("Document updated successfully") + else: + print("Failed to update document") else: await collection.add(auth.localid,data) func load_leaderboard_data(): var auth = Firebase.Auth.auth + var COLLECTION_ID = get_leaderboard_name() if auth.localid: var collection: FirestoreCollection = Firebase.Firestore.collection(COLLECTION_ID) var task = await collection.get_doc(auth.localid) var result = task.get_value("high_score") + +func get_leaderboard_name(): + print("CURRENT LEADERBOARD ID",GameGlobals.all_user_leaderboards[GameGlobals.user_id][GameGlobals.last_leaderboard_id]) + return "leaderboard_" + str(GameGlobals.all_user_leaderboards[GameGlobals.user_id][GameGlobals.last_leaderboard_id]) From 92437ea3987d15e4d1a724d5f9f011b75a4296b8 Mon Sep 17 00:00:00 2001 From: derek Date: Wed, 9 Apr 2025 15:38:14 -0500 Subject: [PATCH 3/4] more tweaks to leaderboard, also changed how gun points during sway --- MainMenu.tscn | 15 ++++++++++++++- assets/LevelBlockouts/hub_1.tscn | 10 +++++++++- assets/jump_platform.tres | 2 +- assets/player.tscn | 3 +++ assets/realtime_day_night_cycle.tscn | 4 ++-- scripts/deposit_money.gd | 1 + scripts/leaderboard.gd | 14 ++++++++++---- scripts/main_menu.gd | 16 +++++++++++++++- scripts/player.gd | 7 +++++++ scripts/save_load.gd | 1 + scripts/stat_display.gd | 2 +- 11 files changed, 64 insertions(+), 11 deletions(-) diff --git a/MainMenu.tscn b/MainMenu.tscn index 209cdd6..ef22e9b 100644 --- a/MainMenu.tscn +++ b/MainMenu.tscn @@ -88,6 +88,17 @@ size_flags_horizontal = 4 size_flags_vertical = 4 theme_override_constants/separation = 30 +[node name="LoginStatusLabel" type="Label" parent="MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "Logged in with: " + +[node name="UserName" type="TextEdit" parent="MarginContainer/VBoxContainer"] +custom_minimum_size = Vector2(0, 150) +layout_mode = 2 +theme = ExtResource("2_3fflq") +text = "Profile 1" +placeholder_text = "Profile Name" + [node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] custom_minimum_size = Vector2(0, 75) layout_mode = 2 @@ -120,6 +131,7 @@ theme_override_font_sizes/font_size = 150 text = "exit" [node name="Add Leaderboard Menu" type="Control" parent="."] +visible = false layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -154,7 +166,7 @@ grow_vertical = 2 [node name="HBoxContainer" type="HBoxContainer" parent="Add Leaderboard Menu/MarginContainer"] layout_mode = 2 -[node name="NewLeaderboardName" type="TextEdit" parent="Add Leaderboard Menu/MarginContainer/HBoxContainer"] +[node name="NewLeaderboardName" type="LineEdit" parent="Add Leaderboard Menu/MarginContainer/HBoxContainer"] custom_minimum_size = Vector2(800, 0) layout_mode = 2 size_flags_vertical = 3 @@ -169,6 +181,7 @@ icon = ExtResource("7_ia0hc") [node name="Authentication" parent="." instance=ExtResource("17_m04lp")] layout_mode = 1 +[connection signal="text_changed" from="MarginContainer/VBoxContainer/UserName" to="." method="_on_user_name_text_changed"] [connection signal="item_selected" from="MarginContainer/VBoxContainer/HBoxContainer/OptionButton" to="." method="_on_option_button_item_selected"] [connection signal="pressed" from="MarginContainer/VBoxContainer/HBoxContainer/Add Leaderboard" to="." method="_on_add_leaderboard_pressed"] [connection signal="pressed" from="MarginContainer/VBoxContainer/Continue" to="." method="_on_continue_pressed"] diff --git a/assets/LevelBlockouts/hub_1.tscn b/assets/LevelBlockouts/hub_1.tscn index 312dbad..7d68e24 100644 --- a/assets/LevelBlockouts/hub_1.tscn +++ b/assets/LevelBlockouts/hub_1.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=29 format=3 uid="uid://otkecr0hcyon"] +[gd_scene load_steps=31 format=3 uid="uid://otkecr0hcyon"] [ext_resource type="PackedScene" uid="uid://bj1y0fbjtul4a" path="res://post_processing.tscn" id="1_48lr2"] [ext_resource type="PackedScene" uid="uid://drwae3loscbw7" path="res://assets/player.tscn" id="1_ibypk"] @@ -20,6 +20,8 @@ [ext_resource type="Terrain3DAssets" uid="uid://dal3jhw6241qg" path="res://demo/data/assets.tres" id="19_wqead"] [ext_resource type="PackedScene" uid="uid://b6d8oy7iuad4a" path="res://cloud1.tscn" id="20_wqead"] [ext_resource type="Script" uid="uid://1q8lyvac5gft" path="res://scripts/cloudSpawner.gd" id="21_mlcq0"] +[ext_resource type="PackedScene" uid="uid://bvjrsc86n2ak0" path="res://assets/deposit_money.tscn" id="21_v6g1i"] +[ext_resource type="PackedScene" uid="uid://bessq6hl7qsh8" path="res://assets/stats.tscn" id="22_v6g1i"] [sub_resource type="Gradient" id="Gradient_vr1m7"] offsets = PackedFloat32Array(0.2, 1) @@ -426,3 +428,9 @@ light_color = Color(1, 0.885, 0.77, 1) light_energy = 15.0 shadow_enabled = true spot_angle = 50.83 + +[node name="DEPOSIT MONEY" parent="." instance=ExtResource("21_v6g1i")] +transform = Transform3D(-0.993502, 0, -0.11381, 0, 1, 0, 0.11381, 0, -0.993502, 22.3872, 2.28075, -1.67325) + +[node name="Stats" parent="." instance=ExtResource("22_v6g1i")] +transform = Transform3D(-0.992703, 0, -0.120587, 0, 1, 0, 0.120587, 0, -0.992703, 18.3029, 3.10808, -2.49889) diff --git a/assets/jump_platform.tres b/assets/jump_platform.tres index 8b07ac2..b45f88e 100644 --- a/assets/jump_platform.tres +++ b/assets/jump_platform.tres @@ -1,4 +1,4 @@ -[gd_resource type="VisualShader" load_steps=31 format=3 uid="uid://dtleyj0ot0g1n"] +[gd_resource type="VisualShader" load_steps=31 format=3 uid="uid://moixdam5rxx7"] [sub_resource type="VisualShaderNodeFresnel" id="VisualShaderNodeFresnel_jqdis"] default_input_values = [2, false, 3, 1.5] diff --git a/assets/player.tscn b/assets/player.tscn index d40203b..e568fb0 100644 --- a/assets/player.tscn +++ b/assets/player.tscn @@ -222,6 +222,9 @@ target_position = Vector3(0, 0, -200) collision_mask = 105 collide_with_areas = true +[node name="GunLookTarget" type="Marker3D" parent="Head/Recoil/Camera3D/BulletRay"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.265447, 0, -4.48192) + [node name="InteractRay" type="RayCast3D" parent="Head/Recoil/Camera3D"] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.00322104, -0.0232438) target_position = Vector3(0, 0, -2.5) diff --git a/assets/realtime_day_night_cycle.tscn b/assets/realtime_day_night_cycle.tscn index a5e52aa..43a242b 100644 --- a/assets/realtime_day_night_cycle.tscn +++ b/assets/realtime_day_night_cycle.tscn @@ -12,8 +12,8 @@ gradient = SubResource("Gradient_idjmm") width = 24 [sub_resource type="Curve" id="Curve_idjmm"] -_limits = [0.0, 2.0, 0.0, 24.0] -_data = [Vector2(5, 0), 0.0, 0.66, 0, 0, Vector2(12, 2), 0.0, 0.0, 0, 0, Vector2(20, 0), -0.66, 0.0, 0, 0] +_limits = [0.0, 1.0, 0.0, 24.0] +_data = [Vector2(5, 0), 0.0, 0.44, 0, 0, Vector2(12, 1), 0.0, 0.0, 0, 0, Vector2(20, 0), -0.37, 0.0, 0, 0] point_count = 3 [sub_resource type="PhysicalSkyMaterial" id="PhysicalSkyMaterial_xp6wm"] diff --git a/scripts/deposit_money.gd b/scripts/deposit_money.gd index 41b3a14..36f76cc 100644 --- a/scripts/deposit_money.gd +++ b/scripts/deposit_money.gd @@ -17,3 +17,4 @@ func _process(delta: float) -> void: func interact(): SignalBus.emit_signal("money_deposited") + Leaderboard.save_leaderboard_data() diff --git a/scripts/leaderboard.gd b/scripts/leaderboard.gd index a4eae83..fefd4b2 100644 --- a/scripts/leaderboard.gd +++ b/scripts/leaderboard.gd @@ -28,11 +28,17 @@ func save_leaderboard_data(): func load_leaderboard_data(): var auth = Firebase.Auth.auth var COLLECTION_ID = get_leaderboard_name() + if auth.localid: - var collection: FirestoreCollection = Firebase.Firestore.collection(COLLECTION_ID) - var task = await collection.get_doc(auth.localid) - var result = task.get_value("high_score") + var collection : FirestoreCollection = Firebase.Firestore.collection(COLLECTION_ID) + + var document = await collection.get_doc(auth.localid) + + if document: + print(document) + return document + else: + print("Failed to load document from Firebase") func get_leaderboard_name(): - print("CURRENT LEADERBOARD ID",GameGlobals.all_user_leaderboards[GameGlobals.user_id][GameGlobals.last_leaderboard_id]) return "leaderboard_" + str(GameGlobals.all_user_leaderboards[GameGlobals.user_id][GameGlobals.last_leaderboard_id]) diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd index 38e588a..99c23a2 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -8,8 +8,11 @@ var last_scene @onready var option_button: OptionButton = $MarginContainer/VBoxContainer/HBoxContainer/OptionButton @onready var add_leaderboard: Button = $"MarginContainer/VBoxContainer/HBoxContainer/Add Leaderboard" @onready var add_leaderboard_menu: Control = $"Add Leaderboard Menu" -@onready var new_leaderboard_name: TextEdit = $"Add Leaderboard Menu/MarginContainer/HBoxContainer/NewLeaderboardName" +@onready var new_leaderboard_name: LineEdit = $"Add Leaderboard Menu/MarginContainer/HBoxContainer/NewLeaderboardName" @onready var confirm_leaderboard_add: Button = $"Add Leaderboard Menu/MarginContainer/HBoxContainer/Confirm" +@onready var user_name: TextEdit = $MarginContainer/VBoxContainer/UserName +@onready var login_status_label: Label = $MarginContainer/VBoxContainer/LoginStatusLabel + func _enter_tree() -> void: @@ -22,6 +25,9 @@ func _ready() -> void: playlist_generator.load_playlist() refresh_option_list() continue_text_check() + + user_name.text = GameGlobals.user_names[GameGlobals.user_id] + login_status() func _on_continue_pressed() -> void: @@ -79,3 +85,11 @@ func _on_confirm_pressed() -> void: func _on_option_button_item_selected(index: int) -> void: GameGlobals.last_leaderboard_id = option_button.selected continue_text_check() + + +func _on_user_name_text_changed() -> void: + GameGlobals.user_names[GameGlobals.user_id] = user_name.text + +func login_status(): + if Firebase.Auth.check_auth_file(): + login_status_label.text = "Logged in" diff --git a/scripts/player.gd b/scripts/player.gd index 40dc3a6..014cb2f 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -68,6 +68,7 @@ var gun_is_holstered = false @onready var level_control = get_tree().current_scene @onready var interact_ray = $Head/Recoil/Camera3D/InteractRay @onready var bullet_ray = $Head/Recoil/Camera3D/BulletRay +@onready var gun_look_target: Marker3D = $Head/Recoil/Camera3D/BulletRay/GunLookTarget @onready var wall_ray_1: RayCast3D = $wall_ray1 @onready var wall_ray_2: RayCast3D = $wall_ray2 @onready var wall_ray_3: RayCast3D = $wall_ray3 @@ -695,12 +696,18 @@ func weapon_tilt(input_x, delta): camera.rotation.z = lerp(camera.rotation.z, -input_x * HEAD_TILT_AMT, 5 * delta) func weapon_sway(delta): + #aim gun at center screen + if !gun_ray.is_colliding(): + gun.look_at(gun_look_target.global_position) + + #apply sway if !ads: var joy_input = Vector2(Input.get_joy_axis(0,JOY_AXIS_RIGHT_X)*5,Input.get_joy_axis(0,JOY_AXIS_RIGHT_Y)*5) mouse_input = lerp(mouse_input, Vector2.ZERO, 10 * delta) joy_input = lerp(joy_input,Vector2.ZERO,10 * delta) weapon_holder.rotation.x = lerp(weapon_holder.rotation.x, (mouse_input.y + joy_input.y) * weapon_sway_amount, 5 * delta) weapon_holder.rotation.y = lerp(weapon_holder.rotation.y, (mouse_input.x + joy_input.x) * weapon_sway_amount, 5 * delta) + else: if gun != null: if gun.weapon_info.ads == true: diff --git a/scripts/save_load.gd b/scripts/save_load.gd index cda3186..c014707 100644 --- a/scripts/save_load.gd +++ b/scripts/save_load.gd @@ -149,6 +149,7 @@ func load_user_data(): func save_game_data(level_name): + Leaderboard.save_leaderboard_data() var level_control = get_tree().current_scene var player = level_control.player var game_save_path = str("user://user_",str(GameGlobals.user_id),"_",str(GameGlobals.all_user_leaderboards[GameGlobals.user_id][GameGlobals.last_leaderboard_id]),"_last_level","_data.save") diff --git a/scripts/stat_display.gd b/scripts/stat_display.gd index dca81b1..f38001f 100644 --- a/scripts/stat_display.gd +++ b/scripts/stat_display.gd @@ -10,7 +10,7 @@ extends Node3D # Called when the node enters the scene tree for the first time. func _ready() -> void: - pass # Replace with function body. + Leaderboard.load_leaderboard_data() # Called every frame. 'delta' is the elapsed time since the previous frame. From 5d116b1a203fb78547ac71d468bfa9bab31c741e Mon Sep 17 00:00:00 2001 From: derek Date: Wed, 9 Apr 2025 15:51:48 -0500 Subject: [PATCH 4/4] fixed mac 10 mag spawning by just pushing the mark functions closer to each other --- assets/mac_10.tscn | 8 ++++---- scripts/weapon_uberscript.gd | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/assets/mac_10.tscn b/assets/mac_10.tscn index 6702e52..365ecb9 100644 --- a/assets/mac_10.tscn +++ b/assets/mac_10.tscn @@ -539,17 +539,17 @@ tracks/7/path = NodePath(".") tracks/7/interp = 1 tracks/7/loop_wrap = true tracks/7/keys = { -"times": PackedFloat32Array(0, 0.28, 0.2801, 0.95), +"times": PackedFloat32Array(0, 0.17, 0.28, 0.95), "transitions": PackedFloat32Array(1, 1, 1, 1), "values": [{ "args": [], "method": &"mark_anim_rot" }, { "args": [], -"method": &"spawn_mag" +"method": &"mark_anim_rot" }, { "args": [], -"method": &"mark_anim_rot" +"method": &"spawn_mag" }, { "args": [], "method": &"reload_finished" @@ -748,7 +748,7 @@ audio_empty = NodePath("Audio/Empty") audio_reload = NodePath("Audio/Reload") [node name="mac10" type="MeshInstance3D" parent="."] -transform = Transform3D(-1.086e-06, 0, -0.3, 0, 0.3, 0, 0.3, 0, -1.086e-06, 0, 0, 0) +transform = Transform3D(0.00547455, 0.00277349, -0.299937, -0.0256886, 0.298889, 0.00229492, 0.298848, 0.0256413, 0.00569177, -0.0109618, 0.0121364, 9.31208e-05) cast_shadow = 0 lod_bias = 10.0 mesh = SubResource("ArrayMesh_pcg38") diff --git a/scripts/weapon_uberscript.gd b/scripts/weapon_uberscript.gd index 1125751..7d838e4 100644 --- a/scripts/weapon_uberscript.gd +++ b/scripts/weapon_uberscript.gd @@ -162,14 +162,13 @@ func reload(): func spawn_mag(): var instance_mag = weapon_info.mag.instantiate() - if spawn_av_lv.size() == 1: - await spawn_av_lv.size() ==2 var anim_velocity = solve_anim_av_lv() instance_mag.position = mag_ejector.global_position instance_mag.transform.basis = mag_ejector.global_transform.basis instance_mag.linear_velocity += global_transform.basis * (anim_velocity["lv"] * 5) + player.velocity instance_mag.angular_velocity += global_transform.basis * anim_velocity["av"] + get_tree().get_root().add_child(instance_mag) func spawn_casing():