mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-21 16:32:39 +00:00
Compare commits
3
Commits
1.9.0
...
web-portal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
208819436a | ||
|
|
d7c9e82fc2 | ||
|
|
8448b3f2a9 |
No files matched your search
@@ -114,6 +114,13 @@ dependencies {
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.38.1")
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.38.1")
|
||||
implementation ("androidx.browser:browser:1.9.0")
|
||||
|
||||
implementation ("io.ktor:ktor-server-core-jvm:3.3.3")
|
||||
implementation ("io.ktor:ktor-server-cio:3.3.3")
|
||||
implementation ("io.ktor:ktor-utils-jvm:3.3.3")
|
||||
implementation ("io.ktor:ktor-server-status-pages:3.3.3")
|
||||
implementation ("io.ktor:ktor-server-sse:3.3.3")
|
||||
|
||||
debugImplementation ("androidx.compose.ui:ui-tooling")
|
||||
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
Vendored
+1
@@ -5,3 +5,4 @@
|
||||
|
||||
# note: if you're going to add more rules, consider the @Keep annotation
|
||||
# this should really only be kept to non-custom code
|
||||
-dontwarn java.lang.management.**
|
||||
@@ -22,6 +22,8 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.wifi"
|
||||
@@ -156,6 +158,12 @@
|
||||
<meta-data
|
||||
android:name="android.game_mode_config"
|
||||
android:resource="@xml/game_mode_config" />
|
||||
|
||||
<service
|
||||
android:name=".DeveloperServerService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false">
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,376 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Geode Android Web Interface</title>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Quicksand:wght@300..700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
.logo {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.listing-main {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Poppins", sans-serif;
|
||||
}
|
||||
|
||||
#file-name {
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#logs-area {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
height: 15rem;
|
||||
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1 class="title">
|
||||
<svg
|
||||
width="698"
|
||||
height="698"
|
||||
viewBox="0 0 696 698"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="logo"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M393.076 24.4223L372.884 9.00248C358.192 -2.21718 337.808 -2.2172 323.116 9.00246L16.9433 242.812C3.28395 253.243 -2.41995 271.086 2.656 287.506L120.413 668.435C125.723 685.612 141.605 697.326 159.584 697.326H536.416C554.395 697.326 570.277 685.612 575.587 668.435L693.344 287.506C698.42 271.086 692.716 253.243 679.057 242.812L650.835 221.26L618.374 323.385L529.198 613.394C525.713 624.725 515.245 632.458 503.39 632.458H190.61C178.755 632.458 168.287 624.725 164.802 613.394L67.1963 295.971C63.8815 285.191 67.6162 273.487 76.562 266.619L330.558 71.6226C331.579 70.8386 332.643 70.1371 333.739 69.5183L393.076 24.4223Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M336.614 143.264C344.511 137.209 355.489 137.209 363.386 143.264L552.797 288.49C560.096 294.086 563.143 303.632 560.437 312.422L487.62 548.924C484.778 558.153 476.251 564.451 466.594 564.451H233.406C223.749 564.451 215.222 558.153 212.38 548.924L199.934 508.5H261.676C262.393 508.647 263.134 508.724 263.888 508.724H432.112C436.963 508.724 441.241 505.546 442.643 500.903L495.141 326.982C496.452 322.638 494.961 317.934 491.386 315.139L354.777 208.3C350.795 205.186 345.205 205.186 341.223 208.3L215.925 306.292L152.151 353.307L139.563 312.422C136.857 303.632 139.904 294.086 147.203 288.49L336.614 143.264Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Geode Android Manager
|
||||
</h1>
|
||||
|
||||
<div class="listing-main">
|
||||
<section>
|
||||
<label for="path-listing">
|
||||
Directory Listing:
|
||||
</label> <br />
|
||||
<select id="path-listing" size=25>
|
||||
</select>
|
||||
|
||||
<br />
|
||||
|
||||
<label for="path-name">Path:</label>
|
||||
<input type="text" id="path-name" />
|
||||
<button id="traverse-btn">Go</button>
|
||||
|
||||
<!--br />
|
||||
|
||||
<input type="file" id="upload-file">
|
||||
<button id="upload-btn">Upload File</button-->
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span id="file-name"></span></h2>
|
||||
|
||||
<div class="hidden" id="file-metadata">
|
||||
<p>
|
||||
Size: <span id="file-size"></span> <br />
|
||||
Modified At: <span id="file-modified"></span> <br />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<!-- button id="file-replace">Replace</button>
|
||||
<button id="file-delete">Delete</button>
|
||||
|
||||
<br /-->
|
||||
|
||||
<a href="" download id="file-download">Download</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Logs</h2>
|
||||
|
||||
<pre id="logs-area"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const basePath = "/";
|
||||
|
||||
/** @type {HTMLSelectElement | null} */
|
||||
const pathListing = document.querySelector("#path-listing");
|
||||
|
||||
/** @type {HTMLInputElement | null} */
|
||||
const pathName = document.querySelector("#path-name");
|
||||
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
const goBtn = document.querySelector("#traverse-btn");
|
||||
|
||||
/** @type {HTMLSpanElement | null} */
|
||||
const fileName = document.querySelector("#file-name");
|
||||
|
||||
/** @type {HTMLSpanElement | null} */
|
||||
const fileSize = document.querySelector("#file-size");
|
||||
|
||||
/** @type {HTMLSpanElement | null} */
|
||||
const fileModified = document.querySelector("#file-modified");
|
||||
|
||||
/** @type {HTMLAnchorElement | null} */
|
||||
const fileDownload = document.querySelector("#file-download");
|
||||
|
||||
/** @type {HTMLDivElement | null} */
|
||||
const fileMetadata = document.querySelector("#file-metadata");
|
||||
|
||||
/** @type {HTMLPreElement | null} */
|
||||
const logsArea = document.querySelector("#logs-area");
|
||||
|
||||
const currentListing = {};
|
||||
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes == 0) return "0 B";
|
||||
|
||||
const base = 1024;
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
||||
const unitIndex = Math.floor(Math.log(bytes) / Math.log(base));
|
||||
|
||||
const formattedValue = (bytes / Math.pow(base, unitIndex)).toFixed(decimals);
|
||||
|
||||
return `${formattedValue} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
async function fetchDirectoryListing(path) {
|
||||
goBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await fetch(`${basePath}files/${path}`);
|
||||
const json = await data.json();
|
||||
|
||||
const selectElements = json.sort((a, b) => {
|
||||
const aIsDir = a.type == "directory";
|
||||
const bIsDir = b.type == "directory";
|
||||
|
||||
if (aIsDir && !bIsDir) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!aIsDir && bIsDir) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return a.filename.localeCompare(b.filename);
|
||||
}).map((f) => {
|
||||
currentListing[f.filename] = f;
|
||||
|
||||
const optionElement = document.createElement("option");
|
||||
optionElement.value = f.filename;
|
||||
|
||||
if (f.type == "directory") {
|
||||
optionElement.innerText = "📁 " + f.filename;
|
||||
} else {
|
||||
optionElement.innerText = f.filename;
|
||||
}
|
||||
|
||||
return optionElement;
|
||||
});
|
||||
|
||||
const parentElement = document.createElement("option");
|
||||
parentElement.value = "..";
|
||||
parentElement.innerText = "⤴️ ..";
|
||||
|
||||
selectElements.unshift(parentElement);
|
||||
|
||||
pathListing.replaceChildren(...selectElements);
|
||||
|
||||
pathName.value = path;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
goBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileData() {
|
||||
if (pathListing.value == "..") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFile = currentListing[pathListing.value];
|
||||
|
||||
fileName.innerText = currentFile.filename;
|
||||
|
||||
if (currentFile.type == "directory") {
|
||||
fileMetadata.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
fileMetadata.classList.remove("hidden");
|
||||
|
||||
fileSize.innerText = formatBytes(currentFile.size);
|
||||
const modifiedDate = new Date(currentFile.lastModified);
|
||||
fileModified.innerText = new Intl.DateTimeFormat("default", {
|
||||
timeStyle: "long",
|
||||
dateStyle: "long",
|
||||
}).format(modifiedDate);
|
||||
fileDownload.href = `${basePath}files${updatePath(currentFile.filename)}`;
|
||||
}
|
||||
|
||||
function updatePath(append) {
|
||||
const currentPath = pathName.value;
|
||||
|
||||
if (append == "..") {
|
||||
if (currentPath == "/") {
|
||||
return "/"
|
||||
} else {
|
||||
const path = currentPath.split("/");
|
||||
path.pop();
|
||||
|
||||
return path.join("/");
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPath.endsWith("/")) {
|
||||
return currentPath + append;
|
||||
} else {
|
||||
return `${currentPath}/${append}`;
|
||||
}
|
||||
}
|
||||
|
||||
pathListing.addEventListener("dblclick", async (event) => {
|
||||
const clickedOption = event.target.value;
|
||||
|
||||
if (clickedOption == "..") {
|
||||
const newPath = updatePath("..");
|
||||
await fetchDirectoryListing(newPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = currentListing[clickedOption];
|
||||
|
||||
if (selected.type == "directory") {
|
||||
const newPath = updatePath(selected.filename);
|
||||
await fetchDirectoryListing(newPath);
|
||||
} else {
|
||||
fileDownload.click();
|
||||
}
|
||||
});
|
||||
|
||||
pathListing.addEventListener("change", (event) => {
|
||||
const clickedOption = event.target.value;
|
||||
|
||||
updateFileData();
|
||||
});
|
||||
|
||||
goBtn.addEventListener("click", async () => {
|
||||
const goto = pathName.value;
|
||||
await fetchDirectoryListing(goto);
|
||||
});
|
||||
|
||||
function formatLog(line) {
|
||||
/*
|
||||
{
|
||||
"process": {
|
||||
"processId": 31123,
|
||||
"threadId": 31123,
|
||||
"processUid": 10486
|
||||
},
|
||||
"time": "2025-12-17T10:38:19.488696104Z",
|
||||
"logId": 4,
|
||||
"priority": "error",
|
||||
"tag": "AndroidRuntime",
|
||||
"message": "..."
|
||||
},
|
||||
*/
|
||||
|
||||
return `[${line.priority.charAt(0)}] ${line.time} - [${line.tag}] ${line.message}`
|
||||
}
|
||||
|
||||
async function updateLogs() {
|
||||
const data = await fetch(`${basePath}logs`);
|
||||
const json = await data.json();
|
||||
|
||||
const lines = json.map((l) => formatLog(l));
|
||||
const message = lines.join("\n");
|
||||
|
||||
logsArea.innerText = message;
|
||||
logsArea.scrollTop = logsArea.scrollHeight - logsArea.clientHeight
|
||||
}
|
||||
|
||||
const unprocessedLogs = [];
|
||||
|
||||
function onTick() {
|
||||
const batch = unprocessedLogs.splice(0, 100);
|
||||
|
||||
if (batch.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = batch.map((l) => formatLog(JSON.parse(l))).join("\n");
|
||||
|
||||
const isScrolledToBottom = (logsArea.scrollHeight - logsArea.clientHeight) <= logsArea.scrollTop + 1;
|
||||
|
||||
logsArea.innerText += `\n${formatted}`;
|
||||
|
||||
if (isScrolledToBottom) {
|
||||
logsArea.scrollTop = logsArea.scrollHeight - logsArea.clientHeight
|
||||
}
|
||||
}
|
||||
|
||||
function animationFrame() {
|
||||
onTick();
|
||||
|
||||
requestAnimationFrame(animationFrame);
|
||||
}
|
||||
|
||||
function openLogsStream() {
|
||||
const evtSource = new EventSource(`${basePath}logs_live`);
|
||||
|
||||
// we store new logs to process on the next frame, to avoid slowdowns when lots of logs come in at once
|
||||
evtSource.addEventListener("log", (event) => {
|
||||
unprocessedLogs.push(event.data);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async (event) => {
|
||||
await fetchDirectoryListing("/");
|
||||
|
||||
await updateLogs();
|
||||
|
||||
openLogsStream();
|
||||
|
||||
requestAnimationFrame(animationFrame);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -24,7 +24,7 @@ object BaseRobTopActivity {
|
||||
|
||||
var isPaused = false
|
||||
|
||||
lateinit var me: WeakReference<Activity>
|
||||
var me: WeakReference<Activity> = WeakReference(null)
|
||||
private var shouldResumeSound = true
|
||||
|
||||
fun setCurrentActivity(currentActivity: Activity) {
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package com.geode.launcher
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import com.geode.launcher.log.LogLine
|
||||
import com.geode.launcher.utils.DownloadUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okio.Buffer
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import java.io.EOFException
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.DirectoryNotEmptyException
|
||||
import kotlin.collections.plusAssign
|
||||
import kotlin.io.path.deleteExisting
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.isDirectory
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.content.PartData
|
||||
import io.ktor.http.content.forEachPart
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.cio.*
|
||||
import io.ktor.server.http.content.staticFiles
|
||||
import io.ktor.server.plugins.statuspages.StatusPages
|
||||
import io.ktor.server.request.receiveChannel
|
||||
import io.ktor.server.request.receiveMultipart
|
||||
import io.ktor.server.sse.*
|
||||
import io.ktor.sse.ServerSentEvent
|
||||
import io.ktor.util.cio.writeChannel
|
||||
import io.ktor.utils.io.copyAndClose
|
||||
|
||||
enum class FileType {
|
||||
@SerialName("file")
|
||||
FILE,
|
||||
@SerialName("directory")
|
||||
DIRECTORY
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class FileMeta(val filename: String, val type: FileType, val size: Long, val lastModified: Instant)
|
||||
|
||||
class DeveloperServerService : Service() {
|
||||
private val binder = LocalBinder()
|
||||
|
||||
private var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getService(): DeveloperServerService = this@DeveloperServerService
|
||||
}
|
||||
|
||||
private fun startForeground() {
|
||||
try {
|
||||
val notification = NotificationCompat.Builder(this, "geode_developer_server")
|
||||
.setSmallIcon(R.drawable.geode_monochrome)
|
||||
.build()
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
100,
|
||||
notification,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
/*
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
&& e is ForegroundServiceStartNotAllowedException
|
||||
) {
|
||||
println("idk")
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
server?.stop()
|
||||
server = null
|
||||
}
|
||||
|
||||
// not recursive copy
|
||||
private fun copyIndexToCache() {
|
||||
val folderName = "web_interface"
|
||||
val base = File(application.cacheDir, folderName)
|
||||
|
||||
base.deleteRecursively()
|
||||
base.mkdirs()
|
||||
|
||||
assets.list(folderName)?.forEach {
|
||||
val assetName = "$folderName/$it"
|
||||
val outputPath = File(base, it)
|
||||
assets.open(assetName).use { inputStream ->
|
||||
outputPath.outputStream().use { outputStream ->
|
||||
DownloadUtils.copyFile(inputStream, outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLogsFlow(): Flow<LogLine> = flow {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val seconds = timestamp / 1000
|
||||
val ms = timestamp % 1000
|
||||
|
||||
// Time format is 'MM-DD hh:mm:ss.mmm...', 'YYYY-MM-DD hh:mm:ss.mmm...', or 'sssss.mmm...'.
|
||||
val timeString = "$seconds.$ms"
|
||||
|
||||
val logCommand = "logcat -B -T $timeString"
|
||||
|
||||
val logProcess = try {
|
||||
Runtime.getRuntime().exec(logCommand)
|
||||
} catch (ioe: IOException) {
|
||||
ioe.printStackTrace()
|
||||
emit(LogLine.showException(ioe))
|
||||
|
||||
return@flow
|
||||
}
|
||||
|
||||
val logBuffer = logProcess.inputStream.source().buffer()
|
||||
|
||||
try {
|
||||
// this runs until the stream is exhausted
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val line = LogLine.fromBufferedSource(logBuffer)
|
||||
emit(line)
|
||||
|
||||
yield()
|
||||
}
|
||||
} catch (_: EOFException) {
|
||||
// ignore, end of file reached
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
emit(LogLine.showException(e))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readLogs(): List<LogLine> {
|
||||
val logLines = mutableListOf<LogLine>()
|
||||
|
||||
val logCommand = "logcat -B -d"
|
||||
val logProcess = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
Runtime.getRuntime().exec(logCommand)
|
||||
}
|
||||
} catch (ioe: IOException) {
|
||||
ioe.printStackTrace()
|
||||
logLines += LogLine.showException(ioe)
|
||||
|
||||
return logLines
|
||||
}
|
||||
|
||||
// read entire log into a buffer so no logs are added to the buffer during processing
|
||||
val logBuffer = Buffer()
|
||||
logProcess.inputStream.source().buffer().readAll(logBuffer)
|
||||
|
||||
try {
|
||||
coroutineScope {
|
||||
// this runs until the stream is exhausted
|
||||
while (true) {
|
||||
val line = LogLine.fromBufferedSource(logBuffer)
|
||||
logLines += line
|
||||
|
||||
yield()
|
||||
}
|
||||
}
|
||||
} catch (_: EOFException) {
|
||||
// ignore, end of file reached
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
logLines += LogLine.showException(e)
|
||||
}
|
||||
|
||||
return logLines
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val staticDir = File(application.cacheDir, "web_interface")
|
||||
val baseDir = LaunchUtils.getBaseDirectory(application, true)
|
||||
|
||||
val logsFlow = createLogsFlow()
|
||||
|
||||
server = embeddedServer(CIO, port = 9043) {
|
||||
routing {
|
||||
install(SSE)
|
||||
|
||||
install(StatusPages) {
|
||||
exception<Throwable> { call, cause ->
|
||||
call.respondText(text = "500: $cause" , status = HttpStatusCode.InternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
get("/logs") {
|
||||
val logs = Json.encodeToString(readLogs())
|
||||
call.respondText(logs, contentType = ContentType.Application.Json)
|
||||
}
|
||||
|
||||
post("/stop") {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
sse("/logs_live") {
|
||||
heartbeat {
|
||||
period = 5000.milliseconds
|
||||
event = ServerSentEvent(comments = "heartbeat")
|
||||
}
|
||||
logsFlow.collect {
|
||||
val log = Json.encodeToString(it)
|
||||
send(log, event = "log", id = it.logId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
staticFiles("/", staticDir)
|
||||
|
||||
staticFiles("/files", baseDir, index = null) {
|
||||
fallback { requestedPath, call ->
|
||||
val basePath = baseDir.toPath()
|
||||
val builtPath = basePath.resolve(requestedPath).normalize()
|
||||
if (!builtPath.startsWith(basePath) || !builtPath.exists()) {
|
||||
// block path traversal issues
|
||||
call.response.status(HttpStatusCode.NotFound)
|
||||
} else {
|
||||
val filenames = builtPath.toFile().listFiles()?.map {
|
||||
FileMeta(
|
||||
filename = it.name,
|
||||
type = if (it.isDirectory) FileType.DIRECTORY else FileType.FILE,
|
||||
size = it.length(),
|
||||
lastModified = Instant.fromEpochMilliseconds(it.lastModified())
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
val data = Json.encodeToString(filenames)
|
||||
|
||||
call.respondText(data, contentType = ContentType.Application.Json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete("/files/{path...}") {
|
||||
val pathSegments = call.parameters.getAll("path")
|
||||
val requestedPath = pathSegments?.joinToString("/") ?: ""
|
||||
|
||||
val basePath = baseDir.toPath()
|
||||
val builtPath = basePath.resolve(requestedPath).normalize()
|
||||
if (!builtPath.startsWith(basePath) || !builtPath.exists()) {
|
||||
call.response.status(HttpStatusCode.NotFound)
|
||||
} else {
|
||||
try {
|
||||
builtPath.deleteExisting()
|
||||
call.response.status(HttpStatusCode.NoContent)
|
||||
} catch (e: DirectoryNotEmptyException) {
|
||||
val message = Json.encodeToString(buildJsonObject {
|
||||
put("message", "Provided directory $requestedPath was not empty")
|
||||
})
|
||||
|
||||
call.respondText(message, status = HttpStatusCode.Conflict, contentType = ContentType.Application.Json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
put("/files/{path...}") {
|
||||
val pathSegments = call.parameters.getAll("path")
|
||||
val requestedPath = pathSegments?.joinToString("/") ?: ""
|
||||
|
||||
val basePath = baseDir.toPath()
|
||||
val builtPath = basePath.resolve(requestedPath).normalize()
|
||||
if (!builtPath.startsWith(basePath)) {
|
||||
call.response.status(HttpStatusCode.NotFound)
|
||||
} else {
|
||||
if (builtPath.isDirectory()) {
|
||||
val multipart = call.receiveMultipart(formFieldLimit = 1024 * 1024 * 100)
|
||||
multipart.forEachPart { part ->
|
||||
if (part is PartData.FileItem) {
|
||||
val fileName = part.originalFileName ?: "uploaded_file"
|
||||
|
||||
val outputPath = builtPath.resolve(fileName).normalize()
|
||||
if (!outputPath.startsWith(basePath)) {
|
||||
val outputFile = outputPath.toFile()
|
||||
part.provider().copyAndClose(outputFile.writeChannel())
|
||||
}
|
||||
}
|
||||
part.dispose()
|
||||
}
|
||||
} else {
|
||||
val outputFile = builtPath.toFile()
|
||||
outputFile.parentFile?.mkdirs()
|
||||
|
||||
call.receiveChannel().copyAndClose(outputFile.writeChannel())
|
||||
}
|
||||
|
||||
call.response.status(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
this.startForeground()
|
||||
|
||||
copyIndexToCache()
|
||||
server?.start(wait = false)
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.geode.launcher
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -25,18 +28,18 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geode.launcher.main.*
|
||||
import com.geode.launcher.updater.ReleaseViewModel
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.ui.theme.launcherTitleStyle
|
||||
import com.geode.launcher.updater.ReleaseViewModel
|
||||
import com.geode.launcher.utils.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -61,6 +64,23 @@ class MainActivity : ComponentActivity() {
|
||||
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
|
||||
} else { null }
|
||||
|
||||
if (PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.DEVELOPER_SERVER)) {
|
||||
val intent = Intent(this, DeveloperServerService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
val name = getString(R.string.notification_channel_development_server)
|
||||
val descriptionText = getString(R.string.notification_channel_development_server_description)
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val mChannel = NotificationChannel("geode_developer_server", name, importance)
|
||||
mChannel.description = descriptionText
|
||||
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(mChannel)
|
||||
|
||||
this.startForegroundService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
val redirectToAlt = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
|
||||
if (redirectToAlt) {
|
||||
val launchIntent = Intent(this, AltMainActivity::class.java)
|
||||
|
||||
@@ -45,7 +45,6 @@ class CrashViewModel(private val application: Application) : ViewModel() {
|
||||
|
||||
private var logJob: Job? = null
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private fun getCrashList(): List<CrashDump> {
|
||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
||||
if (!crashDirectory.exists()) {
|
||||
|
||||
@@ -4,6 +4,9 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import java.io.IOException
|
||||
@@ -26,10 +29,29 @@ fun BufferedSource.readCString(): String {
|
||||
return buffer.toString()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int)
|
||||
|
||||
@Serializable
|
||||
enum class LogPriority {
|
||||
UNKNOWN, DEFAULT, VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL, SILENT;
|
||||
@SerialName("unknown")
|
||||
UNKNOWN,
|
||||
@SerialName("default")
|
||||
DEFAULT,
|
||||
@SerialName("verbose")
|
||||
VERBOSE,
|
||||
@SerialName("debug")
|
||||
DEBUG,
|
||||
@SerialName("info")
|
||||
INFO,
|
||||
@SerialName("warn")
|
||||
WARN,
|
||||
@SerialName("error")
|
||||
ERROR,
|
||||
@SerialName("fatal")
|
||||
FATAL,
|
||||
@SerialName("silent")
|
||||
SILENT;
|
||||
|
||||
class LogPriorityIterator(
|
||||
start: LogPriority,
|
||||
@@ -116,7 +138,8 @@ enum class LogPriority {
|
||||
/**
|
||||
* Represents a log entry from logcat.
|
||||
*/
|
||||
data class LogLine @OptIn(ExperimentalTime::class) constructor(
|
||||
@Serializable
|
||||
data class LogLine(
|
||||
val process: ProcessInformation,
|
||||
val time: Instant,
|
||||
val logId: Int,
|
||||
@@ -198,11 +221,10 @@ data class LogLine @OptIn(ExperimentalTime::class) constructor(
|
||||
time = time,
|
||||
logId = lid,
|
||||
tag = tag,
|
||||
message = message
|
||||
message = message.trimEnd('\u0000')
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun showException(exception: Exception) = LogLine(
|
||||
process = ProcessInformation(0, 0, 0),
|
||||
time = Clock.System.now(),
|
||||
@@ -213,15 +235,15 @@ data class LogLine @OptIn(ExperimentalTime::class) constructor(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Transient
|
||||
val identifier = time.toJavaInstant()
|
||||
|
||||
val messageTrimmed by lazy {
|
||||
this.message.trim { it <= ' ' }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
|
||||
|
||||
val asSimpleString by lazy {
|
||||
"$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
@@ -192,6 +193,18 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
||||
description = LaunchUtils.getBaseDirectory(context).path,
|
||||
onClick = { onOpenFolder(context) }
|
||||
)
|
||||
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_development_server_name),
|
||||
description = stringResource(R.string.preference_development_server_description),
|
||||
preferenceKey = PreferenceUtils.Key.DEVELOPER_SERVER,
|
||||
) { enabled ->
|
||||
if (enabled) {
|
||||
|
||||
} else {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
|
||||
|
||||
@@ -925,7 +925,7 @@ fun OptionsLabel(title: String, description: String? = null, icon: (@Composable
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, asCard: Boolean = true, preferenceKey: PreferenceUtils.Key) {
|
||||
fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, asCard: Boolean = true, preferenceKey: PreferenceUtils.Key, onChange: ((Boolean) -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
val settingEnabled = remember {
|
||||
mutableStateOf(getSetting(context, preferenceKey))
|
||||
@@ -941,7 +941,10 @@ fun SettingsCard(title: String, description: String? = null, icon: (@Composable
|
||||
},
|
||||
modifier = Modifier.toggleable(
|
||||
value = settingEnabled.value,
|
||||
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
|
||||
onValueChange = {
|
||||
settingEnabled.value = toggleSetting(context, preferenceKey)
|
||||
onChange?.invoke(settingEnabled.value)
|
||||
},
|
||||
role = Role.Switch,
|
||||
),
|
||||
backgroundColor = if (asCard)
|
||||
|
||||
@@ -149,7 +149,8 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
SCREEN_ZOOM_FIT,
|
||||
LIMIT_FRAME_RATE,
|
||||
DISABLE_USER_THEME,
|
||||
SELECTED_ICON
|
||||
SELECTED_ICON,
|
||||
DEVELOPER_SERVER,
|
||||
}
|
||||
|
||||
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
||||
@@ -200,6 +201,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate"
|
||||
Key.DISABLE_USER_THEME -> "PreferenceDisableUserTheme"
|
||||
Key.SELECTED_ICON -> "PreferenceSelectedIcon"
|
||||
Key.DEVELOPER_SERVER -> "PreferenceDeveloperServer"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,6 +262,12 @@
|
||||
<string name="preference_override_exceptions_description">Uses exception_symbols.txt to patch</string>
|
||||
<string name="preference_use_index_update_api">Use index release API</string>
|
||||
|
||||
<string name="preference_development_server_name">Enable development server</string>
|
||||
<string name="preference_development_server_description">Opens a public development server on port 9043.</string>
|
||||
|
||||
<string name="notification_channel_development_server">Development Server Service</string>
|
||||
<string name="notification_channel_development_server_description">Indicates the development server is currently active.</string>
|
||||
|
||||
<!-- CrashLogsActivity strings -->
|
||||
<string name="title_activity_crash_logs">Crash dumps</string>
|
||||
<string name="application_crashes_no_dumps">No crash dumps available.</string>
|
||||
|
||||
Reference in new issue
Block a user